fix: 修正 logo 中 N 和 G 字母造型

N 添加对角线笔画(█▄ █),G 添加内横杠(█ ▀█),
避免与 O 字母造型雷同。同步更新 ui.ts 中的硬编码 wordmark。
This commit is contained in:
airlongdian
2026-06-14 09:48:03 +08:00
commit 9ea05df273
5757 changed files with 1170016 additions and 0 deletions

4
packages/tui/bunfig.toml Normal file
View File

@@ -0,0 +1,4 @@
preload = ["@opentui/solid/preload"]
[test]
preload = ["@opentui/solid/preload"]

72
packages/tui/package.json Normal file
View File

@@ -0,0 +1,72 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.17.4",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.tsx",
"./builtins": "./src/feature-plugins/builtins.ts",
"./config": "./src/config/index.tsx",
"./context/args": "./src/context/args.tsx",
"./context/epilogue": "./src/context/epilogue.tsx",
"./context/exit": "./src/context/exit.tsx",
"./context/kv": "./src/context/kv.tsx",
"./context/project": "./src/context/project.tsx",
"./context/runtime": "./src/context/runtime.tsx",
"./context/sdk": "./src/context/sdk.tsx",
"./context/sync": "./src/context/sync.tsx",
"./context/theme": "./src/context/theme.tsx",
"./context/editor": "./src/context/editor.ts",
"./context/clipboard": "./src/context/clipboard.tsx",
"./attention": "./src/attention.ts",
"./editor": "./src/editor.ts",
"./editor-zed": "./src/editor-zed.ts",
"./runtime": "./src/runtime.tsx",
"./terminal-win32": "./src/terminal-win32.ts",
"./config/keybind": "./src/config/keybind.ts",
"./keymap": "./src/keymap.tsx",
"./prompt/display": "./src/prompt/display.ts",
"./plugin/runtime": "./src/plugin/runtime.tsx",
"./plugin/slots": "./src/plugin/slots.tsx",
"./plugin/command-shim": "./src/plugin/command-shim.ts",
"./parsers-config": "./src/parsers-config.ts",
"./util/error": "./src/util/error.ts",
"./util/locale": "./src/util/locale.ts",
"./util/persistence": "./src/util/persistence.ts",
"./util/record": "./src/util/record.ts",
"./logo": "./src/logo.ts",
"./ui/dialog": "./src/ui/dialog.tsx",
"./ui/spinner": "./src/ui/spinner.ts",
"./ui/toast": "./src/ui/toast.tsx",
"./component/spinner": "./src/component/spinner.tsx"
},
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"strip-ansi": "7.1.2",
"solid-js": "catalog:"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
}
}

1101
packages/tui/src/app.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
/// <reference path="./audio.d.ts" />
import type {
TuiAttention,
TuiAttentionNotifyInput,
TuiAttentionNotifyResult,
TuiAttentionNotifySkipReason,
TuiAttentionWhen,
TuiKV,
TuiAttentionSoundName,
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
} from "@opencode-ai/plugin/tui"
import { AttentionSoundName, type TuiConfig } from "./config"
import { Schema } from "effect"
import stripAnsi from "strip-ansi"
import * as TuiAudio from "./audio"
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
type FocusState = "unknown" | "focused" | "blurred"
type AttentionRenderer = {
readonly isDestroyed: boolean
on(event: "focus" | "blur", listener: () => void): unknown
off(event: "focus" | "blur", listener: () => void): unknown
triggerNotification(message: string, title?: string): boolean
}
type RegisteredSoundPack = TuiAttentionSoundPack & {
builtin: boolean
}
type TuiAttentionHost = TuiAttention & {
dispose(): void
}
const DEFAULT_TITLE = "opencode"
const DEFAULT_PACK_ID = "opencode.default"
const KV_SOUND_PACK = "attention_sound_pack"
const TITLE_LIMIT = 80
const MESSAGE_LIMIT = 240
const BUILTIN_PACK: RegisteredSoundPack = {
id: DEFAULT_PACK_ID,
name: "OpenCode Default",
builtin: true,
sounds: {
default: defaultSoundPath,
question: questionSoundPath,
permission: permissionSoundPath,
error: errorSoundPath,
done: doneSoundPath,
subagent_done: subagentDoneSoundPath,
},
}
function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
return {
ok: false,
notification: false,
sound: false,
skipped: reason,
}
}
function normalizeText(input: string | undefined, fallback: string, limit: number) {
const text = stripAnsi(input ?? "")
.replace(/[ \t]*[\r\n]+[ \t]*/g, " ")
.replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
.trim()
const normalized = text.length ? text : fallback
return Array.from(normalized).slice(0, limit).join("")
}
function clampVolume(volume: number) {
if (!Number.isFinite(volume)) return 0
return Math.min(1, Math.max(0, volume))
}
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
if (!config.attention.sound) return
if (input.sound === false) return
if (input.sound === undefined) return clampVolume(config.attention.volume)
if (input.sound === true) return clampVolume(config.attention.volume)
return clampVolume(input.sound.volume ?? config.attention.volume)
}
function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
const id = pack.id.trim()
if (!id) return
return {
id,
name: pack.name?.trim() || undefined,
builtin: false,
sounds: Object.fromEntries(
Object.entries(pack.sounds).filter(
(item): item is [TuiAttentionSoundName, string] =>
Schema.is(AttentionSoundName)(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
),
),
}
}
function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
if (when === "always") return
if (focus === "unknown") return "focus_unknown"
if (when === "blurred" && focus === "focused") return "focused"
if (when === "focused" && focus === "blurred") return "blurred"
}
export function createTuiAttention(input: {
renderer: AttentionRenderer
config: Pick<TuiConfig.Resolved, "attention">
kv?: TuiKV
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
}): TuiAttentionHost {
let focus: FocusState = "unknown"
let disposed = false
let activePackID: string | undefined
const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
const audio = input.audio ?? TuiAudio
const onFocus = () => {
focus = "focused"
}
const onBlur = () => {
focus = "blurred"
}
input.renderer.on("focus", onFocus)
input.renderer.on("blur", onBlur)
function configuredPackID() {
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
return activePackID ?? stored ?? input.config.attention.sound_pack
}
function currentPack() {
return packs.get(configuredPackID()) ?? BUILTIN_PACK
}
function soundCandidates(name: TuiAttentionSoundName) {
return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
(item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
)
}
async function playSound(name: TuiAttentionSoundName, volume: number) {
try {
for (const file of soundCandidates(name)) {
const current = await audio.loadSoundFile(file).catch((error) => {
console.debug("failed to load attention sound", { file, error })
return null
})
if (disposed) return false
if (current == null) continue
if (audio.play(current, { volume }) != null) return true
}
return false
} catch (error) {
console.debug("failed to play attention sound", { error })
return false
}
}
return {
async notify(request) {
try {
if (!input.config.attention.enabled) return skipped("attention_disabled")
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
if (!message) return skipped("empty_message")
const requestedNotification = typeof request.notification === "object" ? request.notification : undefined
const notificationSkip = focusSkip(requestedNotification?.when ?? "blurred", focus)
const notificationRequested = input.config.attention.notifications && request.notification !== false
const shouldNotify = notificationRequested && !notificationSkip
const notification = shouldNotify
? (() => {
try {
return input.renderer.triggerNotification(
message,
normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
)
} catch (error) {
console.debug("failed to trigger attention notification", { error })
return false
}
})()
: false
const volume = soundVolume(request, input.config)
const requestedSound = typeof request.sound === "object" ? request.sound : undefined
const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
const soundName =
requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default"
const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
if (!notification && !sound) {
if (notificationRequested && notificationSkip) return skipped(notificationSkip)
if (soundSkip) return skipped(soundSkip)
}
return {
ok: notification || sound,
notification,
sound,
}
} catch (error) {
console.debug("failed to handle attention notification", { error })
return {
ok: false,
notification: false,
sound: false,
}
}
},
soundboard: {
registerPack(pack) {
const next = normalizePack(pack)
if (!next) return () => {}
packs.set(next.id, next)
let disposed = false
return () => {
if (disposed) return
disposed = true
if (packs.get(next.id) === next) packs.delete(next.id)
}
},
activate(id, options) {
const pack = packs.get(id)
if (!pack) return false
activePackID = pack.id
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
return true
},
current() {
return currentPack().id
},
list(): TuiAttentionSoundPackInfo[] {
const current = currentPack().id
return Array.from(packs.values()).map((pack) => ({
id: pack.id,
name: pack.name,
active: pack.id === current,
builtin: pack.builtin,
}))
},
},
dispose() {
if (disposed) return
disposed = true
input.renderer.off("focus", onFocus)
input.renderer.off("blur", onBlur)
},
}
}

9
packages/tui/src/audio.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module "*.mp3" {
const path: string
export default path
}
declare module "@opencode-ai/ui/audio/*.mp3" {
const path: string
export default path
}

53
packages/tui/src/audio.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
import { readFile } from "node:fs/promises"
let audio: Audio | null | undefined
const sounds = new Map<string, Promise<AudioSound | null>>()
function getAudio() {
if (audio !== undefined) return audio
try {
const next = Audio.create({ autoStart: false })
next.on("error", (error: Error, context: AudioErrorContext) => {
console.debug("tui audio error", { error, context })
})
audio = next
return next
} catch (error) {
console.debug("failed to create tui audio", { error })
audio = null
return null
}
}
export function loadSoundFile(file: string) {
const current = getAudio()
if (!current) return Promise.resolve(null)
const cached = sounds.get(file)
if (cached) return cached
const task = readFile(file)
.then((bytes) => current.loadSound(bytes))
.catch((error) => {
console.debug("failed to load tui sound", { file, error })
return null
})
sounds.set(file, task)
return task
}
export function play(sound: AudioSound, options?: AudioPlayOptions) {
const current = getAudio()
if (!current) return null
if (!current.isStarted() && !current.start()) return null
return current.play(sound, options)
}
export function stopVoice(voice: AudioVoice) {
return audio?.stopVoice(voice) ?? false
}
export function dispose() {
audio?.dispose()
audio = undefined
sounds.clear()
}

View File

@@ -0,0 +1,124 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"
const exec = promisify(execFile)
function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
const output: Buffer[] = []
child.on("error", reject)
child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
child.on("close", (code) => {
if (code === 0) return resolve(Buffer.concat(output))
reject(new Error(`${command} exited with code ${code}`))
})
if (input !== undefined) child.stdin?.end(input)
})
}
function writeOsc52(text: string) {
if (!process.stdout.isTTY) return
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
}
export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
try {
await exec("osascript", [
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${file}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
}
}
if (platform() === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
Buffer.alloc(0),
)
if (image.length) return { data: image.toString().trim(), mime: "image/png" }
}
if (platform() === "linux") {
const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
Buffer.alloc(0),
)
if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
}
const { default: clipboardy } = await import("clipboardy")
const text = await clipboardy.read().catch(() => undefined)
if (text) return { data: text, mime: "text/plain" }
}
export function copyCommand(
os: NodeJS.Platform,
wayland: boolean,
has: (name: string) => boolean,
): string[] | undefined {
if (os === "darwin" && has("osascript")) return ["osascript"]
if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
if (os === "win32" && has("powershell.exe")) {
return [
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
]
}
}
let copyMethod: Promise<(text: string) => Promise<void>> | undefined
function getCopyMethod() {
return (copyMethod ??= (async () => {
const { which } = await import("@opencode-ai/core/util/which")
const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
if (native?.[0] === "osascript") {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
}
}
if (native) {
return async (text: string) => {
await command(native[0], native.slice(1), text).catch(() => undefined)
}
}
return async (text: string) => {
const { default: clipboardy } = await import("clipboardy")
await clipboardy.write(text).catch(() => undefined)
}
})())
}
export async function write(text: string) {
writeOsc52(text)
const method = await getCopyMethod()
await method(text)
}

View File

@@ -0,0 +1,436 @@
import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
import { go } from "../logo"
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 the bg ring emits from the estimated GO center when the logo shimmer peaks.
const PHASE_OFFSET = 0.29
const LOGO_GAP = 1
const LOGO_TOP_BIAS = -1
const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
const LOGO_HEIGHT = LOGO_LINES.length
const SPACE = " ".codePointAt(0)!
const TOP_HALF = "▀".codePointAt(0)!
const FULL_BLOCK = "█".codePointAt(0)!
const RING_SCALE = 1 / RINGS
const TAIL_SCALE = 1 / TAIL
const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
const enum LogoCellKind {
Background,
Top,
ShadowTop,
Solid,
Char,
}
type LogoTemplateCell = {
x: number
y: number
kind: LogoCellKind
charCode: number
attributes: number
topDist: number
bottomDist: number
}
const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
Array.from(line)
.map((char, x) => {
if (char === " ") return
const kind =
char === "_"
? LogoCellKind.Background
: char === "^"
? LogoCellKind.Top
: char === "~"
? LogoCellKind.ShadowTop
: char === "█"
? LogoCellKind.Solid
: LogoCellKind.Char
return {
x,
y,
kind,
charCode: char.codePointAt(0) ?? SPACE,
attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
}
})
.filter((cell): cell is LogoTemplateCell => !!cell),
)
export type Rgb = [number, number, number]
export type GoUpsellArtRenderOptions = {
deltaTime?: number
rgb?: boolean
cache?: boolean
}
const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
const CACHE_FRAMES_PER_RENDER = 1
export function toRgb(color: RGBA): Rgb {
const [r, g, b] = color.toInts()
return [r, g, b]
}
function clamp(n: number) {
return Math.max(0, Math.min(1, n))
}
function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
buffer[offset] = r
buffer[offset + 1] = g
buffer[offset + 2] = b
buffer[offset + 3] = a
}
function mixChannel(base: number, overlay: number, alpha: number) {
return Math.round(base + (overlay - base) * clamp(alpha))
}
function writeLogoTint(
buffer: Uint16Array,
offset: number,
base: Rgb,
primary: Rgb,
primaryMix: number,
peakMix: number,
) {
const p = clamp(primaryMix)
const q = clamp(peakMix)
const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
writeRgb(buffer, offset, r, g, b)
}
function sameRgb(a: Rgb, b: Rgb) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
}
export class GoUpsellArtPainter {
private panelRgb: Rgb = [0, 0, 0]
private primaryRgb: Rgb = [255, 255, 255]
private logoBaseRgb: Rgb = [180, 180, 180]
private elapsed = 0
private distances = new Float32Array(0)
private edgeFalloff = new Float32Array(0)
private geometryWidth = 0
private geometryHeight = 0
private reach = 1
private logoX = 0
private logoY = 0
private logoIndexes = new Int32Array(0)
private logoRgb: boolean | undefined
private pulsePeak = 0
private pulsePrimary = 0
private cacheDirty = true
private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
private cacheBuildIndex = 0
setBackgroundPanel(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.panelRgb, next)) return false
this.panelRgb = next
this.invalidateCache()
return true
}
setLogoBase(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.logoBaseRgb, next)) return false
this.logoBaseRgb = next
this.invalidateCache()
return true
}
setPrimary(value: RGBA | Rgb | undefined) {
if (!value) return false
const next = value instanceof RGBA ? toRgb(value) : value
if (sameRgb(this.primaryRgb, next)) return false
this.primaryRgb = next
this.invalidateCache()
return true
}
render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
const rgb = options.rgb === true
this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
this.rebuildGeometry(frameBuffer, rgb)
if (options.cache !== false) {
this.drawCached(frameBuffer, rgb)
return
}
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
}
private invalidateCache() {
this.cacheDirty = true
this.cacheBuildIndex = 0
this.frameCache = []
}
private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
const width = frameBuffer.width
const height = frameBuffer.height
const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
const logoTemplateChanged = this.logoRgb !== rgb
if (!geometryChanged && !logoTemplateChanged) return
if (geometryChanged) {
this.geometryWidth = width
this.geometryHeight = height
this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
this.logoY = Math.max(
0,
Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
)
const centerX = this.logoX + LOGO_WIDTH / 2
const centerY = this.logoY + LOGO_HEIGHT / 2
this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
this.distances = new Float32Array(width * height)
this.edgeFalloff = new Float32Array(width * height)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = y * width + x
const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
this.distances[index] = dist
this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
}
}
}
this.logoRgb = rgb
this.invalidateCache()
this.rebuildCellTemplate(frameBuffer, rgb)
}
private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
this.buildFrameCache(frameBuffer, rgb)
this.drawBackground(frameBuffer, this.elapsed)
this.drawLogo(frameBuffer, this.elapsed, rgb)
return
}
const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
if (frame) {
frameBuffer.buffers.fg.set(frame.fg)
frameBuffer.buffers.bg.set(frame.bg)
}
}
private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
this.frameCache = []
this.cacheBuildIndex = 0
this.rebuildCellTemplate(frameBuffer, rgb)
this.cacheDirty = false
}
private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
this.drawBackground(frameBuffer, t)
this.drawLogo(frameBuffer, t, rgb)
this.frameCache.push({
fg: new Uint16Array(frameBuffer.buffers.fg),
bg: new Uint16Array(frameBuffer.buffers.bg),
})
}
}
private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
const buffers = frameBuffer.buffers
buffers.char.fill(SPACE)
buffers.attributes.fill(0)
if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
this.logoIndexes = new Int32Array(0)
return
}
this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]!
const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
this.logoIndexes[i] = index
buffers.attributes[index] = cell.attributes
buffers.char[index] =
cell.kind === LogoCellKind.Background
? SPACE
: cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
? TOP_HALF
: cell.kind === LogoCellKind.Solid
? rgb
? TOP_HALF
: FULL_BLOCK
: cell.charCode
}
}
private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const distances = this.distances
const edgeFalloff = this.edgeFalloff
const baseR = this.panelRgb[0]
const baseG = this.panelRgb[1]
const baseB = this.panelRgb[2]
const deltaR = this.primaryRgb[0] - baseR
const deltaG = this.primaryRgb[1] - baseG
const deltaB = this.primaryRgb[2] - baseB
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const envelope2 = Math.sin(phase2 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
const head0 = phase0 * this.reach
const head1 = phase1 * this.reach
const head2 = phase2 * this.reach
for (let index = 0; index < distances.length; index++) {
const dist = distances[index]
const delta0 = dist - head0
const abs0 = delta0 < 0 ? -delta0 : delta0
const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
const delta1 = dist - head1
const abs1 = delta1 < 0 ? -delta1 : delta1
const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
const delta2 = dist - head2
const abs2 = delta2 < 0 ? -delta2 : delta2
const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
const level =
(crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
(crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
(crest2 * AMP + tail2 * TAIL_AMP) * eased2
const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
const offset = index * 4
const r = Math.round(baseR + deltaR * strength)
const g = Math.round(baseG + deltaG * strength)
const b = Math.round(baseB + deltaB * strength)
bg[offset] = fg[offset] = r
bg[offset + 1] = fg[offset + 1] = g
bg[offset + 2] = fg[offset + 2] = b
bg[offset + 3] = fg[offset + 3] = 255
}
}
private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
let peak = 0.04
let primary = 0
const delta0 = dist - head0
const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
peak += core0 * 0.65 * eased0
primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
const delta1 = dist - head1
const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
peak += core1 * 0.65 * eased1
primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
this.pulsePeak = peak > 1 ? 1 : peak
this.pulsePrimary = primary > 1 ? 1 : primary
}
private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
if (this.logoIndexes.length === 0) return
const buffers = frameBuffer.buffers
const fg = buffers.fg
const bg = buffers.bg
const shadow: Rgb = [
mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
]
const phase0 = (t / PERIOD) % 1
const phase1 = (t / PERIOD + 0.5) % 1
const envelope0 = Math.sin(phase0 * Math.PI)
const envelope1 = Math.sin(phase1 * Math.PI)
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
const head0 = phase0 * LOGO_REACH
const head1 = phase1 * LOGO_REACH
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
const cell = LOGO_TEMPLATE[i]!
const index = this.logoIndexes[i]!
const offset = index * 4
this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
const topPeak = this.pulsePeak
const topPrimary = this.pulsePrimary
this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
const bottomPeak = this.pulsePeak
const bottomPrimary = this.pulsePrimary
if (cell.kind === LogoCellKind.Background) {
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
continue
}
if (cell.kind === LogoCellKind.Top) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.ShadowTop) {
writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
continue
}
if (cell.kind === LogoCellKind.Solid && rgb) {
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
continue
}
writeLogoTint(
fg,
offset,
this.logoBaseRgb,
this.primaryRgb,
(topPrimary + bottomPrimary) / 2,
(topPeak + bottomPeak) / 2,
)
}
}
}

View File

@@ -0,0 +1,99 @@
import {
FrameBufferRenderable,
RGBA,
type OptimizedBuffer,
type RenderContext,
type RenderableOptions,
} from "@opentui/core"
import { extend, useRenderer } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { tint, useTheme } from "../context/theme"
import { GoUpsellArtPainter } from "./bg-pulse-render"
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
backgroundPanel?: RGBA
primary?: RGBA
logoBase?: RGBA
}
class GoUpsellArtRenderable extends FrameBufferRenderable {
private painter = new GoUpsellArtPainter()
constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) {
const width = typeof options.width === "number" ? options.width : 1
const height = typeof options.height === "number" ? options.height : 1
super(ctx, {
...options,
width,
height,
live: options.live ?? true,
respectAlpha: false,
})
if (options.width !== undefined && typeof options.width !== "number") this.width = options.width
if (options.height !== undefined && typeof options.height !== "number") this.height = options.height
this.painter.setBackgroundPanel(options.backgroundPanel)
this.painter.setPrimary(options.primary)
this.painter.setLogoBase(options.logoBase)
}
set backgroundPanel(value: RGBA | undefined) {
if (this.painter.setBackgroundPanel(value)) this.requestRender()
}
set logoBase(value: RGBA | undefined) {
if (this.painter.setLogoBase(value)) this.requestRender()
}
set primary(value: RGBA | undefined) {
if (this.painter.setPrimary(value)) this.requestRender()
}
protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void {
if (!this.visible || this.isDestroyed) return
this.painter.render(this.frameBuffer, {
deltaTime,
rgb: this._ctx.capabilities?.rgb === true,
})
super.renderSelf(buffer)
}
}
declare module "@opentui/solid" {
interface OpenTUIComponents {
go_upsell_art: typeof GoUpsellArtRenderable
}
}
extend({ go_upsell_art: GoUpsellArtRenderable })
export function BgPulse() {
const { theme } = useTheme()
const renderer = useRenderer()
let targetFps = renderer.targetFps
let maxFps = renderer.maxFps
onMount(() => {
targetFps = renderer.targetFps
maxFps = renderer.maxFps
renderer.targetFps = 30
renderer.maxFps = 30
})
onCleanup(() => {
renderer.targetFps = targetFps
renderer.maxFps = maxFps
})
return (
<go_upsell_art
width="100%"
height="100%"
backgroundPanel={theme.backgroundPanel}
primary={theme.primary}
logoBase={tint(theme.background, theme.text, 0.62)}
live
/>
)
}

View File

@@ -0,0 +1,79 @@
import { createMemo } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { type DialogContext } from "../ui/dialog"
import {
COMMAND_PALETTE_COMMAND,
formatKeyBindings,
type OpenTuiKeymap,
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useTuiConfig } from "../config"
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
function isVisiblePaletteCommand(command: PaletteCommandEntry["command"]) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
const suggested = entry.command.suggested
if (typeof suggested === "boolean") return suggested
if (typeof suggested === "function") return suggested() === true
return false
}
export function CommandPaletteDialog() {
const config = useTuiConfig()
const keymap = useOpencodeKeymap()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
const query = {
namespace: "palette",
}
const reachable = keymap.getCommandEntries({
...query,
visibility: "reachable",
filter: isVisiblePaletteCommand,
})
const registeredBindings = keymap.getCommandBindings({
visibility: "registered",
commands: reachable.map((entry) => entry.command.name),
})
return reachable.map((entry) => ({
...entry,
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
}))
})
const options = createMemo(() =>
entries().map((entry) => ({
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
footer: formatKeyBindings(entry.bindings, config),
value: entry.command.name,
suggested: isSuggestedPaletteCommand(entry),
onSelect: (dialog: DialogContext) => {
dialog.clear()
keymap.dispatchCommand(entry.command.name)
},
})),
)
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return options()
return [
...options()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
...options(),
]
}
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
}

View File

@@ -0,0 +1,31 @@
import { createMemo } from "solid-js"
import { useLocal } from "../context/local"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
export function DialogAgent() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() =>
local.agent.list().map((item) => {
return {
value: item.name,
title: item.name,
description: item.native ? "native" : item.description,
}
}),
)
return (
<DialogSelect
title="Select agent"
current={local.agent.current()?.name}
options={options()}
onSelect={(option) => {
local.agent.set(option.value)
dialog.clear()
}}
/>
)
}

View File

@@ -0,0 +1,103 @@
import { createResource, createMemo } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useSDK } from "../context/sdk"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useTheme } from "../context/theme"
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
const accountHost = (url: string) => {
try {
return new URL(url).host
} catch {
return url
}
}
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
`${item.accountEmail} ${accountHost(item.accountUrl)}`
export function DialogConsoleOrg() {
const sdk = useSDK()
const dialog = useDialog()
const toast = useToast()
const { theme } = useTheme()
const [orgs] = createResource(async () => {
const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true })
return result.data?.orgs ?? []
})
const current = createMemo(() => orgs()?.find((item) => item.active))
const options = createMemo(() => {
const listed = orgs()
if (listed === undefined) {
return [
{
title: "Loading orgs...",
value: "loading",
onSelect: () => {},
},
]
}
if (listed.length === 0) {
return [
{
title: "No orgs found",
value: "empty",
onSelect: () => {},
},
]
}
return listed
.toSorted((a, b) => {
const activeAccountA = a.active ? 0 : 1
const activeAccountB = b.active ? 0 : 1
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
if (accountCompare !== 0) return accountCompare
return a.orgName.localeCompare(b.orgName)
})
.map((item) => ({
title: item.orgName,
value: item,
category: accountLabel(item),
categoryView: (
<box flexDirection="row" gap={2}>
<text fg={theme.accent}>{item.accountEmail}</text>
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
</box>
),
onSelect: async () => {
if (item.active) {
dialog.clear()
return
}
await sdk.client.experimental.console.switchOrg(
{
accountID: item.accountID,
orgID: item.orgID,
},
{ throwOnError: true },
)
await sdk.client.instance.dispose()
toast.show({
message: `Switched to ${item.orgName}`,
variant: "info",
})
dialog.clear()
},
}))
})
return <DialogSelect<string | OrgOption> title="Switch org" options={options()} current={current()} />
}

View File

@@ -0,0 +1,85 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "../context/local"
import { useSync } from "../context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import { useSDK } from "../context/sdk"
function Status(props: { enabled: boolean; loading: boolean }) {
const { theme } = useTheme()
if (props.loading) {
return <span style={{ fg: theme.textMuted }}> Loading</span>
}
if (props.enabled) {
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}> Enabled</span>
}
return <span style={{ fg: theme.textMuted }}> Disabled</span>
}
export function DialogMcp() {
const local = useLocal()
const sync = useSync()
const sdk = useSDK()
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
const [loading, setLoading] = createSignal<string | null>(null)
const options = createMemo(() => {
// Track sync data and loading state to trigger re-render when they change
const mcpData = sync.data.mcp
const loadingMcp = loading()
return pipe(
mcpData ?? {},
entries(),
sortBy(([name]) => name),
map(([name, status]) => ({
value: name,
title: name,
description: status.status === "failed" ? "failed" : status.status,
footer: <Status enabled={local.mcp.isEnabled(name)} loading={loadingMcp === name} />,
category: undefined,
})),
)
})
const actions = createMemo(() => [
{
command: "dialog.mcp.toggle",
title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress
if (loading() !== null) return
setLoading(option.value)
try {
await local.mcp.toggle(option.value)
// Refresh MCP status from server
const status = await sdk.client.mcp.status()
if (status.data) {
sync.set("mcp", status.data)
} else {
console.error("Failed to refresh MCP status: no data returned")
}
} catch (error) {
console.error("Failed to toggle MCP:", error)
} finally {
setLoading(null)
}
},
},
])
return (
<DialogSelect
ref={setRef}
title="MCPs"
options={options()}
actions={actions()}
onSelect={(_option) => {
// Don't close on select, only on escape
}}
/>
)
}

View File

@@ -0,0 +1,193 @@
import { createMemo, createSignal } from "solid-js"
import { useLocal } from "../context/local"
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useSync } from "../context/sync"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const [query, setQuery] = createSignal("")
const connected = useConnected()
const providers = createDialogProviderOptions()
const showExtra = createMemo(() => connected() && !props.providerID)
const options = createMemo(() => {
const needle = query().trim()
const showSections = showExtra() && needle.length === 0
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent()
function toOptions(items: typeof favorites, category: string) {
if (!showSections) return []
return items.flatMap((item) => {
const provider = sync.data.provider.find((provider) => provider.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
onSelect(provider.id, model.id)
},
},
]
})
}
const favoriteOptions = toOptions(favorites, "Favorites")
const recentOptions = toOptions(
recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
),
"Recent",
)
const providerOptions = pipe(
sync.data.provider,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => ({
value: { providerID: provider.id, modelID: model },
title: info.name ?? model,
releaseDate: info.release_date,
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
onSelect(provider.id, model)
},
})),
filter((option) => {
if (!showSections) return true
if (
favorites.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
)
return false
if (
recents.some(
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
)
)
return false
return true
}),
(options) => sortModelOptions(options, props.providerID !== undefined),
),
),
)
const popularProviders = !connected()
? pipe(
providers(),
map((option) => ({
...option,
category: "Popular providers",
})),
take(6),
)
: []
if (needle) {
return [
...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
}
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
})
const provider = createMemo(() =>
props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null,
)
const title = createMemo(() => {
const value = provider()
if (!value) return "Select model"
return value.name
})
function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true })
const list = local.model.variant.list()
const cur = local.model.variant.selected()
if (cur === "default" || (cur && list.includes(cur))) {
dialog.clear()
return
}
if (list.length > 0) {
dialog.replace(() => <DialogVariant />)
return
}
dialog.clear()
}
return (
<DialogSelect<ReturnType<typeof options>[number]["value"]>
options={options()}
actions={[
{
command: "model.dialog.provider",
title: connected() ? "Connect provider" : "View all providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
{
command: "model.dialog.favorite",
title: "Favorite",
hidden: !connected(),
onTrigger: (option) => {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
]}
onFilter={setQuery}
flat={true}
skipFilter={true}
title={title()}
current={local.model.current()}
/>
)
}
export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
options: T[],
newestFirst: boolean,
) {
if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title)
return sortBy(
options,
(option) => option.footer !== "Free",
(option) => option.title,
)
}

View File

@@ -0,0 +1,238 @@
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { useSync } from "../context/sync"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { Locale } from "../util/locale"
import { errorMessage } from "../util/error"
import { useToast } from "../ui/toast"
import { useCommandShortcut } from "../keymap"
import { useProject } from "../context/project"
import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
export function DialogMoveSession(props: {
projectID: string
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
initialDirectories?: string[]
initialRemoving?: string
}) {
const dialog = useDialog()
const sdk = useSDK()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const sync = useSync()
const projectContext = useProject()
const toast = useToast()
const paths = useTuiPaths()
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
const deleteHint = useCommandShortcut("dialog.move_session.delete")
function reopen(initialRemoving?: string) {
dialog.replace(() => (
<DialogMoveSession {...props} initialDirectories={directories()} initialRemoving={initialRemoving} />
))
}
const [loadedProject] = createResource(
() => (projectContext.project() === props.projectID ? undefined : props.projectID),
async (projectID) => {
const result = await sdk.client.project.current({}, { throwOnError: true })
return result.data?.id === projectID ? result.data.worktree : undefined
},
)
const project = createMemo(() =>
projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(),
)
const [directories, { refetch }] = createResource(
() => (props.initialRemoving ? undefined : props.projectID),
async (projectID) => {
setWorking(true)
try {
await sdk.client.experimental.projectCopy.refresh({ projectID }, { throwOnError: true })
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
return directories.data?.map((item) => item.directory) ?? []
} finally {
setWorking(false)
}
},
{ initialValue: props.initialDirectories },
)
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
const data = directories()
const main = project()
if (directories.loading && !data && !main) return [{ title: "Loading project directories...", value: undefined }]
if (directories.error && !data && !main) return [{ title: "Failed to load project directories", value: undefined }]
const roots = [...new Set(main ? [main, ...(data ?? [])] : (data ?? []))]
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
const subdirectories = sync.data.session
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
.map((session) => session.directory)
.filter((directory) => !roots.includes(directory))
.filter((directory, index, directories) => directories.indexOf(directory) === index)
.map((location) => ({
location,
root: roots
.filter((root) => {
const relative = path.relative(root, location)
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
})
.toSorted((a, b) => b.length - a.length)[0],
}))
.filter((item): item is { location: string; root: string } => item.root !== undefined)
const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => {
const root = roots.indexOf(a.root) - roots.indexOf(b.root)
if (root !== 0) return root
if (a.location === a.root) return -1
if (b.location === b.root) return 1
return a.location.localeCompare(b.location)
})
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
return list.map((item) => {
const title = abbreviateHome(item.location, paths.home)
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
const visible = Locale.truncateLeft(title, titleWidth)
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
const deleting = toDelete() === item.location
const isRemoving = removing() === item.location
return {
title: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title,
titleView: isRemoving ? (
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
) : !deleting && suffix ? (
<>
{visible.slice(0, split)}
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
</>
) : undefined,
bg: deleting ? theme.error : undefined,
value: { type: "directory", directory: item.location, subdirectory: item.location !== item.root } as const,
category: item.root === main ? "Project" : "Working copies",
titleWidth,
truncateTitle: "left" as const,
}
})
})
const current = createMemo(() => {
if (directories.loading || loadedProject.loading || !props.current) return
if (props.current.type === "new") return props.current
const directory = props.current.directory
return options().find((option) => option.value?.type === "directory" && option.value.directory === directory)?.value
})
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
const data = directories()
const main = project()
if (!data || !main || option.value.directory === main || !data.includes(option.value.directory)) return
if (toDelete() !== option.value.directory) {
setToDelete(option.value.directory)
return
}
setToDelete(undefined)
setRemoving(option.value.directory)
setWorking(true)
const result = await sdk.client.experimental.projectCopy
.remove({ projectID: props.projectID, directory: option.value.directory, force: false })
.catch((error) => ({ error }))
if (result.error) {
setRemoving(undefined)
setWorking(false)
if ("data" in result.error && result.error.data.forceRequired) {
const status = await sdk.client.vcs.status({ directory: option.value.directory }).catch(() => undefined)
const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
title: "Delete working copy?",
message: "This working copy has file changes. Do you want to delete it anyway?",
})
if (choice !== "yes") {
reopen()
return
}
reopen(option.value.directory)
const forced = await sdk.client.experimental.projectCopy
.remove({ projectID: props.projectID, directory: option.value.directory, force: true })
.catch((error) => ({ error }))
if (forced.error) {
toast.show({
variant: "error",
title: "Failed to delete project copy",
message: errorMessage(forced.error),
})
}
reopen()
return
}
toast.show({
variant: "error",
title: "Failed to delete project copy",
message: errorMessage(result.error),
})
return
}
await refetch()
setRemoving(undefined)
}
onMount(() => dialog.setSize("xlarge"))
return (
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
<DialogSelect
title="Move session"
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Move session
</text>
<Show when={working()}>
<Spinner />
</Show>
</box>
}
options={options()}
locked={directories.loading || loadedProject.loading || Boolean(removing())}
current={current()}
onSelect={(option) => {
if (option.value) props.onSelect(option.value)
}}
onMove={() => setToDelete(undefined)}
actions={[
{
command: "dialog.move_session.new",
title: "new",
onTrigger: () => props.onSelect({ type: "new" }),
},
{
command: "dialog.move_session.delete",
title: "delete",
disabled: (option) =>
!option?.value ||
option.value.type !== "directory" ||
option.value.subdirectory ||
option.value.directory === project(),
onTrigger: remove,
},
{
command: "dialog.move_session.refresh",
title: "refresh",
onTrigger: () => void refetch(),
},
]}
/>
</box>
)
}

View File

@@ -0,0 +1,469 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useSync } from "../context/sync"
import { map, pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { DialogPrompt } from "../ui/dialog-prompt"
import { Link } from "../ui/link"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "../util/provider-origin"
import { useConnected } from "./use-connected"
import { useBindings } from "../keymap"
import { useClipboard } from "../context/clipboard"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
"opencode-go": 1,
openai: 2,
"github-copilot": 3,
anthropic: 4,
google: 5,
}
const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__"
const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
type ProviderOptionBase = {
title: string
value: string
description?: string
category: string
}
type ProviderOption =
| (ProviderOptionBase & {
type: "provider"
providerID: string
})
| (ProviderOptionBase & {
type: "custom"
})
export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
return [
...pipe(
list,
sortBy(
(x) => PROVIDER_PRIORITY[x.id] ?? 99,
(x) => x.name.toLowerCase(),
(x) => x.id,
),
map((provider) => ({
type: "provider" as const,
title: provider.name,
value: provider.id,
providerID: provider.id,
description: {
opencode: "(Recommended)",
anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
})),
),
{
type: "custom",
title: "Other",
value: CUSTOM_PROVIDER_OPTION_VALUE,
description: "Custom provider",
category: "Providers",
},
]
}
export function normalizeCustomProviderID(value: string) {
const providerID = value.trim().replace(/^@ai-sdk\//, "")
if (!CUSTOM_PROVIDER_ID.test(providerID)) return
return providerID
}
export function createDialogProviderOptions() {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const toast = useToast()
const { theme } = useTheme()
const onboarded = useConnected()
async function promptCustomProviderID(): Promise<string | undefined> {
const value = await DialogPrompt.show(dialog, "Other", {
placeholder: "Provider id",
description: () => (
<text fg={theme.textMuted}>
This only stores a credential. Configure the provider in opencode.json to use it.
</text>
),
})
if (value === null) return
const providerID = normalizeCustomProviderID(value)
if (providerID) return providerID
toast.show({
variant: "error",
message:
"Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
})
return promptCustomProviderID()
}
const options = createMemo(() => {
return pipe(
providerOptions(sync.data.provider_next.all),
map((provider) => {
if (provider.type === "custom") {
return {
title: provider.title,
value: provider.value,
description: provider.description,
category: provider.category,
async onSelect() {
const providerID = await promptCustomProviderID()
if (!providerID) return
return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
},
}
}
const providerID = provider.providerID
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
const connected = sync.data.provider_next.connected.includes(providerID)
return {
title: provider.title,
value: provider.value,
description: provider.description,
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.category,
gutter: connected && onboarded() ? () => <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return
const methods = sync.data.provider_auth[providerID] ?? [
{
type: "api",
label: "API key",
},
]
let index: number | null = 0
if (methods.length > 1) {
index = await new Promise<number | null>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title="Select auth method"
options={methods.map((x, index) => ({
title: x.label,
value: index,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
}
if (index == null) return
const method = methods[index]
if (method.type === "oauth") {
let inputs: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({
dialog,
prompts: method.prompts,
})
if (!value) return
inputs = value
}
const result = await sdk.client.provider.oauth.authorize({
providerID,
method: index,
inputs,
})
if (result.error) {
toast.show({
variant: "error",
message: JSON.stringify(result.error),
})
dialog.clear()
return
}
if (result.data?.method === "code") {
dialog.replace(() => (
<CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
))
}
if (result.data?.method === "auto") {
dialog.replace(() => (
<AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
))
}
}
if (method.type === "api") {
let metadata: Record<string, string> | undefined
if (method.prompts?.length) {
const value = await PromptsMethod({ dialog, prompts: method.prompts })
if (!value) return
metadata = value
}
return dialog.replace(() => (
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
))
}
},
}
}),
)
})
return options
}
export function DialogProvider() {
const options = createDialogProviderOptions()
return <DialogSelect title="Connect a provider" options={options()} />
}
interface AutoMethodProps {
index: number
providerID: string
title: string
authorization: ProviderAuthAuthorization
}
function AutoMethod(props: AutoMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const dialog = useDialog()
const sync = useSync()
const toast = useToast()
const clipboard = useClipboard()
useBindings(() => ({
bindings: [
{
key: "c",
desc: "Copy provider code",
group: "Dialog",
cmd: () => {
const code =
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
clipboard
.write?.(code)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
],
}))
onMount(async () => {
const result = await sdk.client.provider.oauth.callback({
providerID: props.providerID,
method: props.index,
})
if (result.error) {
toast.show({
variant: "error",
message:
"name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
? "OAuth authorization failed. Try /connect again."
: JSON.stringify(result.error),
})
dialog.clear()
return
}
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel providerID={props.providerID} />)
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={1}>
<Link href={props.authorization.url} fg={theme.primary} />
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
</box>
<text fg={theme.textMuted}>Waiting for authorization...</text>
<text fg={theme.text}>
c <span style={{ fg: theme.textMuted }}>copy</span>
</text>
</box>
)
}
interface CodeMethodProps {
index: number
title: string
providerID: string
authorization: ProviderAuthAuthorization
}
function CodeMethod(props: CodeMethodProps) {
const { theme } = useTheme()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const [error, setError] = createSignal(false)
return (
<DialogPrompt
title={props.title}
placeholder="Authorization code"
onConfirm={async (value) => {
const { error } = await sdk.client.provider.oauth.callback({
providerID: props.providerID,
method: props.index,
code: value,
})
if (!error) {
await sdk.client.instance.dispose()
await sync.bootstrap()
dialog.replace(() => <DialogModel providerID={props.providerID} />)
return
}
setError(true)
}}
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
<Link href={props.authorization.url} fg={theme.primary} />
<Show when={error()}>
<text fg={theme.error}>Invalid code</text>
</Show>
</box>
)}
/>
)
}
interface ApiMethodProps {
providerID: string
title: string
metadata?: Record<string, string>
custom?: boolean
}
function ApiMethod(props: ApiMethodProps) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const { theme } = useTheme()
return (
<DialogPrompt
title={props.title}
placeholder="API key"
description={
{
opencode: (
<box gap={1}>
<text fg={theme.textMuted}>
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API
key.
</text>
<text fg={theme.text}>
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
</text>
</box>
),
"opencode-go": (
<box gap={1}>
<text fg={theme.textMuted}>
OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models
with generous usage limits.
</text>
<text fg={theme.text}>
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/go</span> and enable OpenCode Go
</text>
</box>
),
}[props.providerID] ?? undefined
}
onConfirm={async (value) => {
if (!value) return
await sdk.client.auth.set({
providerID: props.providerID,
auth: {
type: "api",
key: value,
...(props.metadata ? { metadata: props.metadata } : {}),
},
})
await sdk.client.instance.dispose()
await sync.bootstrap()
if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
toast.show({
variant: "info",
message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
})
dialog.clear()
return
}
dialog.replace(() => <DialogModel providerID={props.providerID} />)
}}
/>
)
}
interface PromptsMethodProps {
dialog: ReturnType<typeof useDialog>
prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
}
async function PromptsMethod(props: PromptsMethodProps) {
const inputs: Record<string, string> = {}
for (const prompt of props.prompts) {
if (prompt.when) {
const value = inputs[prompt.when.key]
if (value === undefined) continue
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
if (!matches) continue
}
if (prompt.type === "select") {
const value = await new Promise<string | null>((resolve) => {
props.dialog.replace(
() => (
<DialogSelect
title={prompt.message}
options={prompt.options.map((x) => ({
title: x.label,
value: x.value,
description: x.hint,
}))}
onSelect={(option) => resolve(option.value)}
/>
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
continue
}
const value = await new Promise<string | null>((resolve) => {
props.dialog.replace(
() => (
<DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={(value) => resolve(value)} />
),
() => resolve(null),
)
})
if (value === null) return null
inputs[prompt.key] = value
}
return inputs
}

View File

@@ -0,0 +1,160 @@
import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { createSignal } from "solid-js"
import { selectedForeground, useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
import { Link } from "../ui/link"
import { BgPulse } from "./bg-pulse"
import { useBindings } from "../keymap"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
const PAD_TOP_OUTER = 1
const FOREGROUND_ALPHA = 186
export type DialogRetryActionProps = {
title: string
message: string
label: string
link?: string
onClose?: (dontShowAgain?: boolean) => void
}
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
if (props.link) open(props.link).catch(() => {})
props.onClose?.()
dialog.clear()
}
function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
props.onClose?.(true)
dialog.clear()
}
function panelOverlay(color: RGBA) {
const [r, g, b] = color.toInts()
return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA)
}
export function DialogRetryAction(props: DialogRetryActionProps) {
const dialog = useDialog()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const showGoTreatment = () => props.link === GO_URL
const textBg = () => (showGoTreatment() ? panelOverlay(theme.backgroundPanel) : undefined)
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
useBindings(() => ({
bindings: [
{
key: "left",
desc: "Previous retry option",
group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "right",
desc: "Next retry option",
group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "tab",
desc: "Next retry option",
group: "Dialog",
cmd: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
},
{
key: "return",
desc: "Confirm retry option",
group: "Dialog",
cmd: () => {
if (selected() === "action") runAction(props, dialog)
else dismiss(props, dialog)
},
},
],
}))
return (
<box>
{showGoTreatment() ? (
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse />
</box>
) : null}
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text} bg={textBg()}>
{props.title}
</text>
<text fg={theme.textMuted} bg={textBg()} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box gap={0}>
<text fg={theme.textMuted} bg={textBg()}>
{props.message}
</text>
</box>
{props.link ? (
showGoTreatment() ? (
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
<Link href={props.link} fg={theme.primary} bg={textBg()} wrapMode="none" />
</box>
) : (
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
<Link href={props.link} fg={theme.primary} wrapMode="none" />
</box>
)
) : (
<box paddingBottom={1} />
)}
<box 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)}
>
<text
fg={selected() === "dismiss" ? fg : theme.textMuted}
bg={selected() === "dismiss" ? undefined : textBg()}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "action" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("action")}
onMouseUp={() => runAction(props, dialog)}
>
<text
fg={selected() === "action" ? fg : theme.text}
bg={selected() === "action" ? undefined : textBg()}
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
>
{props.label}
</text>
</box>
</box>
</box>
</box>
)
}
DialogRetryAction.show = (
dialog: DialogContext,
props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
) => {
return new Promise<boolean>((resolve) => {
dialog.replace(
() => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
() => resolve(false),
)
})
}

View File

@@ -0,0 +1,99 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useBindings } from "../keymap"
export function DialogSessionDeleteFailed(props: {
session: string
workspace: string
onDelete?: () => boolean | void | Promise<boolean | void>
onRestore?: () => boolean | void | Promise<boolean | void>
onDone?: () => void
}) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
const options = [
{
id: "delete" as const,
title: "Delete workspace",
description: "Delete the workspace and all sessions attached to it.",
run: props.onDelete,
},
{
id: "restore" as const,
title: "Restore to new workspace",
description: "Try to restore this session into a new workspace.",
run: props.onRestore,
},
]
async function confirm() {
const result = await options.find((item) => item.id === store.active)?.run?.()
if (result === false) return
props.onDone?.()
if (!props.onDone) dialog.clear()
}
useBindings(() => ({
bindings: [
{ key: "return", desc: "Confirm recovery option", group: "Dialog", cmd: () => void confirm() },
{ key: "left", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
{ key: "up", desc: "Delete broken session", group: "Dialog", cmd: () => setStore("active", "delete") },
{ key: "right", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
{ key: "down", desc: "Restore broken session", group: "Dialog", cmd: () => setStore("active", "restore") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Failed to Delete Session
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.textMuted} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
}}
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
>
{item.title}
</text>
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
{item.description}
</text>
</box>
)}
</For>
</box>
</box>
)
}

View File

@@ -0,0 +1,306 @@
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import path from "path"
import { Locale } from "../util/locale"
import { useProject } from "../context/project"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { useLocal } from "../context/local"
import { DialogSessionRename } from "./dialog-session-rename"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { useCommandShortcut } from "../keymap"
export function DialogSessionList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const project = useProject()
const { theme } = useTheme()
const sdk = useSDK()
const local = useLocal()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
async (input) => {
if (!input.query) return undefined
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
return result.data ?? []
},
)
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
const warp = async (selection: WorkspaceSelection) => {
const workspaceID = await (async () => {
if (selection.type === "none") return null
if (selection.type === "existing") return selection.workspaceID
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(err),
variant: "error",
})
return
}
const workspace = result?.data
if (!workspace) {
toast.show({
title: "Failed to create workspace",
message: errorMessage(result?.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
return workspace.id
})()
if (workspaceID === undefined) return
await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID: session.workspaceID,
workspaceID,
sessionID: session.id,
copyChanges: false,
done: list,
})
}
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
void openWorkspaceSelect({
dialog,
sdk,
sync,
project,
toast,
onSelect: (selection) => {
void warp(selection)
},
})
return false
}}
/>
))
}
function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
return sessionsList
.filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => x.id)
}
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
const last = quickSwitch9()
if (!first || !last) return undefined
return quickSwitchRange(first, last)
})
const quickSwitchFooterHints = createMemo(() => {
const hint = quickSwitchHint()
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
})
const options = createMemo(() => {
const today = new Date().toDateString()
const sessionMap = new Map(
sessions()
.filter((x) => x.parentID === undefined)
.map((x) => [x.id, x]),
)
const searchResult = searchResults()
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const pinnedSet = new Set(pinned)
const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
function buildOption(id: string, category: string) {
const x = sessionMap.get(id)
if (!x) return undefined
const directory = x.path
? x.directory.endsWith(x.path)
? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
: undefined
: x.directory
const footer =
directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" || status?.type === "retry"
const slot = slotByID.get(x.id)
const gutter = isWorking
? () => <Spinner />
: slot !== undefined
? () => <text fg={theme.accent}>{slot}</text>
: undefined
return {
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer,
gutter,
}
}
const remaining = displayOrder
.filter((id) => !pinnedSet.has(id))
.map((id) => {
const x = sessionMap.get(id)
if (!x) return undefined
const label = new Date(x.time.updated).toDateString()
return buildOption(id, label === today ? "Today" : label)
})
.filter((x) => x !== undefined)
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title="Sessions"
options={options()}
skipFilter={true}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
actions={[
{
command: "session.pin.toggle",
title: "pin/unpin",
onTrigger: (option: { value: string }) => {
local.session.togglePin(option.value)
},
},
{
command: "session.delete",
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const session = sessions().find((item) => item.id === option.value)
const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
if (search()) await refetch()
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
{
command: "session.rename",
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
footerHints={quickSwitchFooterHints()}
/>
)
}
function quickSwitchRange(first: string, last: string) {
const prefix = first.slice(0, -1)
if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
return `${first} through ${last}`
}

View File

@@ -0,0 +1,31 @@
import { DialogPrompt } from "../ui/dialog-prompt"
import { useDialog } from "../ui/dialog"
import { useSync } from "../context/sync"
import { createMemo } from "solid-js"
import { useSDK } from "../context/sdk"
interface DialogSessionRenameProps {
session: string
}
export function DialogSessionRename(props: DialogSessionRenameProps) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const session = createMemo(() => sync.session.get(props.session))
return (
<DialogPrompt
title="Rename Session"
value={session()?.title}
onConfirm={(value) => {
void sdk.client.session.update({
sessionID: props.session,
title: value,
})
dialog.clear()
}}
onCancel={() => dialog.clear()}
/>
)
}

View File

@@ -0,0 +1,36 @@
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { createResource, createMemo } from "solid-js"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
export type DialogSkillProps = {
onSelect: (skill: string) => void
}
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
const sdk = useSDK()
dialog.setSize("large")
const [skills] = createResource(async () => {
const result = await sdk.client.app.skills()
return result.data ?? []
})
const options = createMemo<DialogSelectOption<string>[]>(() => {
const list = skills() ?? []
const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
return list.map((skill) => ({
title: skill.name.padEnd(maxWidth),
description: skill.description?.replace(/\s+/g, " ").trim(),
value: skill.name,
category: "Skills",
onSelect: () => {
props.onSelect(skill.name)
dialog.clear()
},
}))
})
return <DialogSelect title="Skills" placeholder="Search skills..." options={options()} />
}

View File

@@ -0,0 +1,87 @@
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale"
import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash"
import { useCommandShortcut } from "../keymap"
function getRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (seconds < 60) return "just now"
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 7) return `${days}d ago`
return Locale.datetime(timestamp)
}
function getStashPreview(input: string, maxLength: number = 50): string {
const firstLine = input.split("\n")[0].trim()
return Locale.truncate(firstLine, maxLength)
}
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { theme } = useTheme()
const [toDelete, setToDelete] = createSignal<number>()
const deleteHint = useCommandShortcut("stash.delete")
const options = createMemo(() => {
const entries = stash.list()
// Show most recent first
return entries
.map((entry, index) => {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
}
})
.toReversed()
})
return (
<DialogSelect
title="Stash"
options={options()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
const entries = stash.list()
const entry = entries[option.value]
if (entry) {
stash.remove(option.value)
props.onSelect(entry)
}
dialog.clear()
}}
actions={[
{
command: "stash.delete",
title: "delete",
onTrigger: (option) => {
if (toDelete() === option.value) {
stash.remove(option.value)
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
]}
/>
)
}

View File

@@ -0,0 +1,168 @@
import { TextAttributes } from "@opentui/core"
import { fileURLToPath } from "bun"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useSync } from "../context/sync"
import { For, Match, Switch, Show, createMemo } from "solid-js"
export type DialogStatusProps = {}
export function DialogStatus() {
const sync = useSync()
const { theme } = useTheme()
const dialog = useDialog()
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((item) => {
const value = typeof item === "string" ? item : item[0]
if (value.startsWith("file://")) {
const path = fileURLToPath(value)
const parts = path.split("/")
const filename = parts.pop() || path
if (!filename.includes(".")) return { name: filename }
const basename = filename.split(".")[0]
if (basename === "index") {
const dirname = parts.pop()
const name = dirname || basename
return { name }
}
return { name: basename }
}
const index = value.lastIndexOf("@")
if (index <= 0) return { name: value, version: "latest" }
const name = value.substring(0, index)
const version = value.substring(index + 1)
return { name, version }
})
return result.toSorted((a, b) => a.name.localeCompare(b.name))
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Status
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}>
<box>
<text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text>
<For each={Object.entries(sync.data.mcp)}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: (
{
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
needs_auth: theme.warning,
needs_client_registration: theme.error,
} as Record<string, typeof theme.success>
)[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
<b>{key}</b>{" "}
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
<Match when={(item.status as string) === "needs_auth"}>
Needs authentication (run: opencode mcp auth {key})
</Match>
<Match when={(item.status as string) === "needs_client_registration" && item}>
{(val) => (val() as { error: string }).error}
</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</box>
</Show>
{sync.data.lsp.length > 0 && (
<box>
<text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
<For each={sync.data.lsp}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: {
connected: theme.success,
error: theme.error,
}[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
</text>
</box>
)}
</For>
</box>
)}
<Show when={enabledFormatters().length > 0} fallback={<text fg={theme.text}>No Formatters</text>}>
<box>
<text fg={theme.text}>{enabledFormatters().length} Formatters</text>
<For each={enabledFormatters()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: theme.success,
}}
>
</text>
<text wrapMode="word" fg={theme.text}>
<b>{item.name}</b>
</text>
</box>
)}
</For>
</box>
</Show>
<Show when={plugins().length > 0} fallback={<text fg={theme.text}>No Plugins</text>}>
<box>
<text fg={theme.text}>{plugins().length} Plugins</text>
<For each={plugins()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: theme.success,
}}
>
</text>
<text wrapMode="word" fg={theme.text}>
<b>{item.name}</b>
{item.version && <span style={{ fg: theme.textMuted }}> @{item.version}</span>}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}

View File

@@ -0,0 +1,47 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useProject } from "../context/project"
import { useSDK } from "../context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {
const sdk = useSDK()
const dialog = useDialog()
const project = useProject()
const [store] = createStore({
filter: "",
})
const [files] = createResource(
() => [store.filter],
async () => {
const result = await sdk.client.find.files({
query: store.filter,
workspace: project.workspace.current(),
})
if (result.error) return []
const sliced = (result.data ?? []).slice(0, 5)
return sliced
},
)
const options = createMemo(() =>
(files() ?? []).map((file) => ({
value: file,
title: file,
})),
)
return (
<DialogSelect
title="Autocomplete"
options={options()}
onSelect={(option) => {
props.onSelect?.(option.value)
dialog.clear()
}}
/>
)
}

View File

@@ -0,0 +1,50 @@
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { onCleanup } from "solid-js"
export function DialogThemeList() {
const theme = useTheme()
const options = Object.keys(theme.all())
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
.map((value) => ({
title: value,
value: value,
}))
const dialog = useDialog()
let confirmed = false
let ref: DialogSelectRef<string>
const initial = theme.selected
onCleanup(() => {
if (!confirmed) theme.set(initial)
})
return (
<DialogSelect
title="Themes"
options={options}
current={initial}
onMove={(opt) => {
theme.set(opt.value)
}}
onSelect={(opt) => {
theme.set(opt.value)
confirmed = true
dialog.clear()
}}
ref={(r) => {
ref = r
}}
onFilter={(query) => {
if (query.length === 0) {
theme.set(initial)
return
}
const first = ref.filtered[0]
if (first) theme.set(first.value)
}}
/>
)
}

View File

@@ -0,0 +1,39 @@
import { createMemo } from "solid-js"
import { useLocal } from "../context/local"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
export function DialogVariant() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() => {
return [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(undefined)
},
},
...local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
]
})
return (
<DialogSelect<string>
options={options()}
title={"Select variant"}
current={local.model.variant.selected()}
flat={true}
/>
)
}

View File

@@ -0,0 +1,308 @@
import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useSync } from "../context/sync"
import { useProject } from "../context/project"
import { useRoute } from "../context/route"
import { createMemo, createSignal, onMount } from "solid-js"
import { errorMessage } from "../util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
type Adapter = ExperimentalWorkspaceAdapterListResponse[number]
export type WorkspaceSelection =
| {
type: "none"
}
| {
type: "new"
workspaceType: string
workspaceName: string
}
| {
type: "existing"
workspaceID: string
workspaceType: string
workspaceName: string
}
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
type ExistingWorkspaceSelectValue = { workspace: Workspace }
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
workspaces: readonly WorkspaceInfo[]
status: (workspaceID: string) => string | undefined
limit?: number
omitWorkspaceID?: string
}) {
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
const recent = workspaces.slice(0, input.limit ?? 3)
return { recent, hasMore: recent.length < workspaces.length }
}
export function warpReminderText(dir: string) {
return `<system-reminder>The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
}
async function loadWorkspaceAdapters(input: {
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
}) {
const dir = input.sync.path.directory || input.sdk.directory
try {
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
if (response.error) throw response.error
return response.data
} catch (err) {
input.toast.show({
title: "Failed to load workspace adapters",
message: errorMessage(err),
variant: "error",
})
return undefined
}
}
export async function openWorkspaceSelect(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
input.dialog.clear()
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
await input.project.workspace.sync().catch(() => undefined)
const adapters = await loadWorkspaceAdapters(input)
if (!adapters) return
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
}
export async function warpWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
sourceWorkspaceID?: string
workspaceID: string | null
sessionID: string
copyChanges: boolean
done?: () => void
}): Promise<boolean> {
let result
try {
result = await input.sdk.client.experimental.workspace.warp({
id: input.workspaceID,
sessionID: input.sessionID,
copyChanges: input.copyChanges,
})
} catch (err) {
input.toast.show({
title: "Failed to warp session",
message: errorMessage(err),
variant: "error",
})
return false
}
if (!result?.data) {
if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") {
await DialogAlert.show(
input.dialog,
"Unable to Warp Session",
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
)
return false
}
input.toast.show({
title: "Failed to warp session",
message: errorMessage(result?.error ?? "no response"),
variant: "error",
})
return false
}
input.project.workspace.set(input.workspaceID)
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
const dir = input.project.instance.directory() || input.sync.path.directory
if (dir) {
await input.sdk.client.session
.promptAsync({
sessionID: input.sessionID,
workspace: input.workspaceID ?? undefined,
noReply: true,
parts: [
{
type: "text",
text: warpReminderText(dir),
synthetic: true,
},
],
})
.catch(() => undefined)
}
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
if (input.done) {
input.done()
return true
}
input.dialog.clear()
return true
}
export async function confirmWorkspaceFileChanges(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sourceWorkspaceID?: string
}) {
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
const fileChangeChoice = status?.data?.length
? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
: "no"
if (!fileChangeChoice) return
return fileChangeChoice === "yes"
}
export function DialogWorkspaceSelect(props: {
adapters?: Adapter[]
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const dialog = useDialog()
const project = useProject()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
onMount(() => {
dialog.setSize("medium")
void (async () => {
if (adapters()) return
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
if (!res) return
setAdapters(res)
})()
})
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
const list = adapters()
if (!list) return []
const { recent, hasMore } = recentConnectedWorkspaces({
workspaces: project.workspace.list(),
status: project.workspace.status,
omitWorkspaceID: omittedWorkspaceID(),
})
return [
...list.map((adapter) => ({
title: adapter.name,
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
description: adapter.description,
category: "New workspace",
})),
{
title: "None",
value: { type: "none" as const },
description: "Use the local project",
category: "Choose workspace",
},
...recent.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: {
type: "existing" as const,
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
},
category: "Choose workspace",
})),
...(hasMore
? [
{
title: "View all workspaces",
value: { type: "existing-list" as const },
description: "Choose from all workspaces",
category: "Choose workspace",
},
]
: []),
]
})
if (!adapters()) return null
return (
<DialogSelect<WorkspaceSelectValue>
title="Warp"
skipFilter={true}
renderFilter={false}
options={options()}
onSelect={(option) => {
if (!option.value) return
if (option.value.type === "none") {
void props.onSelect(option.value)
return
}
if (option.value.type === "new") {
void props.onSelect(option.value)
return
}
if (option.value.type === "existing") {
void props.onSelect(option.value)
return
}
dialog.replace(() => (
<DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
))
}}
/>
)
}
function DialogExistingWorkspaceSelect(props: {
omitWorkspaceID?: string
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
}) {
const project = useProject()
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
project.workspace
.list()
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
.map((workspace: Workspace) => ({
title: workspace.name,
description: `(${workspace.type})`,
value: { workspace },
})),
)
return (
<DialogSelect<ExistingWorkspaceSelectValue>
title="Existing Workspace"
options={options()}
onSelect={(option) => {
void props.onSelect({
type: "existing",
workspaceID: option.value.workspace.id,
workspaceType: option.value.workspace.type,
workspaceName: option.value.workspace.name,
})
}}
/>
)
}

View File

@@ -0,0 +1,144 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
import { createMemo, For } from "solid-js"
import { createStore } from "solid-js/store"
import { Locale } from "../util/locale"
import { useTheme } from "../context/theme"
import { useTuiConfig } from "../config"
import { useDialog, type DialogContext } from "../ui/dialog"
import { getScrollAcceleration } from "../util/scroll"
const options = ["no", "yes"] as const
export type WorkspaceFileChangesChoice = (typeof options)[number]
function statusLabel(status: VcsFileStatus["status"]) {
if (status === "added") return "A"
if (status === "deleted") return "D"
return "M"
}
function changeCountWidth(file: VcsFileStatus) {
// The "plus 2" is for spaces
return `${file.additions ? `+${file.additions}` : ""}${file.deletions ? ` -${file.deletions}` : ""}`.length + 2
}
export function DialogWorkspaceFileChanges(props: {
files: VcsFileStatus[]
onSelect: (choice: WorkspaceFileChangesChoice) => void
title?: string
message?: string
}) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
const height = createMemo(() => Math.min(props.files.length, 8))
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
function confirm() {
props.onSelect(store.active)
dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
const index = options.indexOf(store.active)
setStore("active", options[Math.max(index - 1, 0)])
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
const index = options.indexOf(store.active)
setStore("active", options[Math.min(index + 1, options.length - 1)])
}
})
return (
<box gap={1}>
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{props.title ?? "File Changes Found"}
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingLeft={2} paddingRight={2}>
<text fg={theme.textMuted} wrapMode="word">
{props.message ?? "Do you want to move these changes with the session?"}
</text>
</box>
<scrollbox
height={height()}
backgroundColor={theme.backgroundElement}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
<For each={props.files}>
{(item) => (
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<box flexDirection="row" minWidth={0} flexShrink={1}>
<box width={2} flexShrink={0}>
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
</box>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateLeft(item.file, fileNameWidth())}
</text>
</box>
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
<text>
{" "}
{item.additions ? <span style={{ fg: theme.diffAdded }}>+{item.additions}</span> : null}
{item.deletions ? <span style={{ fg: theme.diffRemoved }}> -{item.deletions}</span> : null}
</text>
</box>
</box>
)}
</For>
</scrollbox>
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
props.onSelect(item)
dialog.clear()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}
DialogWorkspaceFileChanges.show = (
dialog: DialogContext,
files: VcsFileStatus[],
options?: { title?: string; message?: string },
) => {
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
dialog.replace(
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} {...options} />,
() => resolve(undefined),
)
})
}

View File

@@ -0,0 +1,112 @@
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useProject } from "../context/project"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { useTheme } from "../context/theme"
import { createMemo, createSignal, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type WorkspaceOption = { workspace: Workspace }
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const project = useProject()
const { theme } = useTheme()
const [deleting, setDeleting] = createSignal<string>()
const [removing, setRemoving] = createSignal<string>()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const current = createMemo(() => {
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
return project.workspace.current()
})
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
project.workspace
.list()
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((workspace) => {
const status = project.workspace.status(workspace.id)
return {
title:
removing() === workspace.id
? "Deleting..."
: deleting() === workspace.id
? `Delete ${workspace.name}? Press delete again`
: workspace.name,
value: { workspace },
footer: workspace.type,
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}></text>,
}
}),
)
function showDetails(workspace: Workspace) {
setExpanded(workspace.id, (open) => !open)
}
async function remove(workspace: Workspace) {
if (removing()) return
if (deleting() !== workspace.id) {
setDeleting(workspace.id)
return
}
setDeleting(undefined)
setRemoving(workspace.id)
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
error: err,
}))
if (result?.error) {
setRemoving(undefined)
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return
}
if (current() === workspace.id) {
project.workspace.set(undefined)
route.navigate({ type: "home" })
}
await project.workspace.sync()
await sync.bootstrap({ fatal: false }).catch(() => undefined)
setRemoving(undefined)
}
onMount(() => {
dialog.setSize("large")
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
void project.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
options={options()}
onMove={(option) => {
setDeleting(undefined)
}}
onSelect={(option) => showDetails(option.value.workspace)}
actions={[
{
command: "session.delete",
title: "delete",
onTrigger: (option) => void remove(option.value.workspace),
},
]}
/>
)
}

View File

@@ -0,0 +1,69 @@
import { TextAttributes } from "@opentui/core"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useBindings } from "../keymap"
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
}
useBindings(() => ({
bindings: [
{ key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() },
{ key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") },
{ key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => 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>
)
}

View File

@@ -0,0 +1,79 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createSignal } from "solid-js"
import { getScrollAcceleration } from "../util/scroll"
import { useClipboard } from "../context/clipboard"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { useExit } from "../context/exit"
export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
const term = useTerminalDimensions()
const exit = useExit()
const clipboard = useClipboard()
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
void exit()
}
})
const [copied, setCopied] = createSignal(false)
const issueURL = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
// Choose safe fallback colors per mode since theme context may not be available
const isLight = props.mode === "light"
const colors = {
bg: isLight ? "#ffffff" : "#0a0a0a",
text: isLight ? "#1a1a1a" : "#eeeeee",
muted: isLight ? "#8a8a8a" : "#808080",
primary: isLight ? "#3b7dd8" : "#fab283",
}
if (props.error.message) {
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
}
if (props.error.stack) {
issueURL.searchParams.set(
"description",
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
)
}
issueURL.searchParams.set("opencode-version", InstallationVersion)
const copyIssueURL = () => {
void clipboard.write?.(issueURL.toString()).then(() => {
setCopied(true)
})
}
return (
<box flexDirection="column" gap={1} backgroundColor={colors.bg}>
<box flexDirection="row" gap={1} alignItems="center">
<text attributes={TextAttributes.BOLD} fg={colors.text}>
Please report an issue.
</text>
<box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}>
<text attributes={TextAttributes.BOLD} fg={colors.bg}>
Copy issue URL (exception info pre-filled)
</text>
</box>
{copied() && <text fg={colors.muted}>Successfully copied</text>}
</box>
<box flexDirection="row" gap={2} alignItems="center">
<text fg={colors.text}>A fatal error occurred!</text>
<box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Reset TUI</text>
</box>
<box onMouseUp={() => void exit()} backgroundColor={colors.primary} padding={1}>
<text fg={colors.bg}>Exit</text>
</box>
</box>
<scrollbox height={Math.floor(term().height * 0.7)} scrollAcceleration={getScrollAcceleration()}>
<text fg={colors.muted}>{props.error.stack}</text>
</scrollbox>
<text fg={colors.text}>{props.error.message}</text>
</box>
)
}

View File

@@ -0,0 +1,885 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "../context/theme"
import { go, logo } from "../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)
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
// ~ = shadow top only (▀ with fg=shadow)
const GAP = 1
const WIDTH = 0.76
const GAIN = 2.3
const FLASH = 2.15
const TRAIL = 0.28
const SWELL = 0.24
const WIDE = 1.85
const DRIFT = 1.45
const EXPAND = 1.62
const LIFE = 1020
const CHARGE = 3000
const HOLD = 90
const SINK = 40
const ARC = 2.2
const FORK = 1.2
const DIM = 1.04
const KICK = 0.86
const LAG = 60
const SUCK = 0.34
const SHIMMER_IN = 60
const SHIMMER_OUT = 2.8
const TRACE = 0.033
const TAIL = 1.8
const TRACE_IN = 200
const GLOW_OUT = 1600
const PEAK = RGBA.fromInts(255, 255, 255)
type Ring = {
x: number
y: number
at: number
force: number
kick: number
}
type Hold = {
x: number
y: number
at: number
glyph: number | undefined
}
type Release = {
x: number
y: number
at: number
glyph: number | undefined
level: number
rise: number
}
type Glow = {
glyph: number
at: number
force: number
}
type Frame = {
t: number
list: Ring[]
hold: Hold | undefined
release: Release | undefined
glow: Glow | undefined
spark: number
}
const NEAR = [
[1, 0],
[1, 1],
[0, 1],
[-1, 1],
[-1, 0],
[-1, -1],
[0, -1],
[1, -1],
] as const
type Trace = {
glyph: number
i: number
l: number
}
function clamp(n: number) {
return Math.max(0, Math.min(1, n))
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * clamp(t)
}
function ease(t: number) {
const p = clamp(t)
return p * p * (3 - 2 * p)
}
function push(t: number) {
const p = clamp(t)
return ease(p * p)
}
function ramp(t: number, start: number, end: number) {
if (end <= start) return ease(t >= end ? 1 : 0)
return ease((t - start) / (end - start))
}
function glow(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
const mid = tint(base, theme.primary, 0.84)
const top = tint(theme.primary, PEAK, 0.96)
if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14))
return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1))))
}
function shade(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
if (n >= 0) return glow(base, theme, n)
return tint(base, theme.background, Math.min(0.82, -n * 0.64))
}
function ghost(n: number, scale: number) {
if (n < 0) return n
return n * scale
}
function noise(x: number, y: number, t: number) {
const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453
return n - Math.floor(n)
}
function lit(char: string) {
return char !== " " && char !== "_" && char !== "~" && char !== ","
}
function key(x: number, y: number) {
return `${x},${y}`
}
function route(list: Array<{ x: number; y: number }>) {
const left = new Map(list.map((item) => [key(item.x, item.y), item]))
const path: Array<{ x: number; y: number }> = []
let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0]
let dir = { x: 1, y: 0 }
while (cur) {
path.push(cur)
left.delete(key(cur.x, cur.y))
if (!left.size) return path
const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy)))
.filter((item): item is { x: number; y: number } => !!item)
.sort((a, b) => {
const ax = a.x - cur.x
const ay = a.y - cur.y
const bx = b.x - cur.x
const by = b.y - cur.y
const adot = ax * dir.x + ay * dir.y
const bdot = bx * dir.x + by * dir.y
if (adot !== bdot) return bdot - adot
return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by))
})[0]
if (!next) {
cur = [...left.values()].sort((a, b) => {
const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2
const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2
return da - db
})[0]
dir = { x: 1, y: 0 }
continue
}
dir = { x: next.x - cur.x, y: next.y - cur.y }
cur = next
}
return path
}
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 })
}
}
const all = new Map(cells.map((item) => [key(item.x, item.y), item]))
const seen = new Set<string>()
const glyph = new Map<string, number>()
const trace = new Map<string, Trace>()
const center = new Map<number, { x: number; y: number }>()
let id = 0
for (const item of cells) {
const start = key(item.x, item.y)
if (seen.has(start)) continue
const stack = [item]
const part = [] as Array<{ x: number; y: number }>
seen.add(start)
while (stack.length) {
const cur = stack.pop()!
part.push(cur)
glyph.set(key(cur.x, cur.y), id)
for (const [dx, dy] of NEAR) {
const next = all.get(key(cur.x + dx, cur.y + dy))
if (!next) continue
const mark = key(next.x, next.y)
if (seen.has(mark)) continue
seen.add(mark)
stack.push(next)
}
}
const path = route(part)
path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length }))
center.set(id, {
x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5,
y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1,
})
id++
}
return { glyph, trace, center }
}
type LogoContext = {
LEFT: number
FULL: string[]
SPAN: number
MAP: ReturnType<typeof mapGlyphs>
shape: LogoShape
}
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
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const p = age / LIFE
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))
const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7)
const n = band * wobble * (1 - p) ** 1.45
if (n > best) return n
return best
}, 0)
}
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 = 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, ctx: LogoContext) {
return frame.list.reduce((sum, item) => {
const age = frame.t - item.at
if (age < 0 || age > LIFE) return sum
const p = age / LIFE
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
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
const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force
const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0
const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j)
const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100)
const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110)
const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0
return sum + edge + swell + trail + flash + wake - kick - suck
}, 0)
}
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item) return 0
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
const level = held ? push(rise) : rest!.level
const body = rise
const storm = level * level
const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise
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 angle = Math.atan2(dy, dx)
const spin = frame.t * lerp(0.008, 0.018, storm)
const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014))
const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body)
const shell =
Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body)
const ember =
Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) *
lerp(0.02, 0.78, body)
const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8
const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12
const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm)
const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK
const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm)
const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm))
const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18
const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1
const flicker =
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, ctx) : 1
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
}
function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item) return 0
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
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, ctx) : 1
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
}
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]) => 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, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item || item.glyph === undefined) return 0
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
const appear = held ? ramp(age, 0, TRACE_IN) : 1
const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise)
const head = (age * speed) % step.l
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, 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 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 = 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 - 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
}
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 renderer = useRenderer()
const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>()
const [release, setRelease] = createSignal<Release>()
const [glow, setGlow] = createSignal<Glow>()
const [now, setNow] = createSignal(0)
let box: BoxRenderable | undefined
let timer: ReturnType<typeof setInterval> | undefined
const stop = () => {
if (!timer) return
clearInterval(timer)
timer = undefined
}
const tick = () => {
const t = performance.now()
setNow(t)
const item = hold()
if (item && t - item.at >= CHARGE) {
burst(item.x, item.y)
}
let live = false
setRings((list) => {
const next = list.filter((item) => t - item.at < LIFE)
live = next.length > 0
return next
})
const flash = glow()
if (flash && t - flash.at >= GLOW_OUT) {
setGlow(undefined)
}
if (!live) setRelease(undefined)
if (live || hold() || release() || glow()) return
if (props.idle) return
stop()
}
const start = () => {
if (timer) return
timer = setInterval(tick, 16)
}
onCleanup(() => {
stop()
})
onMount(() => {
if (!props.idle) return
setNow(performance.now())
start()
})
const hit = (x: number, y: number) => {
const char = ctx.FULL[y]?.[x]
return char !== undefined && char !== " "
}
const press = (x: number, y: number, t: number) => {
const last = hold()
if (last) burst(last.x, last.y)
setNow(t)
if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
start()
}
const burst = (x: number, y: number) => {
const item = hold()
if (!item) return
const t = performance.now()
const age = t - item.at
const rise = ramp(age, HOLD, CHARGE)
const level = push(rise)
setHold(undefined)
setRelease({ x, y, at: t, glyph: item.glyph, level, rise })
if (item.glyph !== undefined) {
setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) })
}
setRings((list) => [
...list,
{
x: x + 0.5,
y: y * 2 + 1,
at: t,
force: lerp(0.82, 2.55, level),
kick: lerp(0.32, 0.32 + KICK, level),
},
])
setNow(t)
start()
}
const frame = createMemo(() => {
const t = now()
const item = hold()
return {
t,
list: rings(),
hold: item,
release: release(),
glow: glow(),
spark: item ? noise(item.x, item.y, t) : 0,
}
})
const dusk = createMemo(() => {
const base = frame()
const t = base.t - LAG
const item = base.hold
return {
t,
list: base.list,
hold: item,
release: base.release,
glow: base.glow,
spark: item ? noise(item.x, item.y, t) : 0,
}
})
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
const useSubpixelBlocks = () => renderer.capabilities?.rgb === true
const renderLine = (
line: string,
y: number,
ink: RGBA,
bold: boolean,
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) => {
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(inkTinted, theme, s * 0.08)}
bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
attributes={attrs}
selectable={false}
>
{" "}
</text>
)
}
if (char === "^") {
return (
<text
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}
>
</text>
)
}
if (char === "~") {
return (
<text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text>
)
}
if (char === ",") {
return (
<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 === "█" && useSubpixelBlocks()) {
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(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
{char}
</text>
)
})
}
const mouse = (evt: MouseEvent) => {
if (!box) return
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
const x = evt.x - box.x
const y = evt.y - box.y
if (!hit(x, y)) return
if (evt.type === "drag" && hold()) return
evt.preventDefault()
evt.stopPropagation()
const t = performance.now()
press(x, y, t)
return
}
if (!hold()) return
if (evt.type === "up") {
const item = hold()
if (!item) return
burst(item.x, item.y)
}
}
return (
<box ref={(item: BoxRenderable) => (box = item)}>
<box
position="absolute"
top={0}
left={0}
width={ctx.FULL[0]?.length ?? 0}
height={ctx.FULL.length}
zIndex={1}
onMouse={mouse}
/>
<For each={ctx.shape.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">
{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>
)}
</For>
</box>
)
}
export function GoLogo() {
const { theme } = useTheme()
const base = tint(theme.background, theme.text, 0.62)
return <Logo shape={go} ink={base} idle />
}

View File

@@ -0,0 +1,14 @@
import { useTheme } from "../context/theme"
export function PluginRouteMissing(props: { id: string; onHome: () => void }) {
const { theme } = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={theme.text}>go home</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,768 @@
import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import path from "path"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useEditorContext } from "../../context/editor"
import { useProject } from "../../context/project"
import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync"
import { useData } from "../../context/data"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiPaths } from "../../context/runtime"
import { useTuiConfig } from "../../config"
import { useTheme, selectedForeground } from "../../context/theme"
import { SplitBorder } from "../../ui/border"
import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "../../util/locale"
import type { PromptInfo } from "../../prompt/history"
import { useFrecency } from "../../prompt/frecency"
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
return hashIndex !== -1 ? input.substring(0, hashIndex) : input
}
function extractLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
if (hashIndex === -1) {
return { baseQuery: input }
}
const baseName = input.substring(0, hashIndex)
const linePart = input.substring(hashIndex + 1)
const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
if (!lineMatch) {
return { baseQuery: baseName }
}
const startLine = Number(lineMatch[1])
const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
return {
lineRange: {
baseName,
startLine,
endLine,
},
baseQuery: baseName,
}
}
export type AutocompleteRef = {
onInput: (value: string) => void
visible: false | "@" | "/"
}
export type AutocompleteOption = {
display: string
value?: string
aliases?: string[]
disabled?: boolean
description?: string
isDirectory?: boolean
onSelect?: () => void
path?: string
}
export function Autocomplete(props: {
value: string
sessionID?: string
setPrompt: (input: (prompt: PromptInfo) => void) => void
setExtmark: (partIndex: number, extmarkId: number) => void
anchor: () => BoxRenderable
input: () => TextareaRenderable
ref: (ref: AutocompleteRef) => void
fileStyleId: number
agentStyleId: number
promptPartTypeId: () => number
}) {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const data = useData()
const project = useProject()
const slashes = useCommandSlashes()
const modeStack = useOpencodeModeStack()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const tuiConfig = useTuiConfig()
const paths = useTuiPaths()
const [store, setStore] = createStore({
index: 0,
selected: 0,
visible: false as AutocompleteRef["visible"],
input: "keyboard" as "keyboard" | "mouse",
})
const [positionTick, setPositionTick] = createSignal(0)
createEffect(() => {
if (!store.visible) return
const popMode = modeStack.push("autocomplete")
onCleanup(popMode)
})
createEffect(() => {
if (store.visible) {
let lastPos = { x: 0, y: 0, width: 0 }
const interval = setInterval(() => {
const anchor = props.anchor()
if (anchor.x !== lastPos.x || anchor.y !== lastPos.y || anchor.width !== lastPos.width) {
lastPos = { x: anchor.x, y: anchor.y, width: anchor.width }
setPositionTick((t) => t + 1)
}
}, 50)
onCleanup(() => clearInterval(interval))
}
})
const position = createMemo(() => {
if (!store.visible) return { x: 0, y: 0, width: 0 }
dimensions()
positionTick()
const anchor = props.anchor()
const parent = anchor.parent
const parentX = parent?.x ?? 0
const parentY = parent?.y ?? 0
return {
x: anchor.x - parentX,
y: anchor.y - parentY,
width: anchor.width,
}
})
const filter = createMemo(() => {
if (!store.visible) return
// Track props.value to make memo reactive to text changes
props.value // <- there surely is a better way to do this, like making .input() reactive
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
})
// filter() reads reactive props.value plus non-reactive cursor/text state.
// On keypress those can be briefly out of sync, so filter() may return an empty/partial string.
// Copy it into search in an effect because effects run after reactive updates have been rendered and painted
// so the input has settled and all consumers read the same stable value.
const [search, setSearch] = createSignal("")
createEffect(() => {
const next = filter()
setSearch(next ? next : "")
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
// that the mouseover event doesn't trigger when filtering.
createEffect(() => {
filter()
setStore("input", "keyboard")
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
const input = props.input()
const currentCursorOffset = input.cursorOffset
const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
const needsSpace = charAfterCursor !== " "
const append = "@" + text + (needsSpace ? " " : "")
input.cursorOffset = store.index
const startCursor = input.logicalCursor
input.cursorOffset = currentCursorOffset
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText(append)
const virtualText = "@" + text
const extmarkStart = store.index
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
const styleId = part.type === "file" ? props.fileStyleId : part.type === "agent" ? props.agentStyleId : undefined
const extmarkId = input.extmarks.create({
start: extmarkStart,
end: extmarkEnd,
virtual: true,
styleId,
typeId: props.promptPartTypeId(),
})
props.setPrompt((draft) => {
if (part.type === "file") {
const existingIndex = draft.parts.findIndex((p) => p.type === "file" && "url" in p && p.url === part.url)
if (existingIndex !== -1) {
const existing = draft.parts[existingIndex]
if (
part.source?.text &&
existing &&
"source" in existing &&
existing.source &&
"text" in existing.source &&
existing.source.text
) {
existing.source.text.start = extmarkStart
existing.source.text.end = extmarkEnd
existing.source.text.value = virtualText
}
return
}
}
if (part.type === "file" && part.source?.text) {
part.source.text.start = extmarkStart
part.source.text.end = extmarkEnd
part.source.text.value = virtualText
} else if (part.type === "agent" && part.source) {
part.source.start = extmarkStart
part.source.end = extmarkEnd
part.source.value = virtualText
}
const partIndex = draft.parts.length
draft.parts.push(part)
props.setExtmark(partIndex, extmarkId)
})
if (part.type === "file" && part.source && part.source.type === "file") {
frecency.updateFrecency(part.source.path)
}
}
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "")
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
const urlObj = pathToFileURL(fullPath)
const filename =
lineRange && !item.endsWith("/")
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
: item
if (lineRange && !item.endsWith("/")) {
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
}
return {
filename,
url: urlObj.href,
part: {
type: "file" as const,
mime: "text/plain",
filename,
url: urlObj.href,
source: {
type: "file" as const,
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
},
}
}
const references = createMemo(() => data.location.reference.list() ?? [])
const referenceMatch = createMemo(() => {
if (!store.visible || store.visible === "/") return
const { baseQuery } = extractLineRange(search())
const slash = baseQuery.indexOf("/")
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
return references().find((item) => !item.hidden && item.name === alias)
})
function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || paths.cwd
const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute)
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
return relative.split(path.sep).join("/")
}
return absolute.split(path.sep).join("/")
}
function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
const item = normalizeMentionPath(input.filePath)
const lineRange = {
startLine: input.lineStart,
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
}
const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
setStore("visible", false)
setStore("index", index)
insertPart(filename, part)
}
const [files] = createResource(
() => search(),
async (query) => {
if (!store.visible || store.visible === "/") return []
if (referenceMatch()) return []
const { lineRange, baseQuery } = extractLineRange(query ?? "")
// Get files from SDK
const result = await sdk.client.v2.fs.find({
query: baseQuery,
limit: "20",
location: { workspace: project.workspace.current() },
})
const options: AutocompleteOption[] = []
// Add file options. Trust the order returned by fff (frecency, fuzzy
// score, filename bonus, etc. are already factored in).
if (!result.error && result.data) {
const width = props.anchor().width - 4
options.push(
...result.data.data.map((item): AutocompleteOption => {
const { filename, url, part } = createFilePart(item.path, lineRange)
return {
display: Locale.truncateMiddle(filename, width),
value: filename,
isDirectory: item.type === "directory",
path: item.path,
onSelect: () => {
insertPart(filename, part)
},
}
}),
)
}
return options
},
{
initialValue: [],
},
)
const mcpResources = createMemo(() => {
if (!store.visible || store.visible === "/") return []
const options: AutocompleteOption[] = []
const width = props.anchor().width - 4
for (const res of Object.values(sync.data.mcp_resource)) {
const text = `${res.name} (${res.uri})`
options.push({
display: Locale.truncateMiddle(text, width),
value: text,
description: res.description,
onSelect: () => {
insertPart(res.name, {
type: "file",
mime: res.mimeType ?? "text/plain",
filename: res.name,
url: res.uri,
source: {
type: "resource",
text: {
start: 0,
end: 0,
value: "",
},
clientName: res.client,
uri: res.uri,
},
})
},
})
}
return options
})
const agents = createMemo(() => {
return sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.name,
onSelect: () => {
insertPart(agent.name, {
type: "agent",
name: agent.name,
source: {
start: 0,
end: 0,
value: "",
},
})
},
}),
)
})
const referenceAliases = createMemo(() =>
references()
.filter((reference) => !reference.hidden)
.map(
(reference): AutocompleteOption => ({
display: "@" + reference.name,
description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`,
onSelect: () => {
insertPart(reference.name, {
type: "file",
mime: "application/x-directory",
filename: reference.name,
url: pathToFileURL(reference.path).href,
source: {
type: "file",
text: { start: 0, end: 0, value: "" },
path: reference.name,
},
})
},
}),
),
)
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...slashes()]
for (const serverCommand of sync.data.command) {
if (serverCommand.source === "skill") continue
const label = serverCommand.source === "mcp" ? ":mcp" : ""
results.push({
display: "/" + serverCommand.name + label,
description: serverCommand.description,
onSelect: () => {
const newText = "/" + serverCommand.name + " "
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
props.input().insertText(newText)
props.input().cursorOffset = Bun.stringWidth(newText)
},
})
}
results.sort((a, b) => a.display.localeCompare(b.display))
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
if (!max) return results
return results.map((item) => ({
...item,
display: item.display.padEnd(max + 2),
}))
})
const options = createMemo((prev: AutocompleteOption[] | undefined) => {
const filesValue = files()
const referenceMatchValue = referenceMatch()
const agentsValue = agents()
const referenceAliasesValue = referenceAliases()
const commandsValue = commands()
const searchValue = search()
if (store.visible === "@" && referenceMatchValue) {
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
}
// Files come from fff already fuzzy ranked and filtered
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
if (!searchValue) {
return [...nonFileOptions, ...fileOptions]
}
if (files.loading && prev && prev.length > 0) {
return prev
}
const fuzziedNonFiles = fuzzysort
.go(removeLineRange(searchValue), nonFileOptions, {
keys: [
(obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
"description",
(obj) => obj.aliases?.join(" ") ?? "",
],
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]
let score = objResults.score
if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
score *= 2
}
const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0
return score * (1 + frecencyScore)
},
})
.map((arr) => arr.obj)
return [...fuzziedNonFiles, ...fileOptions].slice(0, 10)
})
createEffect(() => {
filter()
setStore("selected", 0)
})
function move(direction: -1 | 1) {
if (!store.visible) return
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
if (next >= options().length) next = 0
moveTo(next)
}
function moveTo(next: number) {
setStore("selected", next)
if (!scroll) return
const viewportHeight = Math.min(height(), options().length)
const scrollBottom = scroll.scrollTop + viewportHeight
if (next < scroll.scrollTop) {
scroll.scrollBy(next - scroll.scrollTop)
} else if (next + 1 > scrollBottom) {
scroll.scrollBy(next + 1 - scrollBottom)
}
}
function select() {
const selected = options()[store.selected]
if (!selected) return
hide()
selected.onSelect?.()
}
function expandDirectory() {
const selected = options()[store.selected]
if (!selected) return
const input = props.input()
const currentCursorOffset = input.cursorOffset
const displayText = (selected.value ?? selected.display).trimEnd()
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
input.cursorOffset = store.index
const startCursor = input.logicalCursor
input.cursorOffset = currentCursorOffset
const endCursor = input.logicalCursor
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText("@" + path + "/")
setStore("selected", 0)
}
useBindings(() => ({
target: props.input,
enabled: () => Boolean(store.visible),
commands: [
{
name: "prompt.autocomplete.prev",
title: "Previous autocomplete item",
category: "Autocomplete",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "prompt.autocomplete.next",
title: "Next autocomplete item",
category: "Autocomplete",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
name: "prompt.autocomplete.hide",
title: "Hide autocomplete",
category: "Autocomplete",
run() {
hide()
},
},
{
name: "prompt.autocomplete.select",
title: "Select autocomplete item",
category: "Autocomplete",
run() {
select()
},
},
{
name: "prompt.autocomplete.complete",
title: "Complete autocomplete item",
category: "Autocomplete",
run() {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
return
}
select()
},
},
],
bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [
"prompt.autocomplete.prev",
"prompt.autocomplete.next",
"prompt.autocomplete.hide",
"prompt.autocomplete.select",
"prompt.autocomplete.complete",
]),
}))
function show(mode: "@" | "/") {
setStore({
visible: mode,
index: props.input().cursorOffset,
})
}
function hide() {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
// Sync the prompt store immediately since onContentChange is async
props.setPrompt((draft) => {
draft.input = props.input().plainText
})
}
setStore("visible", false)
}
onMount(() => {
const unsubscribeMention = editor.onMention((mention) => {
insertFileMention(mention)
})
onCleanup(() => {
unsubscribeMention()
})
props.ref({
get visible() {
return store.visible
},
onInput(value) {
if (store.visible) {
if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
) {
hide()
}
return
}
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
const offset = props.input().cursorOffset
if (offset === 0) return
// Check for "/" at position 0 - reopen slash commands
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
show("/")
setStore("index", 0)
return
}
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const idx = mentionTriggerIndex(value, offset)
if (idx !== undefined) {
show("@")
setStore("index", idx)
}
},
})
})
const height = createMemo(() => {
const count = options().length || 1
if (!store.visible) return Math.min(10, count)
positionTick()
return Math.min(10, count, Math.max(1, props.anchor().y))
})
let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
<box
visible={store.visible !== false}
position="absolute"
top={position().y - height()}
left={position().x}
width={position().width}
zIndex={100}
{...SplitBorder}
borderColor={theme.border}
>
<scrollbox
ref={(r: ScrollBoxRenderable) => (scroll = r)}
backgroundColor={theme.backgroundMenu}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
<Index
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>No matching items</text>
</box>
}
>
{(option, index) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseOver={() => {
if (store.input !== "mouse") return
moveTo(index)
}}
onMouseDown={() => {
setStore("input", "mouse")
moveTo(index)
}}
onMouseUp={() => select()}
>
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
{option().display}
</text>
<Show when={option().description}>
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
{option().description}
</text>
</Show>
</box>
)}
</Index>
</scrollbox>
</box>
)
}

View File

View File

@@ -0,0 +1 @@
export * from "../../prompt/frecency"

View File

@@ -0,0 +1 @@
export * from "../../prompt/history"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
export type LocalFiles = Readonly<{
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string) {
return readLocalAttachmentWith(
{
readText: (value) => readFile(value, "utf8"),
readBytes: (value) => readFile(value),
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
},
file,
)
}
const mimeTypes: Record<string, string> = {
".avif": "image/avif",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
const mime = await files.mime(path).catch(() => undefined)
if (!mime) return
if (mime === "image/svg+xml") {
const content = await files.readText(path).catch(() => undefined)
if (!content) return
return { type: "text", mime, content }
}
if (!mime.startsWith("image/") && mime !== "application/pdf") return
const content = await files.readBytes(path).catch(() => undefined)
if (!content) return
return { type: "binary", mime, content }
}

View File

@@ -0,0 +1,193 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import path from "path"
import { useTuiPaths } from "../../context/runtime"
import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog"
import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync"
import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
function moveReminderText(directory: string) {
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
}
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
const sdk = useSDK()
const sync = useSync()
const toast = useToast()
const homeDestination = useHomeSessionDestination()
const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>()
async function create(context?: string) {
const projectID = input.projectID()
if (!projectID) return
setCreating(true)
setProgress("Creating copy")
try {
const result = await sdk.client.experimental.projectCopy.create(
{
projectID,
strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)),
context,
},
{ throwOnError: true },
)
const directory = result.data?.directory
if (!directory) throw new Error("No project copy directory returned")
// Call a location-based route to make sure it's bootstrapped
// before moving on
await sdk.client.path.get({ directory }, { throwOnError: true })
setProgress("Creating session")
return directory
} catch (err) {
homeDestination?.clear()
setProgress(undefined)
setCreating(false)
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
return
}
}
function open() {
const projectID = input.projectID()
if (!projectID) return
const sessionID = input.sessionID()
const session = sessionID ? sync.session.get(sessionID) : undefined
dialog.replace(() => (
<DialogMoveSession
projectID={projectID}
current={
homeDestination?.destination() ??
(session
? {
type: "directory",
directory: session.directory,
subdirectory: !!session.path,
}
: undefined)
}
onSelect={(selection) => {
const sessionID = input.sessionID()
if (!sessionID) {
homeDestination?.setDestination(selection)
dialog.clear()
return
}
void moveExistingSession(sessionID, selection)
}}
/>
))
}
function sessionContext(sessionID: string) {
const session = sync.session.get(sessionID)
const messages = (sync.data.message[sessionID] ?? [])
.slice(-6)
.map((message) =>
[
message.role + ":",
...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
].join(" "),
)
return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
}
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
const session = sync.session.get(sessionID)
const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
if (!choice) return
dialog.clear()
const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory
if (!directory) {
setProgress(undefined)
dialog.clear()
return
}
setProgress("Moving session")
try {
await sdk.client.experimental.controlPlane.moveSession(
{
sessionID,
destination: { directory },
moveChanges: choice === "yes",
},
{ throwOnError: true },
)
await sdk.client.session
.promptAsync({
sessionID,
directory,
noReply: true,
parts: [
{
type: "text",
text: moveReminderText(directory),
synthetic: true,
},
],
})
.catch(() => undefined)
dialog.clear()
} catch (error) {
toast.error(error)
dialog.clear()
} finally {
setProgress(undefined)
setCreating(false)
}
}
const pending = createMemo(() => Boolean(homeDestination?.destination()))
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
async function getDirectory(context?: string) {
const value = homeDestination?.destination()
if (!value) return
if (value.type === "directory") {
return value.directory
}
return await create(context)
}
function startSubmit() {
if (progress()) setProgress("Submitting prompt")
}
function finishSubmit() {
homeDestination?.clear()
setProgress(undefined)
setCreating(false)
}
createEffect(() => {
if (!creating()) {
setCreatingDots(3)
return
}
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
return {
creating,
creatingDots,
finishSubmit,
getDirectory,
open,
pending,
pendingNew,
progress,
startSubmit,
}
}

View File

@@ -0,0 +1 @@
export * from "../../prompt/stash"

View File

@@ -0,0 +1,137 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useDialog } from "../../ui/dialog"
import { useSDK } from "../../context/sdk"
import { useProject } from "../../context/project"
import { useSync } from "../../context/sync"
import { useToast } from "../../ui/toast"
import { errorMessage } from "../../util/error"
import {
confirmWorkspaceFileChanges,
openWorkspaceSelect,
warpWorkspaceSession,
type WorkspaceSelection,
} from "../dialog-workspace-create"
import type { WorkspaceStatus } from "../workspace-label"
export function usePromptWorkspace(sessionID?: string) {
const dialog = useDialog()
const sdk = useSDK()
const project = useProject()
const sync = useSync()
const toast = useToast()
const [selection, setSelection] = createSignal<WorkspaceSelection>()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [notice, setNotice] = createSignal<string>()
async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
setCreating(true)
let result
try {
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
} catch (err) {
setSelection(undefined)
setCreating(false)
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
return
}
if (result.error || !result.data) {
setSelection(undefined)
setCreating(false)
toast.show({
title: "Creating workspace failed",
message: errorMessage(result.error ?? "no response"),
variant: "error",
})
return
}
await project.workspace.sync()
const workspace = result.data
setSelection({
type: "existing",
workspaceID: workspace.id,
workspaceType: workspace.type,
workspaceName: workspace.name,
})
setCreating(false)
return workspace
}
async function warp(selection: WorkspaceSelection) {
if (!sessionID) {
setSelection(selection)
dialog.clear()
if (selection.type === "new") void create(selection)
return
}
const sourceWorkspaceID = project.workspace.current()
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
if (copyChanges === undefined) return
setSelection(selection)
dialog.clear()
const workspace =
selection.type === "none"
? { id: null, name: "local project" }
: selection.type === "existing"
? { id: selection.workspaceID, name: selection.workspaceName }
: await create(selection)
if (!workspace) return
const warped = await warpWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
sourceWorkspaceID,
workspaceID: workspace.id,
sessionID,
copyChanges,
})
if (warped) showNotice(workspace.name)
}
function showNotice(name: string) {
setNotice(`Warped to ${name}`)
setTimeout(() => setNotice(undefined), 4000)
}
function clearNotice() {
setNotice(undefined)
}
function open() {
void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
}
createEffect(() => {
if (!creating()) {
setCreatingDots(3)
return
}
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
onCleanup(() => clearInterval(timer))
})
const label = createMemo<
| { type: "new"; workspaceType: string }
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
| undefined
>(() => {
const selected = selection()
if (!selected) return
if (selected.type === "none") return
if (sessionID && !creating()) return
if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType }
return {
type: "existing",
workspaceType: selected.workspaceType,
workspaceName: selected.workspaceName,
status: selected.type === "existing" ? "connected" : undefined,
}
})
return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
}

View File

@@ -0,0 +1,24 @@
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useKV } from "../context/kv"
import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import "opentui-spinner/solid"
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const { theme } = useTheme()
const kv = useKV()
const color = () => props.color ?? theme.textMuted
return (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}> {props.children}</text>}>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
</box>
</Show>
)
}

View File

@@ -0,0 +1,63 @@
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function StartupLoading(props: { ready: () => boolean }) {
const theme = useTheme().theme
const [show, setShow] = createSignal(false)
const text = createMemo(() => (props.ready() ? "Finishing startup..." : "Loading plugins..."))
let wait: NodeJS.Timeout | undefined
let hold: NodeJS.Timeout | undefined
let stamp = 0
createEffect(() => {
if (props.ready()) {
if (wait) {
clearTimeout(wait)
wait = undefined
}
if (!show()) return
if (hold) return
const left = 3000 - (Date.now() - stamp)
if (left <= 0) {
setShow(false)
return
}
hold = setTimeout(() => {
hold = undefined
setShow(false)
}, left).unref()
return
}
if (hold) {
clearTimeout(hold)
hold = undefined
}
if (show()) return
if (wait) return
wait = setTimeout(() => {
wait = undefined
stamp = Date.now()
setShow(true)
}, 500).unref()
})
onCleanup(() => {
if (wait) clearTimeout(wait)
if (hold) clearTimeout(hold)
})
return (
<Show when={show()}>
<box position="absolute" zIndex={5000} left={0} right={0} bottom={1} justifyContent="center" alignItems="center">
<box backgroundColor={theme.backgroundPanel} paddingLeft={1} paddingRight={1}>
<Spinner color={theme.textMuted}>{text()}</Spinner>
</box>
</box>
</Show>
)
}

View File

@@ -0,0 +1,32 @@
import { useTheme } from "../context/theme"
export interface TodoItemProps {
status: string
content: string
}
export function TodoItem(props: TodoItemProps) {
const { theme } = useTheme()
return (
<box flexDirection="row" gap={0}>
<text
flexShrink={0}
style={{
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
}}
>
[{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
</text>
<text
flexGrow={1}
wrapMode="word"
style={{
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
}}
>
{props.content}
</text>
</box>
)
}

View File

@@ -0,0 +1,12 @@
import { createMemo } from "solid-js"
import { useSync } from "../context/sync"
export function useConnected() {
const sync = useSync()
return createMemo(() =>
sync.data.provider.some(
(provider) =>
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost?.input !== 0),
),
)
}

View File

@@ -0,0 +1,19 @@
import { useTheme } from "../context/theme"
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
const { theme } = useTheme()
const color = () => {
if (props.status === "connected") return theme.success
if (props.status === "error") return theme.error
return theme.textMuted
}
return (
<>
{props.icon ? <span style={{ fg: color() }}> </span> : undefined}
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
</>
)
}

View File

@@ -0,0 +1,129 @@
export * as TuiConfig from "."
import { createBindingLookup } from "@opentui/keymap/extras"
import { Schema } from "effect"
import { createContext, type JSX, useContext } from "solid-js"
import { TuiKeybind } from "./keybind"
export const AttentionSoundName = Schema.Literals([
"default",
"question",
"permission",
"error",
"done",
"subagent_done",
])
export type AttentionSoundName = Schema.Schema.Type<typeof AttentionSoundName>
export const PluginOptions = Schema.Record(Schema.String, Schema.Unknown)
export const PluginSpec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, PluginOptions]))])
export const LeaderTimeoutDefault = 2000
export const LeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
description: "Leader key timeout in milliseconds",
})
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
export const ScrollAcceleration = Schema.Struct({
enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
}).annotate({ description: "Scroll acceleration settings" })
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
})
export const AttentionSounds = Schema.Record(AttentionSoundName, Schema.optionalKey(Schema.String))
export type AttentionSoundPaths = Schema.Schema.Type<typeof AttentionSounds>
export const Attention = Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
notifications: Schema.optional(Schema.Boolean),
sound: Schema.optional(Schema.Boolean),
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
sound_pack: Schema.optional(Schema.String),
sounds: Schema.optional(AttentionSounds),
}).annotate({ description: "Attention notification and sound settings" })
const PromptSize = Schema.Int.check(Schema.isGreaterThan(0))
export const Prompt = Schema.Struct({
max_height: Schema.optional(PromptSize).annotate({ description: "Prompt textarea max height" }),
max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({
description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width",
}),
}).annotate({ description: "Prompt size settings" })
export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String),
theme: Schema.optional(Schema.String),
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
plugin: Schema.optional(Schema.Array(PluginSpec)),
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
leader_timeout: Schema.optional(LeaderTimeout),
attention: Schema.optional(Attention),
prompt: Schema.optional(Prompt),
scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }),
scroll_acceleration: Schema.optional(ScrollAcceleration),
diff_style: Schema.optional(DiffStyle),
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
})
export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "mouse"> & {
attention: {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
sound_pack: string
sounds: AttentionSoundPaths
}
keybinds: TuiKeybind.BindingLookupView
leader_timeout: number
mouse: boolean
}
export const ResolveOptions = Schema.Struct({
terminalSuspend: Schema.Boolean,
})
export type ResolveOptions = Schema.Schema.Type<typeof ResolveOptions>
export function resolve(input: Info, options: ResolveOptions): Resolved {
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
if (!options.terminalSuspend) {
keybinds.terminal_suspend = "none"
if (keybinds.input_undo === undefined) {
const inputUndo = TuiKeybind.defaultValue("input_undo")
keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
.filter((value, index, values) => values.indexOf(value) === index)
.join(",")
}
}
return {
...input,
attention: {
enabled: input.attention?.enabled ?? false,
notifications: input.attention?.notifications ?? true,
sound: input.attention?.sound ?? true,
volume: input.attention?.volume ?? 0.4,
sound_pack: input.attention?.sound_pack ?? "opencode.default",
sounds: input.attention?.sounds ?? {},
},
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader_timeout: input.leader_timeout ?? LeaderTimeoutDefault,
mouse: input.mouse ?? true,
}
}
const ConfigContext = createContext<Resolved>()
export function TuiConfigProvider(props: { config: Resolved; children: JSX.Element }) {
return <ConfigContext.Provider value={props.config}>{props.children}</ConfigContext.Provider>
}
export function useTuiConfig() {
const value = useContext(ConfigContext)
if (!value) throw new Error("TuiConfigProvider is missing")
return value
}

View File

@@ -0,0 +1,465 @@
export * as TuiKeybind from "./keybind"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingCommandMap, BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
import { Schema } from "effect"
const KeyStroke = Schema.Struct({
name: Schema.String,
ctrl: Schema.optional(Schema.Boolean),
shift: Schema.optional(Schema.Boolean),
meta: Schema.optional(Schema.Boolean),
super: Schema.optional(Schema.Boolean),
hyper: Schema.optional(Schema.Boolean),
})
const BindingObject = Schema.StructWithRest(
Schema.Struct({
key: Schema.Union([Schema.String, KeyStroke]),
event: Schema.optional(Schema.Literals(["press", "release"])),
preventDefault: Schema.optional(Schema.Boolean),
fallthrough: Schema.optional(Schema.Boolean),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
export const BindingValueSchema = Schema.Union([
Schema.Literal(false),
Schema.Literal("none"),
BindingItem,
Schema.Array(BindingItem),
])
export type BindingValueSchema = Schema.Schema.Type<typeof BindingValueSchema>
type Definition = {
default: BindingValueSchema
description: string
}
export const LeaderDefault = "ctrl+x"
const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
export const Definitions = {
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
app_debug: keybind("none", "Toggle debug panel"),
app_console: keybind("none", "Toggle console"),
app_heap_snapshot: keybind("none", "Write heap snapshot"),
app_toggle_animations: keybind("none", "Toggle animations"),
app_toggle_file_context: keybind("none", "Toggle file context"),
app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"),
app_toggle_paste_summary: keybind("none", "Toggle paste summary"),
app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"),
command_list: keybind("ctrl+p", "List available commands"),
help_show: keybind("none", "Open help dialog"),
docs_open: keybind("none", "Open documentation"),
diff_close: keybind("escape,q", "Close diff viewer"),
diff_toggle: keybind("enter,space", "Toggle diff viewer item"),
diff_expand: keybind("right", "Expand diff viewer item"),
diff_expand_all: keybind("E", "Expand all diff viewer folders"),
diff_collapse: keybind("left", "Collapse diff viewer item"),
diff_switch_focus: keybind("tab", "Switch diff viewer focus"),
diff_next_hunk: keybind("]", "Jump to next diff hunk"),
diff_previous_hunk: keybind("[", "Jump to previous diff hunk"),
diff_next_file: keybind("n", "Jump to next diff file"),
diff_previous_file: keybind("p", "Jump to previous diff file"),
diff_toggle_file_tree: keybind("b", "Toggle diff viewer file tree"),
diff_single_patch: keybind("s", "Toggle single patch view"),
diff_switch_source: keybind("d", "Switch diff viewer source"),
diff_toggle_view: keybind("v", "Toggle diff viewer split or unified view"),
diff_help: keybind("?", "Show more diff viewer shortcuts"),
editor_open: keybind("<leader>e", "Open external editor"),
theme_list: keybind("<leader>t", "List available themes"),
theme_switch_mode: keybind("none", "Switch between light and dark theme mode"),
theme_mode_lock: keybind("none", "Lock or unlock theme mode"),
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
status_view: keybind("<leader>s", "View status"),
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
session_delete: keybind("ctrl+d", "Delete session"),
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background synchronous subagents"),
session_compact: keybind("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
session_child_first: keybind("<leader>down", "Go to first child session"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
session_quick_switch_2: keybind("<leader>2", "Switch to session in quick slot 2"),
session_quick_switch_3: keybind("<leader>3", "Switch to session in quick slot 3"),
session_quick_switch_4: keybind("<leader>4", "Switch to session in quick slot 4"),
session_quick_switch_5: keybind("<leader>5", "Switch to session in quick slot 5"),
session_quick_switch_6: keybind("<leader>6", "Switch to session in quick slot 6"),
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
model_list: keybind("<leader>m", "List available models"),
model_cycle_recent: keybind("f2", "Next recently used model"),
model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"),
model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
mcp_list: keybind("none", "List MCP servers"),
provider_connect: keybind("none", "Connect provider"),
console_org_switch: keybind("none", "Switch console organization"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
variant_list: keybind("none", "List model variants"),
messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
tool_details: keybind("none", "Toggle tool details visibility"),
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
prompt_stash_list: keybind("none", "List stashed prompts"),
workspace_set: keybind("none", "Set workspace"),
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
input_move_down: keybind("down", "Move cursor down in input"),
input_select_left: keybind("shift+left", "Select left in input"),
input_select_right: keybind("shift+right", "Select right in input"),
input_select_up: keybind("shift+up", "Select up in input"),
input_select_down: keybind("shift+down", "Select down in input"),
input_line_home: keybind("ctrl+a", "Move to start of line in input"),
input_line_end: keybind("ctrl+e", "Move to end of line in input"),
input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"),
input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"),
input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"),
input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"),
input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"),
input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"),
input_buffer_home: keybind("home", "Move to start of buffer in input"),
input_buffer_end: keybind("end", "Move to end of buffer in input"),
input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"),
input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"),
input_delete_line: keybind("ctrl+shift+d", "Delete line in input"),
input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"),
input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"),
input_backspace: keybind("backspace,shift+backspace", "Backspace in input"),
input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
input_undo: keybind("ctrl+-,super+z", "Undo in input"),
input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"),
input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
input_select_all: keybind("super+a", "Select all in input"),
history_previous: keybind("up", "Previous history item"),
history_next: keybind("down", "Next history item"),
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
plugin_install: keybind("none", "Install plugin"),
which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"),
which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"),
which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"),
which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"),
which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"),
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
} satisfies Record<string, Definition>
type KeybindName = keyof typeof Definitions
const KeybindNames = new Set<string>(Object.keys(Definitions))
export const KeybindOverrides = Schema.Struct(
Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
Schema.optional(BindingValueSchema).annotate({ description: item.description }),
]),
),
).annotate({ description: "TUI keybinding overrides" })
export const Descriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
) as Record<KeybindName, string>
export const CommandMap = {
app_exit: "app.exit",
app_debug: "app.debug",
app_console: "app.console",
app_heap_snapshot: "app.heap_snapshot",
app_toggle_animations: "app.toggle.animations",
app_toggle_file_context: "app.toggle.file_context",
app_toggle_diffwrap: "app.toggle.diffwrap",
app_toggle_paste_summary: "app.toggle.paste_summary",
app_toggle_session_directory_filter: "app.toggle.session_directory_filter",
command_list: "command.palette.show",
help_show: "help.show",
docs_open: "docs.open",
diff_close: "diff.close",
diff_toggle: "diff.toggle",
diff_expand: "diff.expand",
diff_expand_all: "diff.expand_all",
diff_collapse: "diff.collapse",
diff_switch_focus: "diff.switch_focus",
diff_next_hunk: "diff.next_hunk",
diff_previous_hunk: "diff.previous_hunk",
diff_next_file: "diff.next_file",
diff_previous_file: "diff.previous_file",
diff_toggle_file_tree: "diff.toggle_file_tree",
diff_single_patch: "diff.single_patch",
diff_switch_source: "diff.switch_source",
diff_toggle_view: "diff.toggle_view",
diff_help: "diff.help",
editor_open: "prompt.editor",
theme_list: "theme.switch",
theme_switch_mode: "theme.switch_mode",
theme_mode_lock: "theme.mode.lock",
sidebar_toggle: "session.sidebar.toggle",
scrollbar_toggle: "session.toggle.scrollbar",
status_view: "opencode.status",
session_export: "session.export",
session_copy: "session.copy",
session_new: "session.new",
session_list: "session.list",
session_timeline: "session.timeline",
session_fork: "session.fork",
session_rename: "session.rename",
session_delete: "session.delete",
session_share: "session.share",
session_unshare: "session.unshare",
session_interrupt: "session.interrupt",
session_background: "session.background",
session_compact: "session.compact",
session_toggle_timestamps: "session.toggle.timestamps",
session_toggle_generic_tool_output: "session.toggle.generic_tool_output",
session_queued_prompts: "session.queued_prompts",
session_child_first: "session.child.first",
session_child_cycle: "session.child.next",
session_child_cycle_reverse: "session.child.previous",
session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle",
session_quick_switch_1: "session.quick_switch.1",
session_quick_switch_2: "session.quick_switch.2",
session_quick_switch_3: "session.quick_switch.3",
session_quick_switch_4: "session.quick_switch.4",
session_quick_switch_5: "session.quick_switch.5",
session_quick_switch_6: "session.quick_switch.6",
session_quick_switch_7: "session.quick_switch.7",
session_quick_switch_8: "session.quick_switch.8",
session_quick_switch_9: "session.quick_switch.9",
stash_delete: "stash.delete",
model_provider_list: "model.dialog.provider",
model_favorite_toggle: "model.dialog.favorite",
model_list: "model.list",
model_cycle_recent: "model.cycle_recent",
model_cycle_recent_reverse: "model.cycle_recent_reverse",
model_cycle_favorite: "model.cycle_favorite",
model_cycle_favorite_reverse: "model.cycle_favorite_reverse",
mcp_list: "mcp.list",
provider_connect: "provider.connect",
console_org_switch: "console.org.switch",
agent_list: "agent.list",
agent_cycle: "agent.cycle",
agent_cycle_reverse: "agent.cycle.reverse",
variant_cycle: "variant.cycle",
variant_list: "variant.list",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_line_up: "session.line.up",
messages_line_down: "session.line.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
messages_next: "session.message.next",
messages_previous: "session.message.previous",
messages_last_user: "session.messages_last_user",
messages_copy: "messages.copy",
messages_undo: "session.undo",
messages_redo: "session.redo",
messages_toggle_conceal: "session.toggle.conceal",
tool_details: "session.toggle.actions",
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop",
prompt_stash_list: "prompt.stash.list",
workspace_set: "workspace.set",
input_clear: "prompt.clear",
input_paste: "prompt.paste",
input_submit: "input.submit",
input_newline: "input.newline",
input_move_left: "input.move.left",
input_move_right: "input.move.right",
input_move_up: "input.move.up",
input_move_down: "input.move.down",
input_select_left: "input.select.left",
input_select_right: "input.select.right",
input_select_up: "input.select.up",
input_select_down: "input.select.down",
input_line_home: "input.line.home",
input_line_end: "input.line.end",
input_select_line_home: "input.select.line.home",
input_select_line_end: "input.select.line.end",
input_visual_line_home: "input.visual.line.home",
input_visual_line_end: "input.visual.line.end",
input_select_visual_line_home: "input.select.visual.line.home",
input_select_visual_line_end: "input.select.visual.line.end",
input_buffer_home: "input.buffer.home",
input_buffer_end: "input.buffer.end",
input_select_buffer_home: "input.select.buffer.home",
input_select_buffer_end: "input.select.buffer.end",
input_delete_line: "input.delete.line",
input_delete_to_line_end: "input.delete.to.line.end",
input_delete_to_line_start: "input.delete.to.line.start",
input_backspace: "input.backspace",
input_delete: "input.delete",
input_undo: "input.undo",
input_redo: "input.redo",
input_word_forward: "input.word.forward",
input_word_backward: "input.word.backward",
input_select_word_forward: "input.select.word.forward",
input_select_word_backward: "input.select.word.backward",
input_delete_word_forward: "input.delete.word.forward",
input_delete_word_backward: "input.delete.word.backward",
input_select_all: "input.select.all",
history_previous: "prompt.history.previous",
history_next: "prompt.history.next",
terminal_suspend: "terminal.suspend",
terminal_title_toggle: "terminal.title.toggle",
tips_toggle: "tips.toggle",
plugin_manager: "plugins.list",
plugin_install: "plugins.install",
which_key_toggle: "which-key.toggle",
which_key_layout_toggle: "which-key.layout.toggle",
which_key_pending_toggle: "which-key.pending.toggle",
which_key_group_previous: "which-key.group.previous",
which_key_group_next: "which-key.group.next",
which_key_scroll_up: "which-key.scroll.up",
which_key_scroll_down: "which-key.scroll.down",
which_key_page_up: "which-key.page.up",
which_key_page_down: "which-key.page.down",
which_key_home: "which-key.home",
which_key_end: "which-key.end",
} satisfies BindingCommandMap
const CommandDescriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
CommandMap[name as keyof typeof CommandMap] ?? name,
item.description,
]),
) as Record<string, string>
export type Keybinds = { [K in KeybindName]: BindingValueSchema }
export type KeybindOverrides = Partial<Keybinds>
export type BindingLookupView = {
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
get(command: string): readonly Binding<Renderable, KeyEvent>[]
has(command: string): boolean
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
pick(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
omit(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
}
export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {
return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
}
const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema)
export function defaultValue(name: KeybindName) {
return Definitions[name].default
}
export function parse(keybinds: KeybindOverrides): Keybinds {
const invalid = unknownKeys(keybinds)
if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`)
return Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
decodeBindingValue(keybinds[name as KeybindName] ?? item.default),
]),
) as Keybinds
}
export const Keybinds = { parse }
export function unknownKeys(input: object) {
return Object.keys(input).filter((key) => !KeybindNames.has(key))
}
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
return ({ command, binding }) => {
if (binding.desc !== undefined) return
return { desc: CommandDescriptions[command] }
}
}

View File

@@ -0,0 +1,15 @@
import { createSimpleContext } from "./helper"
export interface Args {
model?: string
agent?: string
prompt?: string
continue?: boolean
sessionID?: string
fork?: boolean
}
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({
name: "Args",
init: (props: Args) => props,
})

View File

@@ -0,0 +1,18 @@
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"
export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
}>
const clipboard = { read, write }
const ClipboardContext = createContext<ClipboardService>(clipboard)
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
}
export function useClipboard() {
return useContext(ClipboardContext)
}

View File

@@ -0,0 +1,587 @@
import { useEvent } from "./event"
import type {
AgentV2Info,
CommandV2Info,
ConnectorInfo,
Event,
LocationRef,
ModelV2Info,
PermissionSavedInfo,
PermissionV2Request,
ProviderV2Info,
QuestionV2Request,
ReferenceInfo,
SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionV2Info,
SkillV2Info,
} from "@opencode-ai/sdk/v2"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
import { createSignal, onMount } from "solid-js"
type LocationData = {
agent?: AgentV2Info[]
command?: CommandV2Info[]
connector?: ConnectorInfo[]
model?: ModelV2Info[]
provider?: ProviderV2Info[]
reference?: ReferenceInfo[]
skill?: SkillV2Info[]
}
type Data = {
session: {
info: Record<string, SessionV2Info>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
}
project: {
permission: Record<string, PermissionSavedInfo[]>
}
location: Record<string, LocationData>
}
function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
}
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: () => {
const [store, setStore] = createStore<Data>({
session: {
info: {},
message: {},
permission: {},
question: {},
},
project: {
permission: {},
},
location: {},
})
const event = useEvent()
const sdk = useSDK()
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
directory: sdk.directory ?? process.cwd(),
})
const message = {
update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
setStore(
"session",
"message",
produce((draft) => {
fn((draft[sessionID] ??= []))
}),
)
},
prepend(messages: SessionMessage[], item: SessionMessage) {
if (messages.some((existing) => existing.id === item.id)) return
messages.unshift(item)
},
activeAssistant(messages: SessionMessage[]) {
const item = messages.find((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
},
assistant(messages: SessionMessage[], messageID: string) {
const item = messages.find((item) => item.type === "assistant" && item.id === messageID)
return item?.type === "assistant" ? item : undefined
},
activeShell(messages: SessionMessage[], callID: string) {
const item = messages.find((item) => item.type === "shell" && item.callID === callID)
return item?.type === "shell" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool =>
item.type === "tool" && (callID === undefined || item.id === callID),
)
},
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
)
},
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
)
},
}
event.subscribe((event, metadata) => {
switch (event.type) {
case "session.next.agent.switched":
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "agent-switched",
agent: event.properties.agent,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.model.switched":
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "model-switched",
model: event.properties.model,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.prompted": {
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timestamp },
})
})
break
}
case "session.next.prompt.admitted":
break
case "session.next.prompt.promoted":
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "user",
text: event.properties.prompt.text,
files: event.properties.prompt.files,
agents: event.properties.prompt.agents,
time: { created: event.properties.timeCreated },
})
})
break
case "session.next.context.updated":
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "system",
text: event.properties.text,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.synthetic":
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "synthetic",
sessionID: event.properties.sessionID,
text: event.properties.text,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.shell.started":
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "shell",
callID: event.properties.callID,
command: event.properties.command,
output: "",
time: { created: event.properties.timestamp },
})
})
break
case "session.next.shell.ended":
message.update(event.properties.sessionID, (draft) => {
const match = message.activeShell(draft, event.properties.callID)
if (!match) return
match.output = event.properties.output
match.time.completed = event.properties.timestamp
})
break
case "session.next.step.started":
message.update(event.properties.sessionID, (draft) => {
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
message.prepend(draft, {
id: event.properties.assistantMessageID,
type: "assistant",
agent: event.properties.agent,
model: event.properties.model,
content: [],
snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined,
time: { created: event.properties.timestamp },
})
})
break
case "session.next.step.ended":
message.update(event.properties.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = event.properties.finish
currentAssistant.cost = event.properties.cost
currentAssistant.tokens = event.properties.tokens
if (event.properties.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
})
break
case "session.next.step.failed":
message.update(event.properties.sessionID, (draft) => {
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = "error"
currentAssistant.error = event.properties.error
})
break
case "session.next.text.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
type: "text",
id: event.properties.textID,
text: "",
})
})
break
case "session.next.text.delta":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestText(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text += event.properties.delta
})
break
case "session.next.text.ended":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestText(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.textID,
)
if (match) match.text = event.properties.text
})
break
case "session.next.tool.input.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
type: "tool",
id: event.properties.callID,
name: event.properties.name,
time: { created: event.properties.timestamp },
state: { status: "pending", input: "" },
})
})
break
case "session.next.tool.input.delta":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status === "pending") match.state.input += event.properties.delta
})
break
case "session.next.tool.input.ended":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status === "pending") match.state.input = event.properties.text
})
break
case "session.next.tool.called":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (!match) return
match.time.ran = event.properties.timestamp
match.provider = event.properties.provider
match.state = { status: "running", input: event.properties.input, structured: {}, content: [] }
})
break
case "session.next.tool.progress":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status !== "running") return
match.state.structured = event.properties.structured
match.state.content = [...event.properties.content]
})
break
case "session.next.tool.success":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (match?.state.status !== "running") return
match.state = {
status: "completed",
input: match.state.input,
structured: event.properties.structured,
content: [...event.properties.content],
result: event.properties.result,
}
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
}
match.time.completed = event.properties.timestamp
})
break
case "session.next.tool.failed":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestTool(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.callID,
)
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
match.state = {
status: "error",
error: event.properties.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
structured: match.state.status === "running" ? match.state.structured : {},
content: match.state.status === "running" ? match.state.content : [],
result: event.properties.result,
}
match.provider = {
executed: event.properties.provider.executed || match.provider?.executed === true,
metadata: match.provider?.metadata,
resultMetadata: event.properties.provider.metadata,
}
match.time.completed = event.properties.timestamp
})
break
case "session.next.reasoning.started":
message.update(event.properties.sessionID, (draft) => {
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
type: "reasoning",
id: event.properties.reasoningID,
text: "",
providerMetadata: event.properties.providerMetadata,
})
})
break
case "session.next.reasoning.delta":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) match.text += event.properties.delta
})
break
case "session.next.reasoning.ended":
message.update(event.properties.sessionID, (draft) => {
const match = message.latestReasoning(
message.assistant(draft, event.properties.assistantMessageID),
event.properties.reasoningID,
)
if (match) {
match.text = event.properties.text
if (event.properties.providerMetadata !== undefined)
match.providerMetadata = event.properties.providerMetadata
}
})
break
case "session.next.retried":
case "session.next.compaction.started":
case "session.next.compaction.delta":
break
case "session.next.compaction.ended":
message.update(event.properties.sessionID, (draft) => {
message.prepend(draft, {
id: event.properties.messageID,
type: "compaction",
reason: event.properties.reason,
summary: event.properties.text,
recent: event.properties.recent,
time: { created: event.properties.timestamp },
})
})
break
case "reference.updated":
void result.location.reference.refresh()
break
case "credential.switched": {
const location = { directory: metadata.directory, workspaceID: metadata.workspace }
void Promise.allSettled([result.location.model.refresh(location), result.location.provider.refresh(location)])
break
}
case "connector.updated":
void result.location.connector.refresh({ directory: metadata.directory, workspaceID: metadata.workspace })
break
}
})
const result = {
session: {
get(sessionID: string) {
return store.session.info[sessionID]
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
setStore("session", "info", sessionID, result.data.data)
},
message: {
list(sessionID: string) {
return store.session.message[sessionID]
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true })
setStore("session", "message", sessionID, result.data.data)
},
},
permission: {
list(sessionID: string) {
return store.session.permission[sessionID]
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true })
setStore("session", "permission", sessionID, result.data.data)
},
},
question: {
list(sessionID: string) {
return store.session.question[sessionID]
},
async refresh(sessionID: string) {
const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true })
setStore("session", "question", sessionID, result.data.data)
},
},
},
project: {
permission: {
list(projectID: string) {
return store.project.permission[projectID]
},
async refresh(projectID: string) {
const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true })
setStore("project", "permission", projectID, result.data.data)
},
},
},
location: {
default() {
return defaultLocation()
},
async refresh(ref?: LocationRef) {
const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true })
const location = response.data
const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {})
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
},
agent: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.agent
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "agent", result.data.data)
},
},
command: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.command
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "command", result.data.data)
},
},
connector: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.connector
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.connector.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "connector", result.data.data)
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "model", result.data.data)
},
},
provider: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.provider
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "provider", result.data.data)
},
},
reference: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "reference", result.data.data)
},
},
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
},
async refresh(ref?: LocationRef) {
const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
const key = locationKey(result.data.location)
setStore("location", key, "skill", result.data.data)
},
},
},
}
onMount(() => {
void Promise.allSettled([
result.location.refresh(),
result.location.agent.refresh(),
result.location.connector.refresh(),
result.location.model.refresh(),
result.location.provider.refresh(),
result.location.reference.refresh(),
result.location.command.refresh(),
result.location.skill.refresh(),
]).then((settled) => {
for (const failure of settled.filter((item) => item.status === "rejected"))
console.error("Failed to refresh default location data", failure.reason)
})
})
return result
},
})

View File

@@ -0,0 +1,17 @@
import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useSync } from "./sync"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
export function useDirectory() {
const project = useProject()
const sync = useSync()
const paths = useTuiPaths()
return createMemo(() => {
const directory = project.instance.path().directory || paths.cwd
const result = abbreviateHome(directory, paths.home)
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result
})
}

View File

@@ -0,0 +1,408 @@
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Option, Schema, SchemaGetter } from "effect"
import { isRecord } from "../util/record"
import { useTuiPaths } from "./runtime"
import { createSimpleContext } from "./helper"
import { editorIntegration } from "../editor"
const MCP_PROTOCOL_VERSION = "2025-11-25"
const JsonRpcMessageSchema = Schema.Struct({
id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])),
method: Schema.optional(Schema.String),
params: Schema.optional(Schema.Unknown),
result: Schema.optional(Schema.Unknown),
error: Schema.optional(
Schema.Struct({
code: Schema.optional(Schema.Number),
message: Schema.optional(Schema.String),
}),
),
})
const PositionSchema = Schema.Struct({
line: Schema.Number,
character: Schema.Number,
})
const EditorSelectionRangeSchema = Schema.Struct({
text: Schema.String,
selection: Schema.Struct({
start: PositionSchema,
end: PositionSchema,
}),
})
const EditorSelectionRangesSchema = Schema.Struct({
filePath: Schema.String,
source: Schema.optional(Schema.Literals(["websocket", "zed"])),
ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))),
})
const EditorSelectionSchema = Schema.Union([
EditorSelectionRangesSchema,
Schema.Struct({
text: Schema.String,
filePath: Schema.String,
source: Schema.optional(Schema.Literals(["websocket", "zed"])),
selection: Schema.Struct({
start: PositionSchema,
end: PositionSchema,
}),
}),
]).pipe(
Schema.decodeTo(EditorSelectionRangesSchema, {
decode: SchemaGetter.transform((value) =>
"ranges" in value
? value
: {
filePath: value.filePath,
source: value.source,
ranges: [
{
text: value.text,
selection: value.selection,
},
],
},
),
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
const EditorMentionSchema = Schema.Struct({
filePath: Schema.String,
lineStart: Schema.Number,
lineEnd: Schema.Number,
})
const EditorServerInfoSchema = Schema.Struct({
protocolVersion: Schema.optional(Schema.String),
serverInfo: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
}),
),
})
const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema)
const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema)
const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema)
const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema)
type JsonRpcMessage = Schema.Schema.Type<typeof JsonRpcMessageSchema>
export type EditorSelection = Schema.Schema.Type<typeof EditorSelectionSchema>
export type EditorMention = Schema.Schema.Type<typeof EditorMentionSchema>
export type EditorLabelState = "pending" | "sent" | "none"
type EditorServerInfo = Schema.Schema.Type<typeof EditorServerInfoSchema>
type EditorConnection = {
url: string
authToken?: string
source: string
}
export type EditorIntegration = Readonly<{
connection?(directory: string): EditorConnection | undefined
selection?(directory: string): Promise<unknown>
}>
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
name: "EditorContext",
init: (props: { integration?: EditorIntegration; WebSocketImpl?: typeof WebSocket }) => {
const paths = useTuiPaths()
const editor = props.integration ?? editorIntegration
const value = process.env.CLAUDE_CODE_SSE_PORT || process.env.OPENCODE_EDITOR_SSE_PORT
const parsedPort = value ? Number.parseInt(value, 10) : undefined
const port =
parsedPort && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 ? parsedPort : undefined
const zedTerminal = process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
const mentionListeners = new Set<(mention: EditorMention) => void>()
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
const [store, setStore] = createStore<{
status: "disabled" | "connecting" | "connected"
selection: EditorSelection | undefined
selectionSent: boolean
server: EditorServerInfo | undefined
}>({
status: "disabled",
selection: undefined,
selectionSent: false,
server: undefined,
})
let socket: WebSocket | undefined
let closed = false
let reconnect: ReturnType<typeof setTimeout> | undefined
let attempt = 0
let requestID = 0
let zedSelection: Promise<void> | undefined
let lastZedSelectionKey: string | undefined
let directory = paths.cwd
let preserveSelectionOnReconnect = false
const pending = new Map<number, string>()
const setSelection = (selection: EditorSelection | undefined) => {
const changed = editorSelectionKey(selection) !== editorSelectionKey(store.selection)
setStore("selection", selection)
if (changed) setStore("selectionSent", false)
}
const clearSelectionForReconnect = (options?: { resetZedSelectionKey?: boolean }) => {
if (preserveSelectionOnReconnect) {
preserveSelectionOnReconnect = false
return
}
if (options?.resetZedSelectionKey) lastZedSelectionKey = undefined
setSelection(undefined)
}
const send = (payload: JsonRpcMessage) => {
if (!socket || socket.readyState !== 1) return
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
}
const request = (method: string, params?: unknown) => {
requestID += 1
pending.set(requestID, method)
send({ id: requestID, method, params })
}
const connect = () => {
if (closed) return
const connection = resolveEditorConnection(directory, port, editor.connection)
if (!connection) {
if (!zedTerminal) {
setStore("status", "disabled")
scheduleReconnect()
return
}
if (!editor.selection) {
setStore("status", "disabled")
scheduleReconnect()
return
}
zedSelection ??= editor
.selection(directory)
.then((result) => {
if (closed || socket) return
if (!isRecord(result) || result.type === "unavailable") return
const decoded = result.type === "selection" ? decodeEditorSelection(result.selection) : Option.none()
const selection = Option.getOrUndefined(decoded)
const key = editorSelectionKey(selection)
if (key !== lastZedSelectionKey) {
lastZedSelectionKey = key
setSelection(selection)
setStore("status", selection ? "connected" : "disabled")
}
})
.catch(() => {
// Keep the last known Zed selection for transient polling failures.
})
.finally(() => {
zedSelection = undefined
})
scheduleZedPoll()
return
}
setStore("status", "connecting")
const current = openEditorSocket(connection, WebSocketImpl)
socket = current
current.addEventListener("open", () => {
if (socket !== current) {
current.close()
return
}
attempt = 0
setStore("status", "connected")
request("initialize", {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "opencode", version: "0.0.0" },
})
})
current.addEventListener("message", (event) => {
const message = parseMessage(event.data)
if (!message) return
const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none()
if (Option.isSome(selection)) {
setSelection({ ...selection.value, source: "websocket" })
return
}
const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none()
if (Option.isSome(mention)) {
mentionListeners.forEach((listener) => listener(mention.value))
return
}
if (typeof message.id !== "number") return
const method = pending.get(message.id)
if (!method) return
pending.delete(message.id)
if (message.error) return
const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none()
if (Option.isSome(initialize)) {
setStore("server", initialize.value)
send({ method: "notifications/initialized" })
return
}
})
current.addEventListener("close", () => {
if (socket !== current) return
socket = undefined
pending.clear()
if (closed) return
setStore("status", "connecting")
scheduleReconnect()
})
}
const scheduleReconnect = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
reconnect = setTimeout(connect, delay)
}
const scheduleZedPoll = () => {
if (closed) return
if (reconnect) clearTimeout(reconnect)
reconnect = setTimeout(connect, 1000)
}
const reconnectWithDirectory = (nextDirectory?: string) => {
const resolved = nextDirectory || paths.cwd
const sameDirectory = directory === resolved
clearSelectionForReconnect({ resetZedSelectionKey: !sameDirectory })
if (sameDirectory) return
directory = resolved
attempt = 0
pending.clear()
if (reconnect) clearTimeout(reconnect)
reconnect = undefined
if (socket) {
const current = socket
socket = undefined
current.close()
}
setStore("status", "disabled")
setStore("server", undefined)
connect()
}
onMount(() => {
connect()
onCleanup(() => {
closed = true
if (reconnect) clearTimeout(reconnect)
socket?.close()
})
})
return {
enabled() {
return Boolean(resolveEditorConnection(directory, port, editor.connection) || (zedTerminal && editor.selection))
},
connected() {
return store.status === "connected"
},
selection() {
return store.selection
},
clearSelection() {
lastZedSelectionKey = undefined
zedSelection = undefined
setSelection(undefined)
},
preserveSelectionFromNewSession() {
preserveSelectionOnReconnect = true
},
markSelectionSent() {
if (!store.selection) return
setStore("selectionSent", true)
},
labelState(): EditorLabelState {
if (!store.selection) return "none"
return store.selectionSent ? "sent" : "pending"
},
onMention(listener: (mention: EditorMention) => void) {
mentionListeners.add(listener)
return () => mentionListeners.delete(listener)
},
server() {
return store.server
},
reconnect(directory?: string) {
reconnectWithDirectory(directory)
},
}
},
})
function resolveEditorConnection(
directory: string,
port: number | undefined,
discover: ((directory: string) => EditorConnection | undefined) | undefined,
): EditorConnection | undefined {
if (port) {
return {
url: `ws://127.0.0.1:${port}`,
source: `env:${port}`,
}
}
return discover?.(directory)
}
export function editorSelectionKey(selection: EditorSelection | undefined) {
if (!selection) return ""
return [
selection.filePath,
...selection.ranges.flatMap((range) => [
range.selection.start.line,
range.selection.start.character,
range.selection.end.line,
range.selection.end.character,
range.text,
]),
].join("\0")
}
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
if (!connection.authToken) return new WebSocketImpl(connection.url)
return new WebSocketImpl(connection.url, {
headers: {
"x-claude-code-ide-authorization": connection.authToken,
},
} as any)
}
function parseMessage(value: unknown) {
if (typeof value !== "string") return
try {
return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value)))
} catch {
return
}
}

View File

@@ -0,0 +1,6 @@
import { createSimpleContext } from "./helper"
export const { use: useEpilogue, provider: EpilogueProvider } = createSimpleContext({
name: "Epilogue",
init: (props: { set(value?: string): void }) => props.set,
})

View File

@@ -0,0 +1,36 @@
import type { Event } from "@opencode-ai/sdk/v2"
import { useSDK } from "./sdk"
type EventMetadata = {
directory: string
workspace: string | undefined
}
export function useEvent() {
const sdk = useSDK()
function subscribe(handler: (event: Event, metadata: EventMetadata) => void) {
return sdk.event.on("event", (event) => {
if (event.payload.type === "sync") {
return
}
handler(event.payload, { directory: event.directory, workspace: event.workspace })
})
}
function on<T extends Event["type"]>(
type: T,
handler: (event: Extract<Event, { type: T }>, metadata: EventMetadata) => void,
) {
return subscribe((event: Event, metadata: EventMetadata) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>, metadata)
})
}
return {
subscribe,
on,
}
}

View File

@@ -0,0 +1,8 @@
import { createSimpleContext } from "./helper"
export type Exit = (reason?: unknown) => void
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { exit: Exit }) => input.exit,
})

View File

@@ -0,0 +1,26 @@
import { createContext, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
context: ctx,
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
return (
// @ts-expect-error
<Show when={init.ready === undefined || init.ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}

View File

@@ -0,0 +1,66 @@
import { createSignal, type Setter } from "solid-js"
import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { Flock } from "@opencode-ai/core/util/flock"
import { Global } from "@opencode-ai/core/global"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTuiPaths } from "./runtime"
import path from "path"
export const { use: useKV, provider: KVProvider } = createSimpleContext({
name: "KV",
init: () => {
const paths = useTuiPaths()
void Global.Path.state
const file = path.join(paths.state, "kv.json")
const lock = `tui-kv:${file}`
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
// Queue same-process writes so rapid updates persist in order.
let write = Promise.resolve()
Flock.withLock(lock, () => readJson<Record<string, unknown>>(file))
.then((x) => {
setStore(x)
})
.catch((error) => {
console.error("Failed to read KV state", { error })
})
.finally(() => {
setReady(true)
})
const result = {
get ready() {
return ready()
},
get store() {
return store
},
signal<T>(name: string, defaultValue: T) {
if (store[name] === undefined) setStore(name, defaultValue)
return [
function () {
return result.get(name)
},
function setter(next: Setter<T>) {
result.set(name, next)
},
] as const
},
get(key: string, defaultValue?: any) {
return store[key] ?? defaultValue
},
set(key: string, value: any) {
setStore(key, value)
const snapshot = structuredClone(unwrap(store))
write = write
.then(() => Flock.withLock(lock, () => writeJsonAtomic(file, snapshot)))
.catch((error) => {
console.error("Failed to write KV state", { error })
})
},
}
return result
},
})

View File

@@ -0,0 +1,540 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "./sync"
import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTheme } from "./theme"
import { useToast } from "../ui/toast"
import { useRoute } from "./route"
export type LocalTheme = {
secondary: RGBA
accent: RGBA
success: RGBA
warning: RGBA
primary: RGBA
error: RGBA
info: RGBA
}
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
export function recentModels(
model: { providerID: string; modelID: string },
recent: { providerID: string; modelID: string }[],
) {
const seen = new Set<string>()
return [model, ...recent]
.filter((item) => {
const key = `${item.providerID}/${item.modelID}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.slice(0, 10)
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const theme = useTheme().theme
const route = useRoute()
const paths = useTuiPaths()
function isModelValid(model: { providerID: string; modelID: string }) {
const provider = sync.data.provider.find((item) => item.id === model.providerID)
return !!provider?.models[model.modelID]
}
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model)) return model
}
}
function createAgent() {
const agents = createMemo(() => sync.data.agent.filter((agent) => agent.mode !== "subagent" && !agent.hidden))
const visibleAgents = createMemo(() => sync.data.agent.filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
const colors = createMemo(() => [
theme.secondary,
theme.accent,
theme.success,
theme.warning,
theme.primary,
theme.error,
theme.info,
])
return {
list() {
return agents()
},
current() {
return agents().find((x) => x.name === agentStore.current) ?? agents().at(0)
},
set(name: string) {
if (!agents().some((x) => x.name === name))
return toast.show({
variant: "warning",
message: `Agent not found: ${name}`,
duration: 3000,
})
setAgentStore("current", name)
},
move(direction: 1 | -1) {
batch(() => {
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]
setAgentStore("current", value.name)
})
},
color(name: string) {
const index = visibleAgents().findIndex((x) => x.name === name)
if (index === -1) return colors()[0]
const agent = visibleAgents()[index]
if (agent?.color) {
const color = agent.color
if (color.startsWith("#")) return RGBA.fromHex(color)
// already validated by config, just satisfying TS here
return theme[color as keyof typeof theme] as RGBA
}
return colors()[index % colors().length]
},
}
}
const agent = createAgent()
function createModel() {
const [modelStore, setModelStore] = createStore<{
ready: boolean
model: Record<
string,
{
providerID: string
modelID: string
}
>
recent: {
providerID: string
modelID: string
}[]
favorite: {
providerID: string
modelID: string
}[]
variant: Record<string, string | undefined>
}>({
ready: false,
model: {},
recent: [],
favorite: [],
variant: {},
})
const filePath = path.join(paths.state, "model.json")
const state = {
pending: false,
}
function save() {
if (!modelStore.ready) {
state.pending = true
return
}
state.pending = false
void writeJsonAtomic(filePath, {
recent: modelStore.recent,
favorite: modelStore.favorite,
variant: modelStore.variant,
})
}
readJson<unknown>(filePath)
.then((x) => {
if (!x || typeof x !== "object") return
const value = x as Record<string, unknown>
if (Array.isArray(value.recent)) setModelStore("recent", value.recent)
if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite)
if (typeof value.variant === "object" && value.variant !== null)
setModelStore("variant", value.variant as Record<string, string | undefined>)
})
.catch(() => {})
.finally(() => {
setModelStore("ready", true)
if (state.pending) save()
})
const args = useArgs()
const fallbackModel = createMemo(() => {
if (args.model) {
const { providerID, modelID } = parseModel(args.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
if (sync.data.config.model) {
const { providerID, modelID } = parseModel(sync.data.config.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
for (const item of modelStore.recent) {
if (isModelValid(item)) {
return item
}
}
const provider = sync.data.provider[0]
if (!provider) return undefined
const defaultModel = sync.data.provider_default[provider.id]
const firstModel = Object.values(provider.models)[0]
const model = defaultModel ?? firstModel?.id
if (!model) return undefined
return {
providerID: provider.id,
modelID: model,
}
})
const currentModel = createMemo(() => {
const a = agent.current()
return (
getFirstValidModel(
() => a && modelStore.model[a.name],
() => a && a.model,
fallbackModel,
) ?? undefined
)
})
return {
current: currentModel,
get ready() {
return modelStore.ready
},
recent() {
return modelStore.recent
},
favorite() {
return modelStore.favorite
},
parsed: createMemo(() => {
const value = currentModel()
if (!value) {
return {
provider: "Connect a provider",
model: "No provider selected",
reasoning: false,
}
}
const provider = sync.data.provider.find((item) => item.id === value.providerID)
const info = provider?.models[value.modelID]
return {
provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
reasoning: info?.capabilities?.reasoning ?? false,
}
}),
cycle(direction: 1 | -1) {
const current = currentModel()
if (!current) return
const recent = modelStore.recent
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
if (index === -1) return
let next = index + direction
if (next < 0) next = recent.length - 1
if (next >= recent.length) next = 0
const val = recent[next]
if (!val) return
const a = agent.current()
if (!a) return
setModelStore("model", a.name, { ...val })
},
cycleFavorite(direction: 1 | -1) {
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
if (!favorites.length) {
toast.show({
variant: "info",
message: "Add a favorite model to use this shortcut",
duration: 3000,
})
return
}
const current = currentModel()
let index = -1
if (current) {
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
}
if (index === -1) {
index = direction === 1 ? 0 : favorites.length - 1
} else {
index += direction
if (index < 0) index = favorites.length - 1
if (index >= favorites.length) index = 0
}
const next = favorites[index]
if (!next) return
const a = agent.current()
if (!a) return
setModelStore("model", a.name, { ...next })
setModelStore("recent", recentModels(next, modelStore.recent))
save()
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
if (!isModelValid(model)) {
toast.show({
message: `Model ${model.providerID}/${model.modelID} is not valid`,
variant: "warning",
duration: 3000,
})
return
}
const a = agent.current()
if (!a) return
setModelStore("model", a.name, model)
if (options?.recent) {
setModelStore("recent", recentModels(model, modelStore.recent))
save()
}
})
},
toggleFavorite(model: { providerID: string; modelID: string }) {
batch(() => {
if (!isModelValid(model)) {
toast.show({
message: `Model ${model.providerID}/${model.modelID} is not valid`,
variant: "warning",
duration: 3000,
})
return
}
const exists = modelStore.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
)
const next = exists
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...modelStore.favorite]
setModelStore(
"favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
save()
})
},
variant: {
selected() {
const m = currentModel()
if (!m) return undefined
const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key]
},
current() {
const v = this.selected()
if (!v) return undefined
if (!this.list().includes(v)) return undefined
return v
},
list() {
const m = currentModel()
if (!m) return []
const provider = sync.data.provider.find((item) => item.id === m.providerID)
const info = provider?.models[m.modelID]
if (!info?.variants) return []
return Object.keys(info.variants)
},
set(value: string | undefined) {
const m = currentModel()
if (!m) return
const key = `${m.providerID}/${m.modelID}`
setModelStore("variant", key, value ?? "default")
save()
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
const current = this.current()
if (!current) {
this.set(variants[0])
return
}
const index = variants.indexOf(current)
if (index === -1 || index === variants.length - 1) {
this.set(undefined)
return
}
this.set(variants[index + 1])
},
},
}
}
const model = createModel()
function createSession() {
const [sessionStore, setSessionStore] = createStore<{
ready: boolean
pinned: string[]
}>({
ready: false,
pinned: [],
})
const filePath = path.join(paths.state, "session.json")
const state = {
pending: false,
}
function save() {
if (!sessionStore.ready) {
state.pending = true
return
}
state.pending = false
void writeJsonAtomic(filePath, {
pinned: sessionStore.pinned,
})
}
readJson<unknown>(filePath)
.then((x) => {
if (!x || typeof x !== "object") return
const pinned = (x as Record<string, unknown>).pinned
if (Array.isArray(pinned))
setSessionStore(
"pinned",
pinned.filter((item): item is string => typeof item === "string"),
)
})
.catch(() => {})
.finally(() => {
setSessionStore("ready", true)
if (state.pending) save()
})
const event = useEvent()
const slots = createMemo(() => {
const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id))
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
})
function prune(sessionID: string) {
batch(() => {
if (sessionStore.pinned.includes(sessionID)) {
setSessionStore(
"pinned",
sessionStore.pinned.filter((x) => x !== sessionID),
)
}
save()
})
}
event.on("session.deleted", (evt) => {
prune(evt.properties.info.id)
})
return {
get ready() {
return sessionStore.ready
},
pinned() {
return sessionStore.pinned
},
slots,
isPinned(sessionID: string) {
return sessionStore.pinned.includes(sessionID)
},
togglePin(sessionID: string) {
batch(() => {
const exists = sessionStore.pinned.includes(sessionID)
const next = exists
? sessionStore.pinned.filter((x) => x !== sessionID)
: [...sessionStore.pinned, sessionID]
setSessionStore("pinned", next)
save()
})
},
quickSwitch(slot: number) {
const target = slots()[slot - 1]
if (!target) return
if (route.data.type === "session" && route.data.sessionID === target) return
route.navigate({ type: "session", sessionID: target })
},
}
}
const session = createSession()
const mcp = {
isEnabled(name: string) {
const status = sync.data.mcp[name]
return status?.status === "connected"
},
async toggle(name: string) {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
// Disable: disconnect the MCP
await sdk.client.mcp.disconnect({ name })
} else {
// Enable/Retry: connect the MCP (handles disabled, failed, and other states)
await sdk.client.mcp.connect({ name })
}
},
}
createEffect(() => {
const value = agent.current()
if (!value?.model) return
if (isModelValid(value.model)) return
toast.show({
variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
duration: 3000,
})
})
const result = {
model,
agent,
mcp,
session,
}
return result
},
})

View File

@@ -0,0 +1,40 @@
import path from "path"
import { createContext, useContext, type ParentProps } from "solid-js"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime"
const context = createContext<{
path: () => string
format: (input?: string) => string
}>()
export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) {
const paths = useTuiPaths()
return (
<context.Provider
value={{
path: () => props.path || paths.cwd,
format: (input) => formatPath(input, props.path || paths.cwd, paths.home),
}}
>
{props.children}
</context.Provider>
)
}
export function usePathFormatter() {
const value = useContext(context)
if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider")
return value
}
function formatPath(input: string | undefined, base: string, home: string) {
if (typeof input !== "string" || !input) return ""
const absolute = path.isAbsolute(input) ? input : path.resolve(base, input)
const relative = path.relative(base, absolute)
if (!relative) return "."
if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative
return abbreviateHome(absolute, home)
}

View File

@@ -0,0 +1,116 @@
import { batch } from "solid-js"
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
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,
worktree: undefined as string | undefined,
mainDir: undefined as string | undefined,
},
instance: {
path: defaultPath,
},
workspace: {
current: undefined as string | undefined,
list: [] as Workspace[],
status: {} as Record<string, WorkspaceStatus>,
},
})
async function sync() {
const workspace = store.workspace.current
const [instancePath, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
])
const directories = project.data?.id
? await sdk.client.project.directories({ projectID: project.data.id, workspace })
: undefined
batch(() => {
setStore("instance", "path", reconcile(instancePath.data || defaultPath))
setStore("project", "id", project.data?.id)
setStore("project", "worktree", project.data?.worktree)
setStore("project", "mainDir", directories?.data?.find((item) => item.type === "main")?.directory)
})
}
async function syncWorkspace() {
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!listed?.data) return
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
batch(() => {
setStore("workspace", "list", reconcile(listed.data))
setStore("workspace", "status", reconcile(next))
if (!listed.data.some((item) => item.id === store.workspace.current)) {
setStore("workspace", "current", undefined)
}
})
}
sdk.event.on("event", (event) => {
if (event.payload.type === "workspace.status") {
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
}
})
return {
data: store,
project() {
return store.project.id
},
instance: {
path() {
return store.instance.path
},
directory() {
return store.instance.path.directory
},
},
workspace: {
current() {
return store.workspace.current
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace.current === workspace) return
setStore("workspace", "current", workspace)
},
list() {
return store.workspace.list
},
get(workspaceID: string) {
return store.workspace.list.find((item) => item.id === workspaceID)
},
status(workspaceID: string) {
return store.workspace.status[workspaceID]
},
statuses() {
return store.workspace.status
},
sync: syncWorkspace,
},
sync,
}
},
})

View File

@@ -0,0 +1,18 @@
import { createSimpleContext } from "./helper"
import type { PromptRef } from "../component/prompt"
export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleContext({
name: "PromptRef",
init: () => {
let current: PromptRef | undefined
return {
get current() {
return current
},
set(ref: PromptRef | undefined) {
current = ref
},
}
},
})

View File

@@ -0,0 +1,60 @@
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../prompt/history"
import { useTuiStartup } from "./runtime"
export type HomeRoute = {
type: "home"
prompt?: PromptInfo
}
export type SessionRoute = {
type: "session"
sessionID: string
prompt?: PromptInfo
}
export type PluginRoute = {
type: "plugin"
id: string
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: (props: { initialRoute?: Route }) => {
const startup = useTuiStartup()
const [store, setStore] = createStore<Route>(
props.initialRoute ?? initialRoute(startup.initialRoute) ?? { type: "home" },
)
return {
get data() {
return store
},
navigate(route: Route) {
setStore(reconcile(route))
},
}
},
})
function initialRoute(value: unknown): Route | undefined {
if (!value || typeof value !== "object" || !("type" in value)) return
if (value.type === "home") return { type: "home" }
if (value.type === "session" && "sessionID" in value && typeof value.sessionID === "string") {
return { type: "session", sessionID: value.sessionID }
}
if (value.type === "plugin" && "id" in value && typeof value.id === "string") {
return { type: "plugin", id: value.id }
}
}
export type RouteContext = ReturnType<typeof useRoute>
export function useRouteData<T extends Route["type"]>(type: T) {
const route = useRoute()
return route.data as Extract<Route, { type: typeof type }>
}

View File

@@ -0,0 +1,62 @@
import { createComponent, createContext, type JSX, useContext } from "solid-js"
export type TuiPaths = Readonly<{
cwd: string
home: string
state: string
worktree: string
}>
export type TuiTerminalEnvironment = Readonly<{
platform: string
multiplexer?: "tmux" | "screen"
displayServer?: "wayland" | "x11"
}>
export type TuiStartup = Readonly<{
initialRoute?: unknown
skipInitialLoading: boolean
}>
const PathsContext = createContext<TuiPaths>()
const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
const StartupContext = createContext<TuiStartup>()
function provider<T>(context: ReturnType<typeof createContext<T>>, value: T, children: () => JSX.Element) {
return createComponent(context.Provider, {
value: Object.freeze({ ...value }),
get children() {
return children()
},
})
}
export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element }) {
return provider(PathsContext, props.value, () => props.children)
}
export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) {
return provider(TerminalEnvironmentContext, props.value, () => props.children)
}
export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Element }) {
return provider(StartupContext, props.value, () => props.children)
}
function required<T>(context: ReturnType<typeof createContext<T>>, name: string) {
const value = useContext(context)
if (!value) throw new Error(`${name} is missing`)
return value
}
export function useTuiPaths() {
return required(PathsContext, "TuiPathsProvider")
}
export function useTuiTerminalEnvironment() {
return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider")
}
export function useTuiStartup() {
return required(StartupContext, "TuiStartupProvider")
}

View File

@@ -0,0 +1,151 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createSimpleContext } from "./helper"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: {
url: string
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}) => {
const abort = new AbortController()
let sse: AbortController | undefined
function createSDK() {
return createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
})
}
let sdk = createSDK()
const handlers = new Set<(event: GlobalEvent) => void>()
const emitter = {
emit(_type: "event", event: GlobalEvent) {
for (const handler of handlers) handler(event)
},
on(_type: "event", handler: (event: GlobalEvent) => void) {
handlers.add(handler)
return () => {
handlers.delete(handler)
}
},
}
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
const retryDelay = 1000
const maxRetryDelay = 30000
const flush = () => {
if (queue.length === 0) return
const events = queue
queue = []
timer = undefined
last = Date.now()
// Batch all event emissions so all store updates result in a single render
batch(() => {
for (const event of events) {
emitter.emit("event", event)
}
})
}
const handleEvent = (event: GlobalEvent) => {
queue.push(event)
const elapsed = Date.now() - last
if (timer) return
// If we just flushed recently (within 16ms), batch this with future events
// Otherwise, process immediately to avoid latency
if (elapsed < 16) {
timer = setTimeout(flush, 16)
return
}
flush()
}
function startSSE() {
sse?.abort()
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,
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
handleEvent(event)
}
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(() => {})
}
onMount(async () => {
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()
}
})
onCleanup(() => {
abort.abort()
sse?.abort()
if (timer) clearTimeout(timer)
handlers.clear()
})
return {
get client() {
return sdk
},
directory: props.directory,
event: emitter,
fetch: props.fetch ?? fetch,
url: props.url,
}
},
})

View File

@@ -0,0 +1,640 @@
import type {
Message,
Agent,
Provider,
Session,
Part,
Config,
Todo,
Command,
PermissionRequest,
QuestionRequest,
LspStatus,
McpStatus,
McpResource,
FormatterStatus,
SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
SnapshotFileDiff,
ConsoleState,
} from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "./project"
import { useEvent } from "./event"
import { useSDK } from "./sdk"
import { useTuiStartup } from "./runtime"
import { createSimpleContext } from "./helper"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import path from "path"
import { useKV } from "./kv"
const emptyConsoleState: ConsoleState = {
consoleManagedProviders: [],
switchableOrgCount: 0,
}
function search<T>(items: T[], target: string, key: (item: T) => string) {
let left = 0
let right = items.length - 1
while (left <= right) {
const middle = Math.floor((left + right) / 2)
const value = key(items[middle])
if (value === target) return { found: true, index: middle }
if (value < target) left = middle + 1
else right = middle - 1
}
return { found: false, index: left }
}
export const {
context: SyncContext,
use: useSync,
provider: SyncProvider,
} = createSimpleContext({
name: "Sync",
init: () => {
const startup = useTuiStartup()
const kv = useKV()
const [store, setStore] = createStore<{
status: "loading" | "partial" | "complete"
provider: Provider[]
provider_default: Record<string, string>
provider_next: ProviderListResponse
console_state: ConsoleState
provider_auth: Record<string, ProviderAuthMethod[]>
agent: Agent[]
command: Command[]
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
config: Config
session: Session[]
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
lsp: LspStatus[]
mcp: {
[key: string]: McpStatus
}
mcp_resource: {
[key: string]: McpResource
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
}>({
provider_next: {
all: [],
default: {},
connected: [],
},
console_state: emptyConsoleState,
provider_auth: {},
config: {},
status: "loading",
agent: [],
permission: {},
question: {},
command: [],
provider: [],
provider_default: {},
session: [],
session_status: {},
session_diff: {},
todo: {},
message: {},
part: {},
lsp: [],
mcp: {},
mcp_resource: {},
formatter: [],
vcs: undefined,
})
const event = useEvent()
const project = useProject()
const sdk = useSDK()
const fullSyncedSessions = new Set<string>()
const syncingSessions = new Map<string, Promise<void>>()
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
const touchMessage = (sessionID: string, messageID: string) => {
hydratingSessions.get(sessionID)?.messages.add(messageID)
}
const touchPart = (sessionID: string, partID: string) => {
hydratingSessions.get(sessionID)?.parts.add(partID)
}
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
return {
path: path
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
.replaceAll("\\", "/"),
}
}
function listSessions() {
return sdk.client.session
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
}
event.subscribe((event, { workspace }) => {
switch (event.type) {
case "server.instance.disposed":
void bootstrap()
break
case "permission.replied": {
const requests = store.permission[event.properties.sessionID]
if (!requests) break
const match = search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "permission.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
break
}
const match = search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const requests = store.question[event.properties.sessionID]
if (!requests) break
const match = search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}
case "question.asked": {
const request = event.properties
const requests = store.question[request.sessionID]
if (!requests) {
setStore("question", request.sessionID, [request])
break
}
const match = search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("question", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"question",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "session.deleted": {
const result = search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "session.updated": {
const result = search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.next.moved": {
const result = search(store.session, event.properties.sessionID, (s) => s.id)
if (!result.found) break
setStore(
"session",
result.index,
produce((session) => {
session.directory = event.properties.location.directory
session.path = event.properties.subdirectory
session.workspaceID = event.properties.location.workspaceID
session.time.updated = event.properties.timestamp
}),
)
break
}
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
touchMessage(event.properties.info.sessionID, event.properties.info.id)
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
const updated = store.message[event.properties.info.sessionID]
if (updated.length > 100) {
const oldest = updated[0]
batch(() => {
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.shift()
}),
)
setStore(
"part",
produce((draft) => {
delete draft[oldest.id]
}),
)
})
}
break
}
case "message.removed": {
touchMessage(event.properties.sessionID, event.properties.messageID)
const messages = store.message[event.properties.sessionID]
const result = search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
setStore(
"message",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "message.part.updated": {
touchPart(event.properties.part.sessionID, event.properties.part.id)
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
break
}
const result = search(parts, event.properties.part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
break
}
setStore(
"part",
event.properties.part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
}),
)
break
}
case "message.part.delta": {
const parts = store.part[event.properties.messageID]
if (!parts) break
const result = search(parts, event.properties.partID, (p) => p.id)
if (!result.found) break
touchPart(event.properties.sessionID, event.properties.partID)
setStore(
"part",
event.properties.messageID,
produce((draft) => {
const part = draft[result.index]
const field = event.properties.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + event.properties.delta
}),
)
break
}
case "message.part.removed": {
touchPart(event.properties.sessionID, event.properties.partID)
const parts = store.part[event.properties.messageID]
const result = search(parts, event.properties.partID, (p) => p.id)
if (result.found) {
setStore(
"part",
event.properties.messageID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "lsp.updated": {
const workspace = project.workspace.current()
void sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data ?? []))
break
}
case "vcs.branch.updated": {
if (workspace === project.workspace.current()) {
setStore("vcs", { branch: event.properties.branch })
}
break
}
}
})
const exit = useExit()
const args = useArgs()
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
const projectPromise = project.sync()
const sessionListPromise = projectPromise.then(() => listSessions())
// blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true })
.then((x) => x.data)
.catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
await Promise.all([
providersPromise,
providerListPromise,
agentsPromise,
configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []),
])
.then(async () => {
const providersResponse = providersPromise.then((x) => x.data!)
const providerListResponse = providerListPromise.then((x) => x.data!)
const consoleStateResponse = consoleStatePromise
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
const configResponse = configPromise.then((x) => x.data!)
const sessionListResponse = args.continue ? sessionListPromise : undefined
return Promise.all([
providersResponse,
providerListResponse,
consoleStateResponse,
agentsResponse,
configResponse,
...(sessionListResponse ? [sessionListResponse] : []),
]).then((responses) => {
const providers = responses[0]
const providerList = responses[1]
const consoleState = responses[2]
const agents = responses[3]
const config = responses[4]
const sessions = responses[5]
batch(() => {
setStore("provider", reconcile(providers.providers))
setStore("provider_default", reconcile(providers.default))
setStore("provider_next", reconcile(providerList))
setStore("console_state", reconcile(consoleState))
setStore("agent", reconcile(agents))
setStore("config", reconcile(config))
if (sessions !== undefined) setStore("session", reconcile(sessions))
})
})
})
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
void Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
sdk.client.experimental.resource
.list({ workspace })
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data ?? {}))
}),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(),
]).then(() => {
setStore("status", "complete")
})
})
.catch(async (e) => {
console.error("tui bootstrap failed", {
error: e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
if (fatal) {
exit(e)
} else {
throw e
}
})
}
onMount(() => {
void bootstrap()
})
const result = {
data: store,
set: setStore,
get status() {
return store.status
},
get ready() {
if (startup.skipInitialLoading) return true
return store.status !== "loading"
},
get path() {
return project.instance.path()
},
session: {
get(sessionID: string) {
const match = search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
query() {
return sessionListQuery()
},
async refresh() {
const list = await listSessions()
setStore("session", reconcile(list))
},
status(sessionID: string) {
const session = result.session.get(sessionID)
if (!session) return "idle"
if (session.time.compacting) return "compacting"
const messages = store.message[sessionID] ?? []
const last = messages.at(-1)
if (!last) return "idle"
if (last.role === "user") return "working"
return last.time.completed ? "idle" : "working"
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const syncing = syncingSessions.get(sessionID)
if (syncing) return syncing
const tracker = { messages: new Set<string>(), parts: new Set<string>() }
hydratingSessions.set(sessionID, tracker)
const task = (async () => {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
const match = search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
const currentMessages = draft.message[sessionID] ?? []
const infos = (messages.data ?? []).flatMap((message) => {
if (!tracker.messages.has(message.info.id)) return [message.info]
const current = currentMessages.find((item) => item.id === message.info.id)
return current ? [current] : []
})
infos.push(
...currentMessages.filter(
(message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
),
)
const removed = infos.slice(0, -100)
const visible = infos.slice(-100)
const visibleIDs = new Set(visible.map((message) => message.id))
for (const message of messages.data ?? []) {
if (!visibleIDs.has(message.info.id)) {
delete draft.part[message.info.id]
continue
}
const currentParts = draft.part[message.info.id] ?? []
const parts = message.parts.flatMap((part) => {
const current = currentParts.find((item) => item.id === part.id)
if (tracker.parts.has(part.id)) return current ? [current] : []
if (
current &&
(part.type === "text" || part.type === "reasoning") &&
(current.type === "text" || current.type === "reasoning") &&
part.text.length === 0 &&
current.text.length > 0
) {
return [current]
}
return [part]
})
parts.push(
...currentParts.filter(
(part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
),
)
draft.part[message.info.id] = parts
}
for (const message of removed) delete draft.part[message.id]
draft.message[sessionID] = visible
draft.session_diff[sessionID] = diff.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
})().finally(() => {
syncingSessions.delete(sessionID)
hydratingSessions.delete(sessionID)
})
syncingSessions.set(sessionID, task)
return task
},
},
bootstrap,
}
return result
},
})

View File

@@ -0,0 +1,332 @@
import { CliRenderEvents, SyntaxStyle, type TerminalColors } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSubtleSyntax,
generateSyntax,
generateSystem,
hasTheme,
isTheme,
resolveTheme,
selectedForeground,
setCustomThemes,
setSystemTheme,
subscribeThemes,
terminalMode,
tint,
upsertTheme,
type ThemeJson,
} from "../theme"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useKV } from "./kv"
import { useTuiConfig } from "../config"
import { Global } from "@opencode-ai/core/global"
import { Glob } from "@opencode-ai/core/util/glob"
import { readFile } from "node:fs/promises"
import path from "node:path"
export type ThemeSource = Readonly<{
discover(): Promise<Record<string, unknown>>
subscribeRefresh?(refresh: () => void): () => void
}>
const themeSource: ThemeSource = {
async discover() {
const directories = [Global.Path.config]
for (let current = process.cwd(); ; current = path.dirname(current)) {
directories.push(path.join(current, ".opencode"))
if (path.dirname(current) === current) break
}
return discoverThemes(directories)
},
subscribeRefresh(refresh) {
process.on("SIGUSR2", refresh)
return () => process.off("SIGUSR2", refresh)
},
}
export async function discoverThemes(directories: string[]) {
const result: Record<string, unknown> = {}
for (const directory of directories) {
const files = await Glob.scan("themes/*.json", { cwd: directory, absolute: true, dot: true, symlink: true })
for (const file of files) {
result[path.basename(file, ".json")] = JSON.parse(await readFile(file, "utf8")) as unknown
}
}
return result
}
export {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSubtleSyntax,
generateSyntax,
generateSystem,
hasTheme,
isTheme,
resolveTheme,
selectedForeground,
terminalMode,
tint,
upsertTheme,
type Theme,
type ThemeJson,
type SyntaxStyleOverrides,
} from "../theme"
const THEME_REFRESH_DELAYS = [250, 1000] as const
type State = {
themes: Record<string, ThemeJson>
mode: "dark" | "light"
lock: "dark" | "light" | undefined
active: string
ready: boolean
}
const [store, setStore] = createStore<State>({
themes: allThemes(),
mode: "dark",
lock: undefined,
active: "opencode",
ready: false,
})
subscribeThemes((themes) => setStore("themes", themes))
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light"; source?: ThemeSource }) => {
const renderer = useRenderer()
const config = useTuiConfig()
const kv = useKV()
const themes = props.source ?? themeSource
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
}
setStore(
produce((draft) => {
const lock = pick(kv.get("theme_mode_lock"))
const mode = lock ?? pick(renderer.themeMode) ?? 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"
draft.ready = false
}),
)
createEffect(() => {
const theme = config.theme
if (theme) setStore("active", theme)
})
function syncCustomThemes() {
return themes
.discover()
.then((themes) => {
setCustomThemes(
Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => {
if (isTheme(theme)) result[name] = theme
return result
}, {}),
)
})
.catch(() => setStore("active", "opencode"))
}
onMount(() => {
void Promise.allSettled([resolveSystemTheme(store.mode), syncCustomThemes()]).finally(() => {
setStore("ready", true)
})
})
let systemThemeSignature: string | undefined
let systemThemeMode: "dark" | "light" | undefined
let hasResolvedSystemTheme = false
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
return renderer
.getPalette({ size: 16 })
.then((colors: TerminalColors) => {
if (!colors.palette[0]) {
if (hasResolvedSystemTheme) return
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
return
}
const next = store.lock ?? terminalMode(colors) ?? mode
if (store.mode !== next) setStore("mode", next)
const signature = JSON.stringify(colors)
hasResolvedSystemTheme = true
if (store.themes.system && systemThemeSignature === signature && systemThemeMode === next) return
systemThemeSignature = signature
systemThemeMode = next
setSystemTheme(generateSystem(colors, next))
})
.catch(() => {
if (hasResolvedSystemTheme) return
setSystemTheme(undefined)
if (store.active === "system") setStore("active", "opencode")
})
}
let systemRefreshRunning = false
let systemRefreshQueued = false
let systemRefreshMode = store.mode
function refreshSystemTheme(mode: "dark" | "light" = store.mode) {
systemRefreshMode = mode
if (systemRefreshRunning) {
systemRefreshQueued = true
return
}
systemRefreshRunning = true
const retry = renderer.paletteDetectionStatus === "detecting"
renderer.clearPaletteCache()
void resolveSystemTheme(mode).finally(() => {
systemRefreshRunning = false
if (!retry && !systemRefreshQueued) return
systemRefreshQueued = false
refreshSystemTheme(systemRefreshMode)
})
}
function apply(mode: "dark" | "light") {
if (store.lock !== undefined) kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
refreshSystemTheme(mode)
}
function pin(mode: "dark" | "light" = store.mode) {
setStore("lock", mode)
kv.set("theme_mode_lock", mode)
apply(mode)
}
function free() {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
kv.set("theme_mode", undefined)
refreshSystemTheme(renderer.themeMode ?? store.mode)
}
const handle = (mode: "dark" | "light") => {
if (store.lock) return
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
const handleThemeNotification = (sequence: string) => {
if (sequence !== "\x1b[?997;1n" && sequence !== "\x1b[?997;2n") return false
queueMicrotask(() => refreshSystemTheme())
return false
}
renderer.prependInputHandler(handleThemeNotification)
let themeRefreshTimeouts: ReturnType<typeof setTimeout>[] = []
const refresh = () => {
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
themeRefreshTimeouts = THEME_REFRESH_DELAYS.map((delay) =>
setTimeout(() => {
refreshSystemTheme()
if (delay === THEME_REFRESH_DELAYS[THEME_REFRESH_DELAYS.length - 1]) void syncCustomThemes()
}, delay),
)
}
let unsubscribeRefresh: (() => void) | undefined
unsubscribeRefresh = themes.subscribeRefresh?.(refresh)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
renderer.removeInputHandler(handleThemeNotification)
unsubscribeRefresh?.()
for (const timeout of themeRefreshTimeouts) clearTimeout(timeout)
themeRefreshTimeouts.length = 0
})
const values = createMemo(() => {
const active = store.themes[store.active]
if (active) return resolveTheme(active, store.mode)
const saved = kv.get("theme")
if (typeof saved === "string") {
const theme = store.themes[saved]
if (theme) return resolveTheme(theme, store.mode)
}
return resolveTheme(store.themes.opencode, store.mode)
})
createEffect(() => renderer.setBackgroundColor(values().background))
const syntax = createSyntaxStyleMemo(() => generateSyntax(values()))
const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values()))
return {
theme: new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error Properties are forwarded to the current reactive value.
return values()[prop]
},
}),
get selected() {
return store.active
},
all: allThemes,
has: hasTheme,
syntax,
subtleSyntax,
mode: () => store.mode,
locked: () => store.lock !== undefined,
lock: () => pin(store.mode),
unlock: free,
setMode: pin,
set(theme: string) {
if (!hasTheme(theme)) return false
setStore("active", theme)
kv.set("theme", theme)
return true
},
get ready() {
return store.ready
},
}
},
})
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
const renderer = useRenderer()
const retained = new Set<SyntaxStyle>()
let current: SyntaxStyle | undefined
const release = (style: SyntaxStyle) => {
retained.add(style)
void renderer
.idle()
.catch(() => {})
.finally(() => {
if (!retained.delete(style)) return
style.destroy()
})
}
onCleanup(() => {
if (current) release(current)
})
return createMemo(() => {
const previous = current
current = factory()
if (previous) release(previous)
return current
})
}

View File

@@ -0,0 +1,67 @@
import { createMemo, type Setter } from "solid-js"
import { useKV } from "./kv"
export type ThinkingMode = "show" | "hide"
const MODES: readonly ThinkingMode[] = ["show", "hide"] as const
// OpenAI's Responses API surfaces reasoning summaries that start with a bolded
// title block: "**Inspecting PR workflow**\n\n<body>". Treat that first block,
// or a complete title still awaiting its body while streaming, as disclosure
// metadata so the TUI can style its header independently from the markdown body.
export function reasoningSummary(text: string) {
const content = text.trim()
const match = content.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
if (!match) return { title: null, body: content }
return { title: match[1].trim(), body: content.slice(match[0].length).trimEnd() }
}
export function isThinkingMode(value: unknown): value is ThinkingMode {
return typeof value === "string" && (MODES as readonly string[]).includes(value)
}
// Cycle order matches the slash command: show → hide → show.
export function nextThinkingMode(current: ThinkingMode): ThinkingMode {
const idx = MODES.indexOf(current)
return MODES[(idx + 1) % MODES.length] ?? "show"
}
export function useThinkingMode() {
const kv = useKV()
// Capture pre-state before `kv.signal` seeds a default, so we can detect
// first-time users with a legacy `thinking_visibility` boolean and migrate.
// The KVProvider only renders children once kv.ready, so reads here are safe.
const hadStored = kv.get("thinking_mode") !== undefined
const legacy = kv.get("thinking_visibility")
const [stored, setStored] = kv.signal<ThinkingMode>("thinking_mode", "hide")
// The kv signal exposes its setter typed as `Setter<T>` which carries Solid's
// overload set; passing an updater fn through a property access loses the
// bivariance trick the existing `setX((prev) => ...)` callsites rely on.
// Wrap it in a sane shape so consumers can just call `set(next)` or pass
// an updater.
const set = (next: ThinkingMode | ((prev: ThinkingMode) => ThinkingMode)) => {
if (typeof next === "function") setStored(next as Setter<ThinkingMode>)
else setStored(() => next)
}
// Preserve previous experience for users who had explicitly toggled the
// legacy `thinking_visibility` boolean. First-time users (no legacy key)
// get the new "hide" default (collapsed thinking).
if (!hadStored) {
if (legacy === true) set("show")
else if (legacy === false) set("hide")
}
if ((stored() as string) === "minimal") set("hide")
const mode = createMemo<ThinkingMode>(() => {
const value = stored()
return isThinkingMode(value) ? value : "hide"
})
return {
mode,
set,
}
}

View File

@@ -0,0 +1,286 @@
import { Database } from "bun:sqlite"
import { statSync } from "node:fs"
import { readFile as readFileAsync } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Option, Schema } from "effect"
import type { EditorSelection } from "./context/editor"
const ZedEditorRowSchema = Schema.Struct({
item_kind: Schema.String,
editor_id: Schema.NullOr(Schema.Number),
workspace_id: Schema.Number,
workspace_paths: Schema.NullOr(Schema.String),
timestamp: Schema.String,
buffer_path: Schema.NullOr(Schema.String),
})
const ZedSelectionRowSchema = Schema.Struct({
selection_start: Schema.NullOr(Schema.Number),
selection_end: Schema.NullOr(Schema.Number),
})
const ZedEditorContentsSchema = Schema.Struct({
contents: Schema.NullOr(Schema.String),
})
const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
const utf8 = new TextEncoder()
type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
export type ZedSelectionResult =
| { type: "selection"; selection: EditorSelection }
| { type: "empty" }
| { type: "unavailable" }
export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
const active = queryZedActiveEditor(dbPath, cwd)
if (active.type !== "row") return active
const row = active.row
if (!row.buffer_path) return { type: "empty" }
const selections = queryZedEditorSelections(dbPath, row)
if (selections.type !== "selections") return selections
const byteRanges = selections.selections
.flatMap((selection) => {
if (selection.selection_start == null || selection.selection_end == null) return []
return [
{
start: Math.min(selection.selection_start, selection.selection_end),
end: Math.max(selection.selection_start, selection.selection_end),
},
]
})
.sort((left, right) => left.start - right.start || left.end - right.end)
if (byteRanges.length === 0) return { type: "unavailable" }
const contents = queryZedEditorContents(dbPath, row)
const text =
contents.type === "contents" && contents.contents != null
? contents.contents
: await readFileAsync(row.buffer_path, "utf8").catch(() => undefined)
if (text == null) return { type: "unavailable" }
const ranges = byteRanges.map((range) => {
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
return {
text: text.slice(startOffset, endOffset),
selection: offsetsToSelection(text, startOffset, endOffset),
}
})
return {
type: "selection",
selection: {
filePath: row.buffer_path,
source: "zed",
ranges,
},
}
}
function queryZedActiveEditor(dbPath: string, cwd: string) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
i.kind as item_kind,
e.item_id as editor_id,
i.workspace_id as workspace_id,
w.paths as workspace_paths,
w.timestamp as timestamp,
e.buffer_path as buffer_path
from items i
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
join workspaces w on w.workspace_id = i.workspace_id
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
where i.active = 1 and p.active = 1
order by w.timestamp desc`,
)
.all()
const rows = raw.flatMap((row) => {
const parsed = decodeZedEditorRow(row)
return Option.isSome(parsed) ? [parsed.value] : []
})
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
const row = rows
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
if (!row) return { type: "empty" as const }
if (row.item_kind !== "Editor") return { type: "unavailable" as const }
if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
return { type: "row" as const, row }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const raw = db
.query(
`select
start as selection_start,
end as selection_end
from editor_selections
where editor_id = $editorID and workspace_id = $workspaceID`,
)
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
const selections = raw.flatMap((selection) => {
const parsed = decodeZedSelectionRow(selection)
return Option.isSome(parsed) ? [parsed.value] : []
})
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
return { type: "selections" as const, selections }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
let db: Database | undefined
try {
db = new Database(dbPath, { readonly: true })
const parsed = decodeZedEditorContents(
db
.query(
`select contents
from editors
where item_id = $editorID and workspace_id = $workspaceID`,
)
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
)
if (Option.isNone(parsed)) return { type: "unavailable" as const }
return { type: "contents" as const, contents: parsed.value.contents }
} catch {
return { type: "unavailable" as const }
} finally {
db?.close()
}
}
function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
return row.item_kind === "Editor" && row.editor_id != null
}
export function resolveZedDbPath() {
const candidates = [
process.env.OPENCODE_ZED_DB,
path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
].filter((item): item is string => Boolean(item))
return candidates.find((item) => isFile(item))
}
export function isZedTerminal() {
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
}
function isFile(item: string) {
try {
return statSync(item).isFile()
} catch {
return false
}
}
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
return score
}, 0)
}
function zedWorkspacePaths(value: string | null) {
if (!value) return []
const parsed = parseJson(value)
if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
return value.split(/\r?\n/).filter(Boolean)
}
export function offsetToPosition(text: string, offset: number) {
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0
let bytes = 0
for (let index = 0; index < text.length; ) {
const codePoint = text.codePointAt(index)
if (codePoint === undefined) return text.length
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
bytes += utf8.encode(text.slice(index, nextIndex)).length
if (bytes >= byteOffset) return nextIndex
index = nextIndex
}
return text.length
}
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
const start = Math.max(0, Math.min(startOffset, text.length))
const end = Math.max(0, Math.min(endOffset, text.length))
let line = 1
let lineStart = 0
let startPosition = position(line, lineStart, start)
let endPosition = position(line, lineStart, end)
for (let index = 0; index <= end; index++) {
if (index === start) startPosition = position(line, lineStart, index)
if (index === end) {
endPosition = position(line, lineStart, index)
break
}
if (text[index] === "\n") {
line += 1
lineStart = index + 1
}
}
return { start: startPosition, end: endPosition }
}
function position(line: number, lineStart: number, offset: number) {
return {
line,
character: offset - lineStart + 1,
}
}
function pathContains(parent: string, child: string) {
const relative = path.relative(path.resolve(parent), path.resolve(child))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}
function parseJson(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return
}
}

101
packages/tui/src/editor.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { CliRenderer } from "@opentui/core"
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
import { readFile, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { spawn } from "node:child_process"
import type { Stream } from "node:stream"
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
export function normalizePromptContent(content: string) {
if (content.endsWith("\r\n")) {
const body = content.slice(0, -2)
return !body.includes("\n") && !body.includes("\r") ? body : content
}
if (content.endsWith("\n")) {
const body = content.slice(0, -1)
return !body.includes("\n") && !body.includes("\r") ? body : content
}
return content
}
export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
const editor = process.env.VISUAL || process.env.EDITOR
if (!editor) return
const file = path.join(os.tmpdir(), `${Date.now()}.md`)
await writeFile(file, input.value)
input.renderer.suspend()
input.renderer.currentRenderBuffer.clear()
try {
await new Promise<void>((resolve, reject) => {
const parts = editor.split(" ")
const child = spawn(parts[0]!, [...parts.slice(1), file], {
cwd: input.cwd && existsSync(input.cwd) ? input.cwd : process.cwd(),
stdio: [input.stdin ?? "inherit", "inherit", "inherit"],
shell: process.platform === "win32",
})
child.on("error", reject)
child.on("exit", (code, signal) => {
if (code === 0) return resolve()
reject(new Error(`Editor exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
})
})
return (await readFile(file, "utf8")) || undefined
} finally {
await rm(file, { force: true }).catch(() => {})
input.renderer.currentRenderBuffer.clear()
input.renderer.resume()
input.renderer.requestRender()
}
}
export function discoverEditorConnection(directory: string) {
const root = path.join(os.homedir(), ".claude", "ide")
const contains = (parent: string) => {
const resolved = path.resolve(parent)
const relative = path.relative(resolved, path.resolve(directory))
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
}
try {
return readdirSync(root)
.filter((entry) => entry.endsWith(".lock"))
.flatMap((entry) => {
const file = path.join(root, entry)
const port = Number.parseInt(path.basename(file, ".lock"), 10)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return []
try {
const value = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>
if (value.transport !== undefined && value.transport !== "ws") return []
const folders = Array.isArray(value.workspaceFolders)
? value.workspaceFolders.filter((item): item is string => typeof item === "string")
: []
const score = Math.max(0, ...folders.map(contains))
if (!score) return []
return [
{
url: `ws://127.0.0.1:${port}`,
authToken: typeof value.authToken === "string" ? value.authToken : undefined,
source: `lock:${port}`,
score,
mtime: statSync(file).mtimeMs,
},
]
} catch {
return []
}
})
.sort((left, right) => right.score - left.score || right.mtime - left.mtime)
.map(({ url, authToken, source }) => ({ url, authToken, source }))[0]
} catch {
return undefined
}
}
export const editorIntegration = {
connection: discoverEditorConnection,
selection: (directory: string) => resolveZedSelection(resolveZedDbPath() ?? "", directory),
}

View File

@@ -0,0 +1,36 @@
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import HomeFooter from "./home/footer"
import HomeTips from "./home/tips"
import SidebarContext from "./sidebar/context"
import SidebarFiles from "./sidebar/files"
import SidebarFooter from "./sidebar/footer"
import SidebarLsp from "./sidebar/lsp"
import SidebarMcp from "./sidebar/mcp"
import SidebarTodo from "./sidebar/todo"
import DiffViewer from "./system/diff-viewer"
import Notifications from "./system/notifications"
import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key"
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
id: string
tui: TuiPlugin
enabled?: boolean
}
export function createBuiltinPlugins(options: { experimentalEventSystem: boolean }): BuiltinTuiPlugin[] {
return [
HomeFooter,
HomeTips,
SidebarContext,
SidebarMcp,
SidebarLsp,
SidebarTodo,
SidebarFiles,
SidebarFooter,
Notifications,
PluginManager,
WhichKey,
DiffViewer,
]
}

View File

@@ -0,0 +1,100 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Match, Show, Switch } from "solid-js"
import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
import { useHomeSessionDestination } from "../../routes/home/session-destination"
const id = "internal:home-footer"
function Directory(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const destination = useHomeSessionDestination()
const paths = useTuiPaths()
const dir = createMemo(() => {
const selected = destination?.destination()
if (!selected || selected.type === "new") return
const out = abbreviateHome(selected.directory, paths.home)
const branch =
selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
if (branch) return out + ":" + branch
return out
})
return <Show when={dir()}>{(value) => <text fg={theme().textMuted}>{value()}</text>}</Show>
}
function Mcp(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.mcp())
const has = createMemo(() => list().length > 0)
const err = createMemo(() => list().some((item) => item.status === "failed"))
const count = createMemo(() => list().filter((item) => item.status === "connected").length)
return (
<Show when={has()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={theme().text}>
<Switch>
<Match when={err()}>
<span style={{ fg: theme().error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}> </span>
</Match>
</Switch>
{count()} MCP
</text>
<text fg={theme().textMuted}>/status</text>
</box>
</Show>
)
}
function Version(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
return (
<box flexShrink={0}>
<text fg={theme().textMuted}>{props.api.app.version}</text>
</box>
)
}
function View(props: { api: TuiPluginApi }) {
return (
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<Directory api={props.api} />
<Mcp api={props.api} />
<box flexGrow={1} />
<Version api={props.api} />
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
home_footer() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,287 @@
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createMemo, For, type Accessor } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "../../context/theme"
import { useCommandShortcut } from "../../keymap"
const themeCount = Object.keys(DEFAULT_THEMES).length
type TipPart = { text: string; highlight: boolean }
type TipShortcut = Accessor<string>
type Shortcuts = {
agentCycle: TipShortcut
childFirst: TipShortcut
childNext: TipShortcut
childPrevious: TipShortcut
commandList: TipShortcut
editorOpen: TipShortcut
helpShow: TipShortcut
inputClear: TipShortcut
inputNewline: TipShortcut
inputPaste: TipShortcut
inputUndo: TipShortcut
leader: TipShortcut
messagesCopy: TipShortcut
messagesFirst: TipShortcut
messagesLast: TipShortcut
messagesPageDown: TipShortcut
messagesPageUp: TipShortcut
messagesToggleConceal: TipShortcut
modelCycleRecent: TipShortcut
modelList: TipShortcut
sessionExport: TipShortcut
sessionInterrupt: TipShortcut
sessionList: TipShortcut
sessionNew: TipShortcut
sessionParent: TipShortcut
sessionPinToggle: TipShortcut
sessionQuickSwitch1: TipShortcut
sessionQuickSwitch9: TipShortcut
sessionSidebarToggle: TipShortcut
sessionTimeline: TipShortcut
statusView: TipShortcut
terminalSuspend: TipShortcut
themeList: TipShortcut
}
type Tip = string | ((shortcuts: Shortcuts) => string | undefined)
function parse(tip: string): TipPart[] {
const parts: TipPart[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
const found = Array.from(tip.matchAll(regex))
const state = found.reduce(
(acc, match) => {
const start = match.index ?? 0
if (start > acc.index) {
acc.parts.push({ text: tip.slice(acc.index, start), highlight: false })
}
acc.parts.push({ text: match[1], highlight: true })
acc.index = start + match[0].length
return acc
},
{ parts, index: 0 },
)
if (state.index < tip.length) {
parts.push({ text: tip.slice(state.index), highlight: false })
}
return parts
}
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
const NO_MODELS_PARTS = parse(NO_MODELS_TIP)
function shortcutText(value: string) {
return `{highlight}${value}{/highlight}`
}
function commandText(command: string, shortcut: string) {
if (!shortcut) return shortcutText(command)
return `${shortcutText(command)} or ${shortcutText(shortcut)}`
}
function press(shortcut: string, text: string) {
if (!shortcut) return undefined
return `Press ${shortcutText(shortcut)} ${text}`
}
function configShortcut(api: TuiPluginApi, command: string): TipShortcut {
return () =>
api.tuiConfig.keybinds
.get(command)
.map((binding) => api.keys.formatSequence(Array.from(api.keymap.parseKeySequence(binding.key))))
.filter(Boolean)
.join(", ")
}
export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
const theme = useTheme().theme
const tipOffset = Math.random()
const shortcuts: Shortcuts = {
agentCycle: useCommandShortcut("agent.cycle"),
childFirst: configShortcut(props.api, "session.child.first"),
childNext: configShortcut(props.api, "session.child.next"),
childPrevious: configShortcut(props.api, "session.child.previous"),
commandList: useCommandShortcut("command.palette.show"),
editorOpen: useCommandShortcut("prompt.editor"),
helpShow: useCommandShortcut("help.show"),
inputClear: useCommandShortcut("prompt.clear"),
inputNewline: useCommandShortcut("input.newline"),
inputPaste: useCommandShortcut("prompt.paste"),
inputUndo: useCommandShortcut("input.undo"),
leader: configShortcut(props.api, "leader"),
messagesCopy: configShortcut(props.api, "messages.copy"),
messagesFirst: configShortcut(props.api, "session.first"),
messagesLast: configShortcut(props.api, "session.last"),
messagesPageDown: configShortcut(props.api, "session.page.down"),
messagesPageUp: configShortcut(props.api, "session.page.up"),
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
modelList: useCommandShortcut("model.list"),
sessionExport: configShortcut(props.api, "session.export"),
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
sessionList: useCommandShortcut("session.list"),
sessionNew: useCommandShortcut("session.new"),
sessionParent: configShortcut(props.api, "session.parent"),
sessionPinToggle: configShortcut(props.api, "session.pin.toggle"),
sessionQuickSwitch1: useCommandShortcut("session.quick_switch.1"),
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
sessionTimeline: configShortcut(props.api, "session.timeline"),
statusView: useCommandShortcut("opencode.status"),
terminalSuspend: useCommandShortcut("terminal.suspend"),
themeList: useCommandShortcut("theme.switch"),
}
const tip = createMemo(() => {
if (props.connected === false) return NO_MODELS_TIP
const tips = [...TIPS, process.platform !== "win32" ? TERMINAL_SUSPEND_TIP : INPUT_UNDO_TIP].flatMap((item) => {
const value = typeof item === "string" ? item : item(shortcuts)
return value ? [value] : []
})
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
}, NO_MODELS_TIP)
// Solid can expose a memo's initial value while a pure computation is pending.
const parts = createMemo(() => {
const value = tip()
if (typeof value === "string") return parse(value)
return NO_MODELS_PARTS
}, NO_MODELS_PARTS)
return (
<box flexDirection="row" maxWidth="100%">
<text flexShrink={0} style={{ fg: theme.warning }}>
Tip{" "}
</text>
<text flexShrink={1} wrapMode="word">
<For each={parts()}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
)
}
const TIPS: Tip[] = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})",
(shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"),
"Use {highlight}/undo{/highlight} to revert the last message and file changes",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes",
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai",
"Drag and drop images or PDFs into the terminal to add them as context",
(shortcuts) => press(shortcuts.inputPaste(), "to paste images from your clipboard into the prompt"),
(shortcuts) => `Use ${commandText("/editor", shortcuts.editorOpen())} to compose messages in your external editor`,
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase",
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`,
(shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`,
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list, pin, and continue sessions`,
(shortcuts) => press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
(shortcuts) =>
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
? `Pinned sessions are assigned quick slots; use ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} to switch`
: undefined,
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
(shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
(shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"),
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers",
(shortcuts) => `The leader key is ${shortcutText(shortcuts.leader())}; combine with other keys for quick actions`,
(shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"),
(shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"),
(shortcuts) =>
shortcuts.messagesPageUp() && shortcuts.messagesPageDown()
? `Use ${shortcutText(shortcuts.messagesPageUp())}/${shortcutText(shortcuts.messagesPageDown())} to navigate through conversation history`
: undefined,
(shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"),
(shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"),
(shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"),
(shortcuts) => press(shortcuts.inputClear(), "when typing to clear the input field"),
(shortcuts) => press(shortcuts.sessionInterrupt(), "to stop the AI mid-response"),
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
(shortcuts) => {
const items = [
shortcuts.sessionParent(),
shortcuts.childFirst(),
shortcuts.childPrevious(),
shortcuts.childNext(),
].filter(Boolean)
if (!items.length) return undefined
return `Use ${items.map(shortcutText).join(" / ")} to move between parent and child sessions`
},
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} to define reusable custom prompts",
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
'Set {highlight}"formatter": true{/highlight} in config to enable built-in formatters like prettier, gofmt, and ruff',
'Set {highlight}"formatter": false{/highlight} in config to disable formatters enabled by another config layer',
"Define custom formatter commands with file extensions in config",
'Set {highlight}"lsp": true{/highlight} in config to enable built-in LSP servers for code analysis',
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools",
"Tool definitions can invoke scripts written in Python, Go, etc",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugins/{/highlight} for event hooks",
"Use plugins to send OS notifications when sessions complete",
"Create a plugin to prevent OpenCode from reading sensitive files",
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
"Use {highlight}opencode --continue{/highlight} to resume the last session",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version",
"Run {highlight}opencode auth list{/highlight} to see all configured providers",
"Run {highlight}opencode agent create{/highlight} for guided agent creation",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
"Themes support dark/light variants for both modes",
"Use numeric xterm color codes 0-255 in custom theme JSON",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
"Use {highlight}instructions{/highlight} in config to load additional rules files",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative)",
"Configure {highlight}steps{/highlight} to limit agentic iterations per request",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools',
'Set {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server',
"Override global tool settings per agent configuration",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing',
"Run {highlight}/unshare{/highlight} to remove a session from public access",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops",
"Permission {highlight}external_directory{/highlight} protects files outside project",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
(shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
(shortcuts) =>
shortcuts.commandList()
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`
: "Toggle username display in chat via the command palette",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
(shortcuts) => `Use ${commandText("/help", shortcuts.helpShow())} to show the help dialog`,
"Use {highlight}/rename{/highlight} to rename the current session",
]
const INPUT_UNDO_TIP: Tip = (shortcuts) => press(shortcuts.inputUndo(), "to undo changes in your prompt")
const TERMINAL_SUSPEND_TIP: Tip = (shortcuts) =>
press(shortcuts.terminalSuspend(), "to suspend the terminal and return to your shell")

View File

@@ -0,0 +1,59 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view"
import { useBindings } from "../../keymap"
const id = "internal:home-tips"
function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
useBindings(() => ({
commands: [
{
name: "tips.toggle",
title: props.hidden ? "Show tips" : "Hide tips",
category: "System",
namespace: "palette",
run() {
props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
props.api.ui.dialog.clear()
},
},
],
bindings: props.api.tuiConfig.keybinds.get("tips.toggle"),
}))
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={props.show}>
<Tips api={props.api} connected={props.connected} />
</Show>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
home_bottom() {
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
const first = createMemo(() => api.state.session.count() === 0)
const connected = createMemo(() =>
api.state.provider.some(
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
),
)
const show = createMemo(() => (!first() || !connected()) && !hidden())
return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,65 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo } from "solid-js"
const id = "internal:sidebar-context"
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
function View(props: { api: TuiPluginApi; session_id: string }) {
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const session = createMemo(() => props.api.state.session.get(props.session_id))
const cost = createMemo(() => session()?.cost ?? 0)
const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) {
return {
tokens: 0,
percent: null,
}
}
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
}
})
return (
<box>
<text fg={theme().text}>
<b>Context</b>
</text>
<text fg={theme().textMuted}>{state().tokens.toLocaleString()} tokens</text>
<text fg={theme().textMuted}>{state().percent ?? 0}% used</text>
<text fg={theme().textMuted}>{money.format(cost())} spent</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_content(_ctx, props) {
return <View api={api} session_id={props.session_id} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,70 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Show, createSignal } from "solid-js"
import { Locale } from "../../util/locale"
const id = "internal:sidebar-files"
function changeCountWidth(item: { additions: number; deletions: number }) {
return [item.additions ? `+${item.additions}` : "", item.deletions ? `-${item.deletions}` : ""]
.filter(Boolean)
.join(" ").length
}
function View(props: { api: TuiPluginApi; session_id: string }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.session.diff(props.session_id))
return (
<Show when={list().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>Modified Files</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme().textMuted} wrapMode="none">
{Locale.truncateLeft(item.file, Math.max(2, 36 - changeCountWidth(item)))}
</text>
<box flexDirection="row" gap={1} flexShrink={0}>
<Show when={item.additions}>
<text fg={theme().diffAdded}>+{item.additions}</text>
</Show>
<Show when={item.deletions}>
<text fg={theme().diffRemoved}>-{item.deletions}</text>
</Show>
</box>
</box>
)}
</For>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 500,
slots: {
sidebar_content(_ctx, props) {
return <View api={api} session_id={props.session_id} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,98 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, Show } from "solid-js"
import { abbreviateHome } from "../../runtime"
import { useTuiPaths } from "../../context/runtime"
const id = "internal:sidebar-footer"
function View(props: { api: TuiPluginApi; sessionID: string }) {
const paths = useTuiPaths()
const theme = () => props.api.theme.current
const has = createMemo(() =>
props.api.state.provider.some(
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
),
)
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
const show = createMemo(() => !has() && !done())
const path = createMemo(() => {
const session = props.api.state.session.get(props.sessionID)
const dir = session?.directory || props.api.state.path.directory || paths.cwd
const out = abbreviateHome(dir, paths.home)
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
const text = branch ? out + ":" + branch : out
const list = text.split("/")
return {
parent: list.slice(0, -1).join("/"),
name: list.at(-1) ?? "",
}
})
return (
<box gap={1}>
<Show when={show()}>
<box
backgroundColor={theme().backgroundElement}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
gap={1}
>
<text flexShrink={0} fg={theme().text}>
</text>
<box flexGrow={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme().text}>
<b>Getting started</b>
</text>
<text fg={theme().textMuted} onMouseDown={() => props.api.kv.set("dismissed_getting_started", true)}>
</text>
</box>
<text fg={theme().textMuted}>OpenCode includes free models so you can start immediately.</text>
<text fg={theme().textMuted}>
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
</text>
<box flexDirection="row" gap={1} justifyContent="space-between">
<text fg={theme().text}>Connect provider</text>
<text fg={theme().textMuted}>/connect</text>
</box>
</box>
</box>
</Show>
<text>
<span style={{ fg: theme().textMuted }}>{path().parent}/</span>
<span style={{ fg: theme().text }}>{path().name}</span>
</text>
<text fg={theme().textMuted}>
<span style={{ fg: theme().success }}></span> <b>Open</b>
<span style={{ fg: theme().text }}>
<b>Code</b>
</span>{" "}
<span>{props.api.app.version}</span>
</text>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
sidebar_footer(_ctx, props) {
return <View api={api} sessionID={props.session_id} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,65 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-lsp"
function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.lsp())
const off = createMemo(() => !props.api.state.config.lsp)
return (
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>LSP</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<Show when={list().length === 0}>
<text fg={theme().textMuted}>{off() ? "LSPs are disabled" : "LSPs will activate as files are read"}</text>
</Show>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: item.status === "connected" ? theme().success : theme().error,
}}
>
</text>
<text fg={theme().textMuted}>
{item.id} {item.root}
</text>
</box>
)}
</For>
</Show>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 300,
slots: {
sidebar_content() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,97 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
const id = "internal:sidebar-mcp"
function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.mcp())
const on = createMemo(() => list().filter((item) => item.status === "connected").length)
const bad = createMemo(
() =>
list().filter(
(item) =>
item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration",
).length,
)
const dot = (status: string) => {
if (status === "connected") return theme().success
if (status === "failed") return theme().error
if (status === "disabled") return theme().textMuted
if (status === "needs_auth") return theme().warning
if (status === "needs_client_registration") return theme().error
return theme().textMuted
}
return (
<Show when={list().length > 0}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>MCP</b>
<Show when={!open()}>
<span style={{ fg: theme().textMuted }}>
{" "}
({on()} active{bad() > 0 ? `, ${bad()} error${bad() > 1 ? "s" : ""}` : ""})
</span>
</Show>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<For each={list()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: dot(item.status),
}}
>
</text>
<text fg={theme().text} wrapMode="word">
{item.name}{" "}
<span style={{ fg: theme().textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed"}>
<i>{item.error}</i>
</Match>
<Match when={item.status === "disabled"}>Disabled</Match>
<Match when={item.status === "needs_auth"}>Needs auth</Match>
<Match when={item.status === "needs_client_registration"}>Needs client ID</Match>
</Switch>
</span>
</text>
</box>
)}
</For>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 200,
slots: {
sidebar_content() {
return <View api={api} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,49 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo, For, Show, createSignal } from "solid-js"
import { TodoItem } from "../../component/todo-item"
const id = "internal:sidebar-todo"
function View(props: { api: TuiPluginApi; session_id: string }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.session.todo(props.session_id))
const show = createMemo(() => list().length > 0 && list().some((item) => item.status !== "completed"))
return (
<Show when={show()}>
<box>
<box flexDirection="row" gap={1} onMouseDown={() => list().length > 2 && setOpen((x) => !x)}>
<Show when={list().length > 2}>
<text fg={theme().text}>{open() ? "▼" : "▶"}</text>
</Show>
<text fg={theme().text}>
<b>Todo</b>
</text>
</box>
<Show when={list().length <= 2 || open()}>
<For each={list()}>{(item) => <TodoItem status={item.status} content={item.content} />}</For>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 400,
slots: {
sidebar_content(_ctx, props) {
return <View api={api} session_id={props.session_id} />
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,232 @@
// Paths branch softly through the screen,
// A quiet tree of changed designs;
// Each leaf remembers what has been,
// And waits where careful light aligns.
export type FileTreeItem = {
readonly file: string
readonly status?: "added" | "deleted" | "modified"
}
export type FileTreeNode = {
readonly id: number
readonly name: string
readonly parent: number | undefined
readonly children: number[]
readonly depth: number
readonly kind: "directory" | "file"
readonly fileIndex?: number
}
export type FileTree = {
readonly roots: number[]
readonly nodes: FileTreeNode[]
}
export type FileTreeRow = {
readonly id: number
readonly depth: number
readonly kind: "directory" | "file"
readonly name: string
readonly fileIndex?: number
}
export function buildFileTree(files: readonly FileTreeItem[]): FileTree {
const roots: number[] = []
const nodes: FileTreeNode[] = []
const directoryByPath = new Map<string, number>()
files.forEach((file, fileIndex) => {
const segments = file.file.split("/").filter(Boolean)
if (segments.length === 0) return
const parent = segments.slice(0, -1).reduce(
(state, segment) => {
const directoryPath = state.path ? `${state.path}/${segment}` : segment
const existing = directoryByPath.get(directoryPath)
if (existing !== undefined) return { id: existing, path: directoryPath, depth: state.depth + 1 }
const id = addFileTreeNode(nodes, roots, {
name: segment,
parent: state.id,
depth: state.depth,
kind: "directory",
})
directoryByPath.set(directoryPath, id)
return { id, path: directoryPath, depth: state.depth + 1 }
},
{ id: undefined as number | undefined, path: "", depth: 0 },
)
addFileTreeNode(nodes, roots, {
name: segments[segments.length - 1]!,
parent: parent.id,
depth: parent.depth,
kind: "file",
fileIndex,
})
})
const tree = { roots, nodes }
tree.roots.sort((left, right) => compareFileTreeNodes(tree, left, right))
tree.nodes.forEach((node) => node.children.sort((left, right) => compareFileTreeNodes(tree, left, right)))
return tree
}
export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet<number>): FileTreeRow[] {
const rows: FileTreeRow[] = []
const visit = (id: number, depth: number) => {
const node = tree.nodes[id]!
if (node.kind === "file") {
rows.push({
id: node.id,
depth,
kind: node.kind,
name: node.name,
fileIndex: node.fileIndex,
})
return
}
const chain = collapsedFileTreeDirectoryChain(tree, node.id)
const last = chain[chain.length - 1]!
rows.push({
id: node.id,
depth,
kind: node.kind,
name: chain.map((item) => item.name).join("/"),
fileIndex: node.fileIndex,
})
if (!expanded || expanded.has(node.id)) last.children.forEach((child) => visit(child, depth + 1))
}
tree.roots.forEach((root) => visit(root, 0))
return rows
}
function collapsedFileTreeDirectoryChain(tree: FileTree, id: number): FileTreeNode[] {
const node = tree.nodes[id]!
const child = node.children.length === 1 ? tree.nodes[node.children[0]!] : undefined
if (child?.kind !== "directory") return [node]
return [node, ...collapsedFileTreeDirectoryChain(tree, child.id)]
}
export function compareFileTreeNodes(tree: FileTree, left: number, right: number) {
const leftNode = tree.nodes[left]!
const rightNode = tree.nodes[right]!
if (leftNode.kind !== rightNode.kind) return leftNode.kind === "directory" ? -1 : 1
if (leftNode.name < rightNode.name) return -1
if (leftNode.name > rightNode.name) return 1
return left - right
}
export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) {
if (rows.length === 0) return undefined
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
if (index === -1) return rows[0]!.id
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
}
export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
const row = index === -1 ? undefined : rows[index]
if (row?.kind !== "directory") return selected
const child = rows[index + 1]
return child && child.depth > row.depth ? child.id : selected
}
export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
const row = index === -1 ? undefined : rows[index]
if (!row || row.depth === 0) return selected
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
}
export function moveFileTreeSelectionToFile(
rows: readonly FileTreeRow[],
selected: number | undefined,
offset: number,
) {
const fileRows = rows.filter((row) => row.fileIndex !== undefined)
if (fileRows.length === 0) return undefined
const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
const next =
offset < 0
? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
: fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
}
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined
return {
highlightedNode: node.id,
expandedNodes: fileTreeParentDirectories(tree, node.id),
}
}
export function singlePatchFileIndex(
selected: number | undefined,
active: number | undefined,
current: number | undefined,
first: number | undefined,
) {
return selected ?? active ?? current ?? first
}
export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
return rows.flatMap((row) => (row.fileIndex === undefined ? [] : [row.fileIndex]))
}
export function showDiffViewerFileTree(showFileTree: boolean, fileCount: number) {
return showFileTree && fileCount > 0
}
export function movePatchFileIndex(fileIndexes: readonly number[], current: number | undefined, offset: number) {
if (fileIndexes.length === 0) return undefined
const index = current === undefined ? -1 : fileIndexes.indexOf(current)
if (index === -1) return fileIndexes[0]
return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))]
}
export function allExpandedFileTreeDirectories(tree: FileTree) {
return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
}
export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<number>, selected: number | undefined) {
if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
const next = new Set(expanded)
if (next.has(selected)) next.delete(selected)
else next.add(selected)
return next
}
export function setFileTreeDirectoryExpanded(
tree: FileTree,
expanded: ReadonlySet<number>,
selected: number | undefined,
value: boolean,
) {
if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
const next = new Set(expanded)
if (value) next.add(selected)
else next.delete(selected)
return next
}
function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<FileTreeNode, "id" | "children">) {
const id = nodes.length
nodes.push({ ...input, id, children: [] })
if (input.parent === undefined) roots.push(id)
else nodes[input.parent]!.children.push(id)
return id
}
function fileTreeParentDirectories(tree: FileTree, id: number) {
const result = new Set<number>()
for (let parent = tree.nodes[id]?.parent; parent !== undefined; parent = tree.nodes[parent]?.parent) {
result.add(parent)
}
return result
}

View File

@@ -0,0 +1,162 @@
/** @jsxImportSource @opentui/solid */
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { Locale } from "../../util/locale"
import { tint } from "../../context/theme"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
const FILE_TREE_STATUS_WIDTH = 2
export type DiffViewerFileTreeTheme = {
readonly background: RGBA
readonly backgroundPanel: ColorInput
readonly backgroundElement: ColorInput
readonly primary: ColorInput
readonly secondary: ColorInput
readonly selectedListItemText: ColorInput
readonly text: RGBA
readonly textMuted: RGBA
readonly error: ColorInput
}
export type DiffViewerFileTreeProps = {
readonly width: number
readonly files: readonly FileTreeItem[]
readonly loading: boolean
readonly error: unknown
readonly theme: DiffViewerFileTreeTheme
readonly focused?: boolean
readonly highlightedNode?: number
readonly selectedFileIndex?: number
readonly reviewedFileNames?: ReadonlySet<string>
readonly expandedNodes?: ReadonlySet<number>
readonly onRowClick?: (row: FileTreeRow) => void
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const tree = createMemo(() => buildFileTree(props.files))
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
let scroll: ScrollBoxRenderable | undefined
createEffect(() => {
const node = props.highlightedNode
if (node === undefined) return
const selectedIndex = rows().findIndex((row) => row.id === node)
if (selectedIndex === -1) return
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, selectedIndex)
scrollSelectedIntoView()
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
return (
<Panel border="both" width={props.width}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<Switch>
<Match when={props.loading || props.error}>
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={props.theme.text}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
{(row, index) => {
const highlighted = () => props.focused && props.highlightedNode === row.id
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
const reviewed = () => {
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
}
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
const status = () => fileTreeRowStatus(row, props.files, reviewed())
const name = () =>
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
return (
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? props.theme.primary : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text fg={highlighted() ? props.theme.background : fadedColor()} wrapMode="none" flexShrink={0}>
{prefix()}
</text>
<box flexGrow={1} minWidth={0}>
<text
fg={
highlighted()
? props.theme.background
: selected()
? props.theme.primary
: reviewed() || row.kind === "directory"
? props.theme.textMuted
: props.theme.text
}
wrapMode="none"
>
{name()}
</text>
</box>
<text
fg={highlighted() ? props.theme.background : props.theme.textMuted}
wrapMode="none"
flexShrink={0}
>
{status()}
</text>
</box>
)
}}
</For>
</Match>
</Switch>
</scrollbox>
</Panel>
)
}
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) {
if (!scroll) return
if (index < scroll.scrollTop) {
scroll.scrollTo(index)
return
}
if (index >= scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(index - scroll.viewport.height + 1)
}
}
function fileTreeRowPrefix(
rows: readonly FileTreeRow[],
index: number,
row: FileTreeRow,
expandedNodes: ReadonlySet<number> | undefined,
) {
const indentation = Array.from({ length: row.depth }, (_, depth) => {
if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " "
return hasLaterSibling(rows, index, depth) ? "│ " : " "
}).join("")
const topRoot = index === 0 && row.depth === 0
const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ "
const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : ""
return `${indentation}${branch}${marker}`
}
function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) {
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
}
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) {
if (row.fileIndex === undefined) return ""
const status = files[row.fileIndex]?.status
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
}

View File

@@ -0,0 +1,103 @@
import type { BorderSides, ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import { createContext, Show, splitProps, useContext } from "solid-js"
export type Axis = "x" | "y"
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
export type PanelBorder = "start" | "end" | "both" | "none"
const PanelGroupContext = createContext<{ axis: Axis }>()
function crossAxis(axis: Axis) {
return axis === "x" ? "y" : "x"
}
function usePanelGroup() {
return useContext(PanelGroupContext)
}
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
const [local, boxProps] = splitProps(props, ["axis", "children"])
return (
<PanelGroupContext.Provider value={{ axis: local.axis }}>
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
{local.children}
</box>
</PanelGroupContext.Provider>
)
}
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
const group = usePanelGroup()
const { theme } = useTheme()
const [local, boxProps] = splitProps(props, ["border"])
const border = local.border ?? "start"
const borderProps =
border === "none"
? {}
: {
border: panelBorderSides(group?.axis ?? "y", border),
borderColor: theme.border,
}
return (
<box
minWidth={0}
minHeight={0}
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
{...borderProps}
{...boxProps}
/>
)
}
function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): BorderSides[] {
if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"]
return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"]
}
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
const group = usePanelGroup()
const { theme } = useTheme()
const color = () => props.color ?? theme.border
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
if (axis() === "y") {
return (
<Show
when={props.start || props.end}
fallback={<box width={1} flexShrink={0} border={["left"]} borderColor={color()} />}
>
<box width={1} flexShrink={0} flexDirection="column">
<Show when={props.start}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["left"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
return (
<Show
when={props.start || props.end}
fallback={<box height={1} flexShrink={0} border={["top"]} borderColor={color()} />}
>
<box height={1} flexShrink={0} flexDirection="row">
<Show when={props.start}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "start")}</text>}</Show>
<box flexGrow={1} border={["top"]} borderColor={color()} />
<Show when={props.end}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "end")}</text>}</Show>
</box>
</Show>
)
}
function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") {
if (edge === "edge") return side === "start" ? "├" : "┤"
if (edge === "edge-in") return "┴"
return "┬"
}
function verticalEdge(edge: SeparatorEdge, side: "start" | "end") {
if (edge === "edge") return side === "start" ? "┬" : "┴"
if (edge === "edge-in") return "┤"
return "├"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
import type { Event } from "@opencode-ai/sdk/v2"
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
const id = "internal:notifications"
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
const session = sessionID ? api.state.session.get(sessionID) : undefined
const isSubagent = session?.parentID !== undefined
void api.attention.notify({
title: session?.title,
message,
notification: isSubagent ? false : { when: "blurred" },
sound: { name: sound, when: "always" },
})
}
function sessionErrorMessage(error: SessionError) {
if (error?.name === "MessageAbortedError") return "Session aborted"
const data = error?.data
if (data && typeof data === "object" && "message" in data && data.message === "SSE read timed out") {
return "Model stopped responding"
}
return "Session error"
}
const tui: TuiPlugin = async (api) => {
const active = new Set<string>()
const errored = new Set<string>()
const questions = new Set<string>()
const permissions = new Set<string>()
api.event.on("question.asked", (event) => {
if (questions.has(event.properties.id)) return
questions.add(event.properties.id)
notify(api, event.properties.sessionID, "Question needs input", "question")
})
api.event.on("question.replied", (event) => {
questions.delete(event.properties.requestID)
})
api.event.on("question.rejected", (event) => {
questions.delete(event.properties.requestID)
})
api.event.on("permission.asked", (event) => {
if (permissions.has(event.properties.id)) return
permissions.add(event.properties.id)
notify(api, event.properties.sessionID, "Permission needs input", "permission")
})
api.event.on("permission.replied", (event) => {
permissions.delete(event.properties.requestID)
})
api.event.on("session.status", (event) => {
const sessionID = event.properties.sessionID
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
active.add(sessionID)
errored.delete(sessionID)
return
}
if (event.properties.status.type !== "idle") return
if (!active.has(sessionID)) return
active.delete(sessionID)
if (errored.has(sessionID)) {
errored.delete(sessionID)
return
}
const session = api.state.session.get(sessionID)
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
})
api.event.on("session.error", (event) => {
const sessionID = event.properties.sessionID
if (!sessionID) return
if (!active.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error")
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,269 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { useBindings } from "../../keymap"
const id = "internal:plugin-manager"
function state(api: TuiPluginApi, item: TuiPluginStatus) {
if (!item.enabled) {
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
}
return (
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
{item.active ? "active" : "inactive"}
</span>
)
}
function source(spec: string) {
if (!spec.startsWith("file://")) return
return fileURLToPath(spec)
}
function meta(item: TuiPluginStatus, width: number) {
if (item.source === "internal") {
if (width >= 120) return "Built-in plugin"
return "Built-in"
}
const next = source(item.spec)
if (next) return next
return item.spec
}
function Install(props: { api: TuiPluginApi }) {
const [global, setGlobal] = createSignal(false)
const [busy, setBusy] = createSignal(false)
useBindings(() => ({
enabled: !busy(),
bindings: [{ key: "tab", desc: "Toggle install scope", group: "Plugins", cmd: () => setGlobal((value) => !value) }],
}))
return (
<props.api.ui.DialogPrompt
title="Install plugin"
placeholder="npm package name"
busy={busy()}
busyText="Installing plugin..."
description={() => (
<box flexDirection="row" gap={1}>
<text fg={props.api.theme.current.textMuted}>scope:</text>
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
{global() ? "global" : "local"}
</text>
<Show when={!busy()}>
<text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
</Show>
</box>
)}
onConfirm={(raw) => {
if (busy()) return
const mod = raw.trim()
if (!mod) {
props.api.ui.toast({
variant: "error",
message: "Plugin package name is required",
})
return
}
setBusy(true)
void props.api.plugins
.install(mod, { global: global() })
.then((out) => {
if (!out.ok) {
props.api.ui.toast({
variant: "error",
message: out.message,
})
if (out.missing) {
props.api.ui.toast({
variant: "info",
message: "Check npm registry/auth settings and try again.",
})
}
show(props.api)
return
}
props.api.ui.toast({
variant: "success",
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
})
if (!out.tui) {
props.api.ui.toast({
variant: "info",
message: "Package has no TUI target to load in this app.",
})
show(props.api)
return
}
return props.api.plugins.add(mod).then((ok) => {
if (!ok) {
props.api.ui.toast({
variant: "warning",
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
})
show(props.api)
return
}
props.api.ui.toast({
variant: "success",
message: `Loaded ${mod} in current session.`,
})
show(props.api)
})
})
.finally(() => {
setBusy(false)
})
}}
onCancel={() => {
show(props.api)
}}
/>
)
}
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
return {
title: item.id,
value: item.id,
category: item.source === "internal" ? "Internal" : "External",
description: meta(item, width),
footer: state(api, item),
disabled: item.id === id,
}
}
function showInstall(api: TuiPluginApi) {
api.ui.dialog.replace(() => <Install api={api} />)
}
function View(props: { api: TuiPluginApi }) {
const size = useTerminalDimensions()
const [list, setList] = createSignal(props.api.plugins.list())
const [cur, setCur] = createSignal<string | undefined>()
const [lock, setLock] = createSignal(false)
createEffect(() => {
const width = size().width
if (width >= 128) {
props.api.ui.dialog.setSize("xlarge")
return
}
if (width >= 96) {
props.api.ui.dialog.setSize("large")
return
}
props.api.ui.dialog.setSize("medium")
})
const rows = createMemo(() =>
[...list()]
.sort((a, b) => {
const x = a.source === "internal" ? 1 : 0
const y = b.source === "internal" ? 1 : 0
if (x !== y) return x - y
return a.id.localeCompare(b.id)
})
.map((item) => row(props.api, item, size().width)),
)
const flip = (x: string) => {
if (lock()) return
const item = list().find((entry) => entry.id === x)
if (!item) return
setLock(true)
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
void task
.then((ok) => {
if (!ok) {
props.api.ui.toast({
variant: "error",
message: `Failed to update plugin ${item.id}`,
})
}
setList(props.api.plugins.list())
})
.finally(() => {
setLock(false)
})
}
return (
<DialogSelect
title="Plugins"
options={rows()}
current={cur()}
onMove={(item) => setCur(item.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
hidden: lock(),
onTrigger: (item) => {
setCur(item.value)
flip(item.value)
},
},
{
title: "install",
command: "dialog.plugins.install",
hidden: lock(),
onTrigger: () => {
showInstall(props.api)
},
},
]}
onSelect={(item) => {
setCur(item.value)
flip(item.value)
}}
/>
)
}
function show(api: TuiPluginApi) {
api.ui.dialog.replace(() => <View api={api} />)
}
const tui: TuiPlugin = async (api) => {
api.keymap.registerLayer({
commands: [
{
name: "plugins.list",
title: "Plugins",
category: "System",
namespace: "palette",
run() {
show(api)
},
},
{
name: "plugins.install",
title: "Install plugin",
category: "System",
namespace: "palette",
run() {
showInstall(api)
},
},
],
bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]),
})
}
const plugin: BuiltinTuiPlugin = {
id,
tui,
}
export default plugin

View File

@@ -0,0 +1,608 @@
/** @jsxImportSource @opentui/solid */
import { RGBA, TextAttributes, type KeyEvent, type Renderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { useBindings, useKeymapSelector } from "../../keymap"
import type { ActiveKey } from "@opentui/keymap"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
const command = {
toggle: "which-key.toggle",
toggleLayout: "which-key.layout.toggle",
togglePending: "which-key.pending.toggle",
groupPrevious: "which-key.group.previous",
groupNext: "which-key.group.next",
scrollUp: "which-key.scroll.up",
scrollDown: "which-key.scroll.down",
pageUp: "which-key.page.up",
pageDown: "which-key.page.down",
home: "which-key.home",
end: "which-key.end",
} as const
const LAYER_PRIORITY = 900
const KV_LAYOUT = "which_key_layout"
const KV_PENDING_PREVIEW = "which_key_pending_preview"
const toggleCommands = [command.toggle, command.toggleLayout, command.togglePending] as const
const scrollCommands = [
command.scrollUp,
command.scrollDown,
command.pageUp,
command.pageDown,
command.home,
command.end,
] as const
const panelCommands = [command.groupPrevious, command.groupNext, ...scrollCommands] as const
const COLUMN_GAP = 4
const TAB_GAP = 3
const MIN_TAB_GAP = 1
const TAB_CONTENT_GAP = 1
const MIN_COLUMN_WIDTH = 28
const MAX_COLUMN_WIDTH = 44
const PANEL_HEIGHT_RATIO = 0.3
const MIN_PANEL_HEIGHT = 8
const MAX_PANEL_HEIGHT = 16
const PANEL_TOP_PADDING = 1
const FOOTER_HEIGHT = 1
const FOOTER_MARGIN = 1
const UNKNOWN = "Unknown"
type Layout = "dock" | "overlay"
type Color = RGBA | string
type Skin = {
panel: Color
text: Color
muted: Color
subtle: Color
key: Color
accent: Color
tab: Color
tabText: Color
}
type Entry = {
type: "entry"
key: string
label: string
group: string
continues: boolean
}
type Group = {
label: string
entries: Entry[]
}
type HeaderItem = { type: "tab"; group: Group } | { type: "scroll" }
type GroupHeader = {
type: "group"
label: string
}
type Item = Entry | GroupHeader
function text(value: unknown) {
if (typeof value !== "string") return undefined
const trimmed = value.trim()
return trimmed || undefined
}
function ink(api: TuiPluginApi, name: string, fallback: string): Color {
const value = Reflect.get(api.theme.current, name)
if (typeof value === "string") return value
if (value instanceof RGBA) return value
return fallback
}
function skin(api: TuiPluginApi): Skin {
return {
panel: ink(api, "backgroundMenu", "#1c1c1c"),
text: ink(api, "text", "#f0f0f0"),
muted: ink(api, "textMuted", "#a5a5a5"),
subtle: ink(api, "borderSubtle", "#6f6f6f"),
key: ink(api, "warning", "#ffd75f"),
accent: ink(api, "primary", "#5f87ff"),
tab: ink(api, "primary", "#5f87ff"),
tabText: ink(api, "selectedListItemText", "#ffffff"),
}
}
function activeKeyLabel(active: ActiveKey<Renderable, KeyEvent>) {
if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN
return (
text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN
)
}
function activeKeyGroup(active: ActiveKey<Renderable, KeyEvent>) {
if (active.continues) return "System"
return text(active.commandAttrs?.category) ?? text(active.bindingAttrs?.group) ?? UNKNOWN
}
function activeKeyEntry(api: TuiPluginApi, active: ActiveKey<Renderable, KeyEvent>): Entry {
const key = api.keys.formatSequence([
{
stroke: active.stroke,
display: active.display,
tokenName: active.tokenName,
},
])
const label = activeKeyLabel(active)
return {
type: "entry",
key,
label: active.continues ? `+${label}` : label,
group: activeKeyGroup(active),
continues: active.continues,
}
}
function grouped(entries: Entry[]): Group[] {
const map = new Map<string, Entry[]>()
for (const entry of entries) map.set(entry.group, [...(map.get(entry.group) ?? []), entry])
return [...map]
.map(([label, entries]) => ({
label,
entries: entries.toSorted(
(a, b) =>
Number(b.continues) - Number(a.continues) || a.label.localeCompare(b.label) || a.key.localeCompare(b.key),
),
}))
.toSorted((a, b) => a.label.localeCompare(b.label))
}
function commandShortcut(api: TuiPluginApi, name: string) {
return useKeymapSelector((keymap) =>
api.keys.formatSequence(
keymap.getCommandBindings({ visibility: "registered", commands: [name] }).get(name)?.[0]?.sequence,
),
)
}
function layout(value: unknown): Layout {
if (value === "overlay") return "overlay"
return "dock"
}
function HomeHint(props: { api: TuiPluginApi }) {
const trigger = commandShortcut(props.api, command.toggle)
const look = createMemo(() => skin(props.api))
return (
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0}>
<text fg={look().muted} wrapMode="none">
Show keyboard shortcuts with <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
</text>
</box>
)
}
function WhichKeyPanel(props: {
api: TuiPluginApi
layout: Layout
mode: () => Layout
pendingPreview: () => boolean
pinned: () => boolean
}) {
const dimensions = useTerminalDimensions()
const [offset, setOffset] = createSignal(0)
const [activeGroup, setActiveGroup] = createSignal<string | undefined>()
const pending = useKeymapSelector((keymap) => keymap.getPendingSequence())
const active = useKeymapSelector((keymap) => keymap.getActiveKeys({ includeMetadata: true }))
const pendingActive = createMemo(() => pending().length > 0 && active().length > 0)
const pendingAutoVisible = createMemo(() => props.mode() === "overlay" && props.pendingPreview() && pendingActive())
const visible = createMemo(() => props.pinned() || pendingAutoVisible())
const pendingMode = createMemo(() => visible() && pendingActive())
const left = 0
const width = createMemo(() => Math.max(1, dimensions().width))
const panelHeight = createMemo(() =>
Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, Math.floor(dimensions().height * PANEL_HEIGHT_RATIO))),
)
const contentWidth = createMemo(() => Math.max(1, width() - 2))
const columns = createMemo(() =>
Math.max(1, Math.min(3, Math.floor((contentWidth() + COLUMN_GAP) / (MAX_COLUMN_WIDTH + COLUMN_GAP)) || 1)),
)
const entries = createMemo(() => active().map((item) => activeKeyEntry(props.api, item)))
const groups = createMemo(() => grouped(entries()))
const tabsVisible = createMemo(() => !pendingMode() && groups().length > 0)
const headerVisible = createMemo(() => tabsVisible() || pendingMode())
const footerVisible = createMemo(() => !pendingMode())
const rows = createMemo(() =>
Math.max(
1,
panelHeight() -
PANEL_TOP_PADDING -
(headerVisible() ? 1 : 0) -
(tabsVisible() ? TAB_CONTENT_GAP : 0) -
(footerVisible() ? FOOTER_MARGIN + FOOTER_HEIGHT : 0),
),
)
const pageSize = createMemo(() => rows() * columns())
const currentGroup = createMemo(() => {
const group = activeGroup()
return groups().find((item) => item.label === group) ?? groups()[0]
})
const activeEntries = createMemo(() => currentGroup()?.entries ?? [])
const items = createMemo<Item[]>(() => {
if (!pendingMode()) return activeEntries()
return groups().flatMap((group) => [{ type: "group", label: group.label } satisfies GroupHeader, ...group.entries])
})
const maxOffset = createMemo(() => Math.max(0, items().length - pageSize()))
const shown = createMemo(() => {
const columnsItems: Item[][] = []
let index = offset()
for (let column = 0; column < columns() && index < items().length; column++) {
const list: Item[] = []
while (list.length < rows() && index < items().length) {
list.push(items()[index]!)
index += 1
}
columnsItems.push(list)
}
return columnsItems
})
const rowIndexes = createMemo(() => Array.from({ length: rows() }, (_, index) => index))
const trigger = commandShortcut(props.api, command.toggle)
const modeTrigger = commandShortcut(props.api, command.toggleLayout)
const upActive = createMemo(() => offset() > 0)
const downActive = createMemo(() => offset() < maxOffset())
const scrollable = createMemo(() => maxOffset() > 0)
const headerItems = createMemo<HeaderItem[]>(() => [
...(tabsVisible() ? groups().map((group) => ({ type: "tab" as const, group })) : []),
...(scrollable() ? [{ type: "scroll" as const }] : []),
])
const tabGap = createMemo(() => {
const itemCount = headerItems().length
if (itemCount <= 1) return 0
const itemWidth = headerItems().reduce(
(sum, item) => sum + (item.type === "tab" ? item.group.label.length + 2 : 3),
0,
)
return Math.max(MIN_TAB_GAP, Math.min(TAB_GAP, Math.floor((contentWidth() - itemWidth) / (itemCount - 1))))
})
const nextMode = createMemo(() => (props.mode() === "dock" ? "overlay" : "dock"))
const look = createMemo(() => skin(props.api))
const columnWidth = createMemo(() =>
Math.max(1, Math.min(MAX_COLUMN_WIDTH, Math.floor((contentWidth() - (columns() - 1) * COLUMN_GAP) / columns()))),
)
const clamp = (value: number) => Math.max(0, Math.min(maxOffset(), value))
const scroll = (delta: number) => setOffset((value) => clamp(value + delta))
const moveGroup = (delta: number) => {
if (pendingMode()) return
const list = groups()
if (!list.length) return
const index = Math.max(
0,
list.findIndex((item) => item.label === currentGroup()?.label),
)
setActiveGroup(list[(index + delta + list.length) % list.length]!.label)
setOffset(0)
}
useBindings(() => ({
priority: 1000,
enabled: visible(),
commands: [
{
name: command.groupPrevious,
title: "Previous key binding group",
desc: "Show the previous which-key group",
category: "System",
run() {
moveGroup(-1)
},
},
{
name: command.groupNext,
title: "Next key binding group",
desc: "Show the next which-key group",
category: "System",
run() {
moveGroup(1)
},
},
{
name: command.scrollUp,
title: "Scroll key bindings up",
desc: "Scroll the which-key panel up",
category: "System",
run() {
scroll(-columns())
},
},
{
name: command.scrollDown,
title: "Scroll key bindings down",
desc: "Scroll the which-key panel down",
category: "System",
run() {
scroll(columns())
},
},
{
name: command.pageUp,
title: "Page key bindings up",
desc: "Page the which-key panel up",
category: "System",
run() {
scroll(-pageSize())
},
},
{
name: command.pageDown,
title: "Page key bindings down",
desc: "Page the which-key panel down",
category: "System",
run() {
scroll(pageSize())
},
},
{
name: command.home,
title: "First key binding",
desc: "Jump to the first which-key binding",
category: "System",
run() {
setOffset(0)
},
},
{
name: command.end,
title: "Last key binding",
desc: "Jump to the last which-key binding",
category: "System",
run() {
setOffset(maxOffset())
},
},
],
bindings: pendingMode()
? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands)
: props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands),
}))
createEffect(() => {
if (pendingMode()) return
const group = currentGroup()
if (group?.label === activeGroup()) return
setActiveGroup(group?.label)
})
createEffect(() => {
if (pendingMode()) return
activeGroup()
setOffset(0)
})
createEffect(() => {
if (!visible()) setOffset(0)
})
createEffect(() => {
pending()
setOffset(0)
})
createEffect(() => {
setOffset((value) => clamp(value))
})
return (
<Show when={visible()}>
<box
position={props.layout === "overlay" ? "absolute" : "relative"}
zIndex={3500}
left={left}
bottom={props.layout === "overlay" ? 0 : undefined}
width={dimensions().width}
height={panelHeight()}
backgroundColor={look().panel}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
flexShrink={0}
flexDirection="column"
>
<Show when={headerVisible()}>
<box width="100%" flexDirection="row" justifyContent="center" gap={tabGap()} flexShrink={0}>
<For each={headerItems()}>
{(item) => (
<Show
when={item.type === "tab" ? item.group : undefined}
fallback={
<box flexShrink={0}>
<text wrapMode="none">
<span style={{ fg: upActive() ? look().text : look().muted }}></span>
<span style={{ fg: look().muted }}> </span>
<span style={{ fg: downActive() ? look().text : look().muted }}></span>
</text>
</box>
}
>
{(group) => {
const selected = createMemo(() => currentGroup()?.label === group().label)
return (
<box
paddingLeft={1}
paddingRight={1}
flexShrink={0}
backgroundColor={selected() ? look().tab : undefined}
onMouseDown={() => {
setActiveGroup(group().label)
setOffset(0)
}}
>
<text
fg={selected() ? look().tabText : look().muted}
attributes={selected() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{group().label}
</text>
</box>
)
}}
</Show>
)}
</For>
</box>
</Show>
<Show when={tabsVisible()}>
<box height={TAB_CONTENT_GAP} flexShrink={0} />
</Show>
<box height={rows()} flexShrink={0} flexDirection="column">
<Show when={shown().length > 0} fallback={<text fg={look().muted}>No reachable bindings</text>}>
<For each={rowIndexes()}>
{(row) => (
<box width="100%" flexDirection="row" justifyContent="center" gap={COLUMN_GAP}>
<For each={shown()}>
{(column) => {
const item = createMemo(() => column[row])
const entry = createMemo(() => {
const value = item()
if (value?.type !== "entry") return undefined
return value
})
return (
<box width={columnWidth()} flexDirection="row" gap={1} justifyContent="space-between">
<Show when={item()}>
{(value) => (
<Show
when={entry()}
fallback={
<text fg={look().accent} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{value().label}
</text>
}
>
{(binding) => (
<>
<box flexGrow={1} minWidth={0}>
<text
fg={binding().continues ? look().accent : look().muted}
wrapMode="none"
truncate
>
{binding().label}
</text>
</box>
<box flexShrink={0}>
<text fg={look().text} attributes={TextAttributes.BOLD} wrapMode="none" truncate>
{binding().key}
</text>
</box>
</>
)}
</Show>
)}
</Show>
</box>
)
}}
</For>
</box>
)}
</For>
</Show>
</box>
<Show when={footerVisible()}>
<box height={FOOTER_MARGIN} flexShrink={0} />
<box width="100%" flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box>
<text fg={look().text} wrapMode="none">
toggle <span style={{ fg: look().subtle }}>{trigger() || command.toggle}</span>
</text>
</box>
<box>
<text fg={look().text} wrapMode="none">
{nextMode()} <span style={{ fg: look().subtle }}>{modeTrigger() || command.toggleLayout}</span>
</text>
</box>
</box>
</Show>
</box>
</Show>
)
}
const tui: TuiPlugin = async (api) => {
const [pinned, setPinned] = createSignal(false)
const [mode, setMode] = createSignal(layout(api.kv.get(KV_LAYOUT, "dock")))
const [pendingPreview, setPendingPreview] = createSignal(api.kv.get(KV_PENDING_PREVIEW, false))
api.keymap.registerLayer({
priority: LAYER_PRIORITY,
commands: [
{
name: command.toggle,
title: "Show key bindings",
desc: "Toggle which-key overlay",
category: "System",
run() {
setPinned((value) => !value)
},
},
{
name: command.toggleLayout,
title: "Toggle key bindings layout",
desc: "Switch which-key between dock and overlay mode",
category: "System",
run() {
setMode((value) => {
const next = value === "dock" ? "overlay" : "dock"
api.kv.set(KV_LAYOUT, next)
return next
})
},
},
{
name: command.togglePending,
title: "Toggle pending key preview",
desc: "Automatically show which-key for pending key sequences in overlay mode",
category: "System",
run() {
setPendingPreview((value) => {
api.kv.set(KV_PENDING_PREVIEW, !value)
return !value
})
},
},
],
bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands),
})
api.slots.register({
order: 200,
slots: {
home_bottom() {
return <HomeHint api={api} />
},
app() {
return (
<Show when={mode() === "overlay"}>
<WhichKeyPanel api={api} layout="overlay" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
</Show>
)
},
app_bottom() {
return (
<Show when={mode() === "dock"}>
<WhichKeyPanel api={api} layout="dock" mode={mode} pendingPreview={pendingPreview} pinned={pinned} />
</Show>
)
},
},
})
}
const plugin: BuiltinTuiPlugin = {
id: "which-key",
enabled: false,
tui,
}
export default plugin

View File

@@ -0,0 +1 @@
export { run, type TuiInput } from "./app"

290
packages/tui/src/keymap.tsx Normal file
View File

@@ -0,0 +1,290 @@
import { InputRenderable, TextareaRenderable, type CliRenderer, type KeyEvent, type Renderable } from "@opentui/core"
import {
registerBackspacePopsPendingSequence,
registerBaseLayoutFallback,
registerCommaBindings,
registerEscapeClearsPendingSequence,
registerManagedTextareaLayer,
registerTimedLeader,
} from "@opentui/keymap/addons/opentui"
import { stringifyKeyStroke, type Binding } from "@opentui/keymap"
import {
formatCommandBindings as formatCommandBindingsExtra,
formatKeySequence as formatKeySequenceExtra,
} from "@opentui/keymap/extras"
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
import { createMemo, type Accessor } from "solid-js"
import { useTuiConfig } from "./config"
import { TuiKeybind } from "./config/keybind"
export const LEADER_TOKEN = "leader"
export const OPENCODE_BASE_MODE = "base"
export const COMMAND_PALETTE_COMMAND = "command.palette.show"
const OPENCODE_MODE_KEY = "opencode.mode"
export const OpencodeKeymapProvider = KeymapProvider
export const useOpencodeKeymap = useKeymap
export { useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
type CommandSlashEntry = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
type BindingLookup = {
get(command: string): readonly Binding<Renderable, KeyEvent>[]
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
}
type FormatConfig = { keybinds: BindingLookup }
type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number }
const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
function isVisiblePaletteCommand(command: Command) {
return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
}
export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
keymap.setData(OPENCODE_MODE_KEY, OPENCODE_BASE_MODE)
const offFields = keymap.registerLayerFields({
mode(value, ctx) {
ctx.require(OPENCODE_MODE_KEY, value)
},
})
const stack: { id: symbol; mode: string }[] = []
let disposed = false
const update = () => {
keymap.setData(OPENCODE_MODE_KEY, stack.at(-1)?.mode ?? OPENCODE_BASE_MODE)
}
const stackApi = {
current() {
return stack.at(-1)?.mode ?? OPENCODE_BASE_MODE
},
push(mode: string) {
if (disposed) return () => {}
const id = Symbol(mode)
let active = true
stack.push({ id, mode })
update()
return () => {
if (!active) return
active = false
const index = stack.findIndex((item) => item.id === id)
if (index !== -1) stack.splice(index, 1)
update()
}
},
dispose() {
if (disposed) return
disposed = true
stack.length = 0
offFields()
keymap.setData(OPENCODE_MODE_KEY, undefined)
modeStacks.delete(keymap)
},
}
modeStacks.set(keymap, stackApi)
return stackApi
}
export function useOpencodeModeStack() {
return getOpencodeModeStack(useOpencodeKeymap())
}
export function getOpencodeModeStack(keymap: OpenTuiKeymap) {
const value = modeStacks.get(keymap)
if (!value) throw new Error("Opencode mode stack is not registered for this keymap")
return value
}
const KEY_ALIASES = {
enter: "return",
esc: "escape",
pgdown: "pagedown",
pgup: "pageup",
} as const
function expandKeyAliases(input: string) {
const result = Object.entries(KEY_ALIASES).reduce(
(acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
input,
)
if (result === input) return
return result
}
function registerKeyAliases(keymap: OpenTuiKeymap) {
return keymap.appendBindingExpander((ctx) => {
const key = expandKeyAliases(ctx.input)
if (!key) return
return [{ key, displays: ctx.displays }]
})
}
const inputCommands = [
"input.move.left",
"input.move.right",
"input.move.up",
"input.move.down",
"input.select.left",
"input.select.right",
"input.select.up",
"input.select.down",
"input.line.home",
"input.line.end",
"input.select.line.home",
"input.select.line.end",
"input.visual.line.home",
"input.visual.line.end",
"input.select.visual.line.home",
"input.select.visual.line.end",
"input.buffer.home",
"input.buffer.end",
"input.select.buffer.home",
"input.select.buffer.end",
"input.delete.line",
"input.delete.to.line.end",
"input.delete.to.line.start",
"input.backspace",
"input.delete",
"input.newline",
"input.undo",
"input.redo",
"input.word.forward",
"input.word.backward",
"input.select.word.forward",
"input.select.word.backward",
"input.delete.word.forward",
"input.delete.word.backward",
"input.select.all",
"input.submit",
] as const
function hasManagedTextareaFocus(renderer: CliRenderer) {
const editor = renderer.currentFocusedEditor
return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
}
function leaderDisplay(config: FormatConfig) {
const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
if (!key) return TuiKeybind.LeaderDefault
return typeof key === "string" ? key : stringifyKeyStroke(key)
}
function leaderKey(config: FormatConfig) {
return config.keybinds.get(LEADER_TOKEN)?.[0]?.key
}
function formatOptions(config: FormatConfig) {
return {
tokenDisplay: {
[LEADER_TOKEN]: leaderDisplay(config),
},
keyNameAliases: {
pageup: "pgup",
pagedown: "pgdn",
delete: "del",
},
modifierAliases: {
meta: "alt",
},
} as const
}
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: FormatConfig) {
return formatKeySequenceExtra(parts, formatOptions(config))
}
export function formatKeyBindings(bindings: Parameters<typeof formatCommandBindingsExtra>[0], config: FormatConfig) {
return formatCommandBindingsExtra(bindings, formatOptions(config))
}
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: ResolvedKeymapConfig) {
const modeStack = createOpencodeModeStack(keymap)
const offCommaBindings = registerCommaBindings(keymap)
const offAliasExpander = registerKeyAliases(keymap)
const offBaseLayout = registerBaseLayoutFallback(keymap)
const leader = leaderKey(config)
const offLeader = leader
? registerTimedLeader(keymap, {
trigger: leader,
name: LEADER_TOKEN,
timeoutMs: config.leader_timeout,
})
: () => {}
const offEscape = registerEscapeClearsPendingSequence(keymap)
const offBackspace = registerBackspacePopsPendingSequence(keymap)
const offInputBindings = registerManagedTextareaLayer(keymap, renderer, {
enabled: () => hasManagedTextareaFocus(renderer),
bindings: config.keybinds.gather("input", inputCommands),
})
return () => {
offInputBindings()
offBackspace()
offEscape()
offLeader()
offAliasExpander()
offBaseLayout()
offCommaBindings()
modeStack.dispose()
}
}
export function useLeaderActive(): Accessor<boolean> {
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
}
export function useCommandShortcut(command: string): Accessor<string> {
const config = useTuiConfig()
return useKeymapSelector((keymap: OpenTuiKeymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
config,
),
)
}
export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
const keymap = useOpencodeKeymap()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
keymap.getCommandEntries({
visibility: "reachable",
namespace: "palette",
filter: isVisiblePaletteCommand,
}),
)
return createMemo<CommandSlashEntry[]>(() =>
entries().flatMap((entry) => {
const slashName = entry.command.slashName
if (typeof slashName !== "string" || !slashName) return []
const slashAliases = entry.command.slashAliases
return {
display: `/${slashName}`,
description:
typeof entry.command.desc === "string"
? entry.command.desc
: typeof entry.command.title === "string"
? entry.command.title
: undefined,
aliases: Array.isArray(slashAliases)
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
: undefined,
onSelect: () => keymap.dispatchCommand(entry.command.name),
}
}),
)
}

31
packages/tui/src/logo.ts Normal file
View File

@@ -0,0 +1,31 @@
export const logo = {
left: [
" ",
" ▄▀█ █▀▀ █▀▀▄",
"█▀▀█ █ █▀▀▄",
"▀ ▀ ▀▀▀ ▀ ▀",
],
right: [
" ",
"█▀▀ █▀▀█ █▀▀▄ ▀█▀ █▀▀▄ █▀▀▄ ",
"█ █ █ █ █ █ █▄ █ █ ▀█ ",
"▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀▀▀ ",
],
}
export const go = {
left: [
" ",
"█▀▀█",
"█▀▀█",
"▀ ▀",
],
right: [
" ",
" ▀█ ",
" █ ",
" ▀▀ ",
],
}
export const marks = "_^~,"

View File

@@ -0,0 +1,386 @@
export default {
// NOTE: FOR markdown, javascript and typescript, we use the opentui built-in parsers
// Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
// marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
// ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
parsers: [
{
filetype: "python",
wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// it is using "except" nodes that the parser is complaining about, but it has been in the query for 3+ years.
// Unclear.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/locals.scm",
],
},
},
{
filetype: "rust",
wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.24.0/tree-sitter-rust.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/locals.scm",
],
},
},
{
filetype: "go",
wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.25.0/tree-sitter-go.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/locals.scm",
],
},
},
{
filetype: "cpp",
wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/locals.scm",
],
},
},
{
filetype: "csharp",
wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/locals.scm",
],
},
},
{
filetype: "bash",
wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.0/tree-sitter-bash.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm",
],
},
},
{
filetype: "c",
wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/locals.scm",
],
},
},
{
filetype: "java",
wasm: "https://github.com/tree-sitter/tree-sitter-java/releases/download/v0.23.5/tree-sitter-java.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/locals.scm",
],
},
},
{
filetype: "kotlin",
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
queries: {
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
},
},
{
filetype: "ruby",
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/locals.scm",
],
},
},
{
filetype: "php",
wasm: "https://github.com/tree-sitter/tree-sitter-php/releases/download/v0.24.2/tree-sitter-php.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/php/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-php/raw/refs/heads/master/queries/highlights.scm",
],
},
},
{
filetype: "scala",
wasm: "https://github.com/tree-sitter/tree-sitter-scala/releases/download/v0.24.0/tree-sitter-scala.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/scala/highlights.scm",
],
},
},
{
filetype: "html",
wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
queries: {
highlights: [
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/html/highlights.scm",
"https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm",
],
// TODO: Injections not working for some reason
// injections: [
// "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/injections.scm",
// ],
},
// injectionMapping: {
// nodeTypes: {
// script_element: "javascript",
// style_element: "css",
// },
// infoStringMap: {
// javascript: "javascript",
// css: "css",
// },
// },
},
{
filetype: "vue",
wasm: "https://github.com/anomalyco/tree-sitter-vue/releases/download/v0.1.2/tree-sitter-vue.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/html_tags/highlights.scm",
"https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/vue/highlights.scm",
],
},
},
{
filetype: "hcl",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
],
},
},
{
filetype: "json",
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
],
},
},
{
filetype: "yaml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.2/tree-sitter-yaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/highlights.scm",
],
},
},
{
filetype: "haskell",
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/haskell/highlights.scm",
],
},
},
{
filetype: "css",
wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.25.0/tree-sitter-css.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm",
],
},
},
{
filetype: "julia",
wasm: "https://github.com/tree-sitter/tree-sitter-julia/releases/download/v0.23.1/tree-sitter-julia.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/julia/highlights.scm",
],
},
},
{
filetype: "lua",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
],
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
},
},
{
filetype: "ocaml",
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ocaml/highlights.scm",
],
},
},
{
filetype: "clojure",
// temporarily using fork to fix issues
wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",
],
},
},
{
filetype: "swift",
wasm: "https://github.com/alex-pinkus/tree-sitter-swift/releases/download/0.7.1/tree-sitter-swift.wasm",
queries: {
highlights: [
// NOTE: Using parser repo queries instead of nvim-treesitter due to incompatible #lua-match? predicates
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/highlights.scm
"https://raw.githubusercontent.com/alex-pinkus/tree-sitter-swift/main/queries/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/swift/locals.scm",
],
},
},
{
filetype: "toml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
],
},
},
{
filetype: "nix",
// TODO: Replace with official tree-sitter-nix WASM when published
// See: https://github.com/nix-community/tree-sitter-nix/issues/66
wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
],
},
},
{
filetype: "diff",
aliases: ["udiff", "patch"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-diff/releases/download/v0.1.0/tree-sitter-diff.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm",
],
},
},
{
filetype: "elixir",
wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
],
},
},
{
filetype: "fsharp",
wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
],
},
},
{
filetype: "r",
wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
],
},
},
{
filetype: "make",
aliases: ["makefile"],
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
],
},
},
{
filetype: "vim",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
],
},
},
{
filetype: "xml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
],
},
},
{
filetype: "agda",
wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
],
},
},
],
}

View File

@@ -0,0 +1,354 @@
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { TuiConfig } from "../config"
import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useSDK } from "../context/sdk"
import type { useSync } from "../context/sync"
import type { useTheme } from "../context/theme"
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
import type { useOpencodeKeymap } from "../keymap"
import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
import { Prompt } from "../component/prompt"
import type { useToast } from "../ui/toast"
import * as Keymap from "../keymap"
import { createCommandShim } from "./command-shim"
import type { PluginRoutes } from "./api"
export type { RouteMap } from "./api"
export { createPluginRoutes, createTuiApi } from "./api"
type Input = {
version: string
tuiConfig: TuiConfig.Resolved
dialog: ReturnType<typeof useDialog>
keymap: ReturnType<typeof useOpencodeKeymap>
kv: ReturnType<typeof useKV>
route: ReturnType<typeof useRoute>
routes: PluginRoutes
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
renderer: TuiPluginApi["renderer"]
attention: TuiPluginApi["attention"]
Slot: TuiPluginApi["ui"]["Slot"]
}
function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
if (name === "home") {
route.navigate({ type: "home" })
return
}
if (name === "session") {
const sessionID = params?.sessionID
if (typeof sessionID !== "string") return
route.navigate({ type: "session", sessionID })
return
}
route.navigate({ type: "plugin", id: name, data: params })
}
function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
if (route.data.type === "home") return { name: "home" }
if (route.data.type === "session") {
return {
name: "session",
params: {
sessionID: route.data.sessionID,
prompt: route.data.prompt,
},
}
}
return {
name: route.data.id,
params: route.data.data,
}
}
function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
return {
...item,
onSelect: () => item.onSelect?.(),
}
}
function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
return {
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
}
}
function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
if (!cb) return
return (item: SelectOption<Value>) => cb(pickOption(item))
}
function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return {
get ready() {
return sync.ready
},
get config() {
return sync.data.config
},
get provider() {
return sync.data.provider
},
get path() {
return sync.path
},
get vcs() {
if (!sync.data.vcs) return
return {
branch: sync.data.vcs.branch,
}
},
session: {
count() {
return sync.data.session.length
},
get(sessionID) {
return sync.session.get(sessionID)
},
diff(sessionID) {
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
item.file === undefined ? [] : [{ ...item, file: item.file }],
)
},
todo(sessionID) {
return sync.data.todo[sessionID] ?? []
},
messages(sessionID) {
return sync.data.message[sessionID] ?? []
},
status(sessionID) {
return sync.data.session_status[sessionID]
},
permission(sessionID) {
return sync.data.permission[sessionID] ?? []
},
question(sessionID) {
return sync.data.question[sessionID] ?? []
},
},
part(messageID) {
return sync.data.part[messageID] ?? []
},
lsp() {
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
},
mcp() {
return Object.entries(sync.data.mcp)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, item]) => ({
name,
status: item.status,
error: item.status === "failed" ? item.error : undefined,
}))
},
}
}
function appApi(version: string): TuiPluginApi["app"] {
return {
get version() {
return version
},
}
}
export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycle"> {
return {
app: appApi(input.version),
attention: input.attention,
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
keys: {
formatSequence(parts) {
return Keymap.formatKeySequence(parts, input.tuiConfig)
},
formatBindings(bindings) {
return Keymap.formatKeyBindings(bindings, input.tuiConfig)
},
},
keymap: input.keymap,
mode: {
current() {
return Keymap.getOpencodeModeStack(input.keymap).current()
},
push(mode) {
return Keymap.getOpencodeModeStack(input.keymap).push(mode)
},
},
route: {
register(list) {
return input.routes.register(list)
},
navigate(name, params) {
routeNavigate(input.route, name, params)
},
get current() {
return routeCurrent(input.route)
},
},
ui: {
Dialog(props) {
return (
<DialogUI size={props.size} onClose={props.onClose}>
{props.children}
</DialogUI>
)
},
DialogAlert(props) {
return <DialogAlert {...props} />
},
DialogConfirm(props) {
return <DialogConfirm {...props} />
},
DialogPrompt(props) {
return <DialogPrompt {...props} description={props.description} />
},
DialogSelect(props) {
return (
<DialogSelect
title={props.title}
placeholder={props.placeholder}
options={props.options.map(mapOption)}
flat={props.flat}
onMove={mapOptionCb(props.onMove)}
onFilter={props.onFilter}
onSelect={mapOptionCb(props.onSelect)}
skipFilter={props.skipFilter}
current={props.current}
/>
)
},
Slot<Name extends string>(props: TuiSlotProps<Name>) {
return <input.Slot {...props} />
},
Prompt(props) {
return (
<Prompt
sessionID={props.sessionID}
visible={props.visible}
disabled={props.disabled}
onSubmit={props.onSubmit}
ref={props.ref}
hint={props.hint}
right={props.right}
showPlaceholder={props.showPlaceholder}
placeholders={props.placeholders}
/>
)
},
toast(inputToast) {
input.toast.show({
title: inputToast.title,
message: inputToast.message,
variant: inputToast.variant ?? "info",
duration: inputToast.duration,
})
},
dialog: {
replace(render, onClose) {
input.dialog.replace(render, onClose)
},
clear() {
input.dialog.clear()
},
setSize(size) {
input.dialog.setSize(size)
},
get size() {
return input.dialog.size
},
get depth() {
return input.dialog.stack.length
},
get open() {
return input.dialog.stack.length > 0
},
},
},
get tuiConfig() {
return input.tuiConfig
},
kv: {
get(key, fallback) {
return input.kv.get(key, fallback)
},
set(key, value) {
input.kv.set(key, value)
},
get ready() {
return input.kv.ready
},
},
state: stateApi(input.sync),
get client() {
return input.sdk.client
},
event: input.event,
renderer: input.renderer,
slots: {
register() {
throw new Error("slots.register is only available in plugin context")
},
},
plugins: {
list() {
return []
},
async activate() {
return false
},
async deactivate() {
return false
},
async add() {
return false
},
async install() {
return {
ok: false,
message: "plugins.install is only available in plugin context",
}
},
},
theme: {
get current() {
return input.theme.theme
},
get selected() {
return input.theme.selected
},
has(name) {
return input.theme.has(name)
},
set(name) {
return input.theme.set(name)
},
async install(_jsonPath) {
throw new Error("theme.install is only available in plugin context")
},
mode() {
return input.theme.mode()
},
get ready() {
return input.theme.ready
},
},
}
}

View File

@@ -0,0 +1,52 @@
import type { TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import { createSignal } from "solid-js"
type RouteEntry = {
key: symbol
render: TuiRouteDefinition["render"]
}
export type RouteMap = Map<string, RouteEntry[]>
export function createPluginRoutes() {
const routes: RouteMap = new Map()
const [revision, setRevision] = createSignal(0)
return {
register(list: TuiRouteDefinition[]) {
const key = Symbol()
list.forEach((item) => routes.set(item.name, [...(routes.get(item.name) ?? []), { key, render: item.render }]))
setRevision((value) => value + 1)
return () => {
list.forEach((item) => {
const next = routes.get(item.name)?.filter((entry) => entry.key !== key) ?? []
if (next.length) {
routes.set(item.name, next)
return
}
routes.delete(item.name)
})
setRevision((value) => value + 1)
}
},
get(name: string) {
revision()
return routes.get(name)?.at(-1)?.render
},
}
}
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
export function createTuiApi(input: Omit<TuiPluginApi, "lifecycle">): TuiPluginApi {
return {
...input,
lifecycle: {
signal: new AbortController().signal,
onDispose() {
return () => {}
},
},
}
}

View File

@@ -0,0 +1,109 @@
// Legacy `api.command` bridge for v1 plugins; remove in v2.
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { TuiKeybind } from "../config/keybind"
import type { DialogContext } from "../ui/dialog"
const COMMAND_PALETTE_SHOW = "command.palette.show"
const warned = new Set<string>()
type Warn = (api: string, replacement: string) => void
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
type CommandShimDialog = DialogContext | LegacyDialog
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
function warnCommandShim(api: string, replacement: string) {
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
}
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
if (!("stack" in dialog)) return dialog
return {
replace(render, onClose) {
dialog.replace(render, onClose)
},
clear() {
dialog.clear()
},
setSize(size) {
dialog.setSize(size)
},
get size() {
return dialog.size
},
get depth() {
return dialog.stack.length
},
get open() {
return dialog.stack.length > 0
},
}
}
function warnOnce(api: string, replacement: string, warn: Warn) {
if (warned.has(api)) return
warned.add(api)
warn(api, replacement)
}
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
return {
namespace: "palette",
name: item.value,
title: item.title,
desc: item.description,
category: item.category,
suggested: item.suggested,
hidden: item.hidden,
enabled: item.enabled,
slashName: item.slash?.name,
slashAliases: item.slash?.aliases,
run() {
return item.onSelect?.(dialog)
},
}
}
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
return commands.flatMap((item) =>
item.keybind
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
? keybinds
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
: [
{
key: item.keybind,
cmd: item.value,
desc: item.title,
},
]
: [],
)
}
export function createCommandShim(
keymap: TuiPluginApi["keymap"],
dialog: CommandShimDialog,
keybinds: LegacyKeybinds,
): TuiPluginApi["command"] {
const shimDialog = createCommandShimDialog(dialog)
return {
register(cb) {
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
const commands = cb()
return keymap.registerLayer({
commands: commands.map((item) => toCommand(item, shimDialog)),
bindings: toBindings(commands, keybinds),
})
},
trigger(value) {
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
keymap.dispatchCommand(value)
},
show() {
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
},
}
}

View File

@@ -0,0 +1,81 @@
import type {
TuiPluginApi,
TuiPluginInstallOptions,
TuiPluginInstallResult,
TuiPluginStatus,
} from "@opencode-ai/plugin/tui"
import type { TuiConfig } from "../config"
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
import { createPluginRoutes } from "./api"
import { createSlots, type HostSlots } from "./slots"
export function createPluginRuntime() {
const [commands, setCommands] = createSignal<PluginRuntimeCommands>(emptyCommands)
const [status, setStatus] = createSignal<ReadonlyArray<TuiPluginStatus>>([])
const slots = createSlots()
return {
Slot: slots.Slot,
routes: createPluginRoutes(),
commands,
status,
update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray<TuiPluginStatus> }) {
if (input.commands) setCommands(input.commands)
if (input.status) setStatus(input.status)
},
clear() {
setCommands(emptyCommands)
setStatus([])
slots.clear()
},
setupSlots(api: TuiPluginApi): HostSlots {
return slots.setup(api)
},
}
}
export type PluginRuntimeCommands = {
activate: (id: string) => Promise<boolean>
deactivate: (id: string) => Promise<boolean>
add: (spec: string) => Promise<boolean>
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
}
const emptyCommands: PluginRuntimeCommands = {
async activate() {
return false
},
async deactivate() {
return false
},
async add() {
return false
},
async install() {
return { ok: false, message: "Plugin runtime is not available." }
},
}
export type PluginRuntime = ReturnType<typeof createPluginRuntime>
export type TuiPluginHost = {
start(input: {
api: TuiPluginApi
config: TuiConfig.Resolved
runtime: PluginRuntime
dispose?: () => void
}): Promise<void>
dispose(): Promise<void>
}
const Context = createContext<PluginRuntime>()
export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element {
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
}
export function usePluginRuntime() {
const runtime = useContext(Context)
if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider")
return runtime
}

View File

@@ -0,0 +1,65 @@
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import { createSignal } from "solid-js"
import { isRecord } from "../util/record"
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
type SlotView = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
export type HostPluginApi = TuiPluginApi
export type HostSlots = {
register: {
(plugin: HostSlotPlugin): () => void
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
}
dispose: () => void
}
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
return isRecord(value.slots)
}
export function createSlots() {
const empty: SlotView = () => null
const [view, setView] = createSignal<SlotView>(empty)
const Slot: SlotView = (props) => view()(props)
return {
Slot,
setup(api: HostPluginApi): HostSlots {
const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
api.renderer,
{ theme: api.theme },
{
onPluginError(event) {
console.error("[tui.slot] plugin error", {
plugin: event.pluginId,
slot: event.slot,
phase: event.phase,
source: event.source,
message: event.error.message,
})
},
},
)
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(registry)
setView(() => (props: TuiSlotProps<string>) => slot(props))
return {
register(plugin: HostSlotPlugin) {
if (!isHostSlotPlugin(plugin)) return () => {}
return registry.register(plugin)
},
dispose() {
setView(() => empty)
},
}
},
clear() {
setView(() => empty)
},
}
}

View File

@@ -0,0 +1,48 @@
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export function promptOffsetWidth(value: string) {
let width = 0
for (const part of graphemes.segment(value)) {
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
}
return width
}
function displayOffsetIndex(value: string, offset: number) {
if (offset <= 0) return 0
let width = 0
for (const part of graphemes.segment(value)) {
const next = width + promptOffsetWidth(part.segment)
if (next > offset) return part.index
width = next
}
return value.length
}
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
}
export function displayCharAt(value: string, offset: number) {
let width = 0
for (const part of graphemes.segment(value)) {
const next = width + promptOffsetWidth(part.segment)
if (offset === width || offset < next) return part.segment
width = next
}
}
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
const text = displaySlice(value, 0, offset)
const index = text.lastIndexOf("@")
if (index === -1) return
const before = index === 0 ? undefined : text[index - 1]
const query = text.slice(index)
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
return promptOffsetWidth(text.slice(0, index))
}
}

View File

@@ -0,0 +1,80 @@
import path from "path"
import { onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "../context/helper"
import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence"
type FrecencyEntry = { path: string; frequency: number; lastOpen: number }
export const MAX_FRECENCY_ENTRIES = 1000
export function parseFrecency(text: string) {
const latest = text
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as FrecencyEntry
} catch {
return undefined
}
})
.filter((line): line is FrecencyEntry => line !== undefined)
.reduce<Record<string, FrecencyEntry>>((result, entry) => {
result[entry.path] = entry
return result
}, {})
return Object.values(latest)
.sort((a, b) => b.lastOpen - a.lastOpen)
.slice(0, MAX_FRECENCY_ENTRIES)
}
function calculateFrecency(entry?: { frequency: number; lastOpen: number }) {
if (!entry) return 0
return entry.frequency / (1 + (Date.now() - entry.lastOpen) / 86400000)
}
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
name: "Frecency",
init: () => {
const paths = useTuiPaths()
const frecencyPath = path.join(paths.state, "frecency.jsonl")
onMount(async () => {
const lines = parseFrecency(await readText(frecencyPath).catch(() => ""))
setStore(
"data",
Object.fromEntries(
lines.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]),
),
)
if (lines.length > 0)
writeText(frecencyPath, lines.map((entry) => JSON.stringify(entry)).join("\n") + "\n").catch(() => {})
})
const [store, setStore] = createStore({ data: {} as Record<string, { frequency: number; lastOpen: number }> })
function updateFrecency(filePath: string) {
const absolutePath = path.resolve(paths.cwd, filePath)
const newEntry = { frequency: (store.data[absolutePath]?.frequency || 0) + 1, lastOpen: Date.now() }
setStore("data", absolutePath, newEntry)
appendText(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
if (Object.keys(store.data).length <= MAX_FRECENCY_ENTRIES) return
const sorted = Object.entries(store.data)
.sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
.slice(0, MAX_FRECENCY_ENTRIES)
setStore("data", Object.fromEntries(sorted))
writeText(
frecencyPath,
sorted.map(([entryPath, entry]) => JSON.stringify({ path: entryPath, ...entry })).join("\n") + "\n",
).catch(() => {})
}
return {
getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(paths.cwd, filePath)]),
updateFrecency,
data: () => store.data,
}
},
})

View File

@@ -0,0 +1,111 @@
import path from "path"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "../context/helper"
import { useTuiPaths } from "../context/runtime"
import { appendText, readText, writeText } from "../util/persistence"
export type PromptInfo = {
input: string
mode?: "normal" | "shell"
parts: (
| Omit<FilePart, "id" | "messageID" | "sessionID">
| Omit<AgentPart, "id" | "messageID" | "sessionID">
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
source?: {
text: {
start: number
end: number
value: string
}
}
})
)[]
}
export const MAX_HISTORY_ENTRIES = 50
export function parsePromptHistory(text: string) {
return text
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line) as PromptInfo
} catch {
return undefined
}
})
.filter((line): line is PromptInfo => line !== undefined)
.slice(-MAX_HISTORY_ENTRIES)
}
export function isDuplicateEntry(previous: PromptInfo | undefined, next: PromptInfo): boolean {
if (!previous) return false
return JSON.stringify(previous) === JSON.stringify(next)
}
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
name: "PromptHistory",
init: () => {
const paths = useTuiPaths()
const historyPath = path.join(paths.state, "prompt-history.jsonl")
onMount(async () => {
const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
setStore("history", lines)
// Rewrite valid retained entries to self-heal corruption and enforce the limit.
if (lines.length > 0)
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
})
const [store, setStore] = createStore({
index: 0,
history: [] as PromptInfo[],
})
return {
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
if (!current) return undefined
if (current.input !== input && input.length) return
setStore(
produce((draft) => {
const next = store.index + direction
if (Math.abs(next) > store.history.length) return
if (next > 0) return
draft.index = next
}),
)
if (store.index === 0) return { input: "", parts: [] }
return store.history.at(store.index)
},
append(item: PromptInfo) {
const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(store.history.at(-1), entry)) {
setStore("index", 0)
return
}
let trimmed = false
setStore(
produce((draft) => {
draft.history.push(entry)
if (draft.history.length > MAX_HISTORY_ENTRIES) {
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
trimmed = true
}
draft.index = 0
}),
)
if (trimmed) {
writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
return
}
appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
},
}
},
})

Some files were not shown because too many files have changed in this diff Show More