feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
4
packages/tui/bunfig.toml
Normal file
4
packages/tui/bunfig.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["@opentui/solid/preload"]
|
||||
72
packages/tui/package.json
Normal file
72
packages/tui/package.json
Normal 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
1101
packages/tui/src/app.tsx
Normal file
File diff suppressed because it is too large
Load Diff
260
packages/tui/src/attention.ts
Normal file
260
packages/tui/src/attention.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/// <reference path="./audio.d.ts" />
|
||||
import type {
|
||||
TuiAttention,
|
||||
TuiAttentionNotifyInput,
|
||||
TuiAttentionNotifyResult,
|
||||
TuiAttentionNotifySkipReason,
|
||||
TuiAttentionWhen,
|
||||
TuiKV,
|
||||
TuiAttentionSoundName,
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import { AttentionSoundName, type TuiConfig } from "./config"
|
||||
import { Schema } from "effect"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import * as TuiAudio from "./audio"
|
||||
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
|
||||
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
|
||||
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
|
||||
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
|
||||
import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
|
||||
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
|
||||
|
||||
type FocusState = "unknown" | "focused" | "blurred"
|
||||
|
||||
type AttentionRenderer = {
|
||||
readonly isDestroyed: boolean
|
||||
on(event: "focus" | "blur", listener: () => void): unknown
|
||||
off(event: "focus" | "blur", listener: () => void): unknown
|
||||
triggerNotification(message: string, title?: string): boolean
|
||||
}
|
||||
|
||||
type RegisteredSoundPack = TuiAttentionSoundPack & {
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
type TuiAttentionHost = TuiAttention & {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE = "opencode"
|
||||
const DEFAULT_PACK_ID = "opencode.default"
|
||||
const KV_SOUND_PACK = "attention_sound_pack"
|
||||
const TITLE_LIMIT = 80
|
||||
const MESSAGE_LIMIT = 240
|
||||
const BUILTIN_PACK: RegisteredSoundPack = {
|
||||
id: DEFAULT_PACK_ID,
|
||||
name: "OpenCode Default",
|
||||
builtin: true,
|
||||
sounds: {
|
||||
default: defaultSoundPath,
|
||||
question: questionSoundPath,
|
||||
permission: permissionSoundPath,
|
||||
error: errorSoundPath,
|
||||
done: doneSoundPath,
|
||||
subagent_done: subagentDoneSoundPath,
|
||||
},
|
||||
}
|
||||
|
||||
function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
|
||||
return {
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: reason,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(input: string | undefined, fallback: string, limit: number) {
|
||||
const text = stripAnsi(input ?? "")
|
||||
.replace(/[ \t]*[\r\n]+[ \t]*/g, " ")
|
||||
.replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
|
||||
.trim()
|
||||
const normalized = text.length ? text : fallback
|
||||
return Array.from(normalized).slice(0, limit).join("")
|
||||
}
|
||||
|
||||
function clampVolume(volume: number) {
|
||||
if (!Number.isFinite(volume)) return 0
|
||||
return Math.min(1, Math.max(0, volume))
|
||||
}
|
||||
|
||||
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
|
||||
if (!config.attention.sound) return
|
||||
if (input.sound === false) return
|
||||
if (input.sound === undefined) return clampVolume(config.attention.volume)
|
||||
if (input.sound === true) return clampVolume(config.attention.volume)
|
||||
return clampVolume(input.sound.volume ?? config.attention.volume)
|
||||
}
|
||||
|
||||
function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
|
||||
const id = pack.id.trim()
|
||||
if (!id) return
|
||||
return {
|
||||
id,
|
||||
name: pack.name?.trim() || undefined,
|
||||
builtin: false,
|
||||
sounds: Object.fromEntries(
|
||||
Object.entries(pack.sounds).filter(
|
||||
(item): item is [TuiAttentionSoundName, string] =>
|
||||
Schema.is(AttentionSoundName)(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
|
||||
if (when === "always") return
|
||||
if (focus === "unknown") return "focus_unknown"
|
||||
if (when === "blurred" && focus === "focused") return "focused"
|
||||
if (when === "focused" && focus === "blurred") return "blurred"
|
||||
}
|
||||
|
||||
export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<TuiConfig.Resolved, "attention">
|
||||
kv?: TuiKV
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
let focus: FocusState = "unknown"
|
||||
let disposed = false
|
||||
let activePackID: string | undefined
|
||||
const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
|
||||
const audio = input.audio ?? TuiAudio
|
||||
|
||||
const onFocus = () => {
|
||||
focus = "focused"
|
||||
}
|
||||
const onBlur = () => {
|
||||
focus = "blurred"
|
||||
}
|
||||
|
||||
input.renderer.on("focus", onFocus)
|
||||
input.renderer.on("blur", onBlur)
|
||||
|
||||
function configuredPackID() {
|
||||
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
|
||||
return activePackID ?? stored ?? input.config.attention.sound_pack
|
||||
}
|
||||
|
||||
function currentPack() {
|
||||
return packs.get(configuredPackID()) ?? BUILTIN_PACK
|
||||
}
|
||||
|
||||
function soundCandidates(name: TuiAttentionSoundName) {
|
||||
return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
|
||||
(item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
|
||||
)
|
||||
}
|
||||
|
||||
async function playSound(name: TuiAttentionSoundName, volume: number) {
|
||||
try {
|
||||
for (const file of soundCandidates(name)) {
|
||||
const current = await audio.loadSoundFile(file).catch((error) => {
|
||||
console.debug("failed to load attention sound", { file, error })
|
||||
return null
|
||||
})
|
||||
if (disposed) return false
|
||||
if (current == null) continue
|
||||
if (audio.play(current, { volume }) != null) return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.debug("failed to play attention sound", { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async notify(request) {
|
||||
try {
|
||||
if (!input.config.attention.enabled) return skipped("attention_disabled")
|
||||
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
|
||||
|
||||
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
|
||||
if (!message) return skipped("empty_message")
|
||||
|
||||
const requestedNotification = typeof request.notification === "object" ? request.notification : undefined
|
||||
const notificationSkip = focusSkip(requestedNotification?.when ?? "blurred", focus)
|
||||
const notificationRequested = input.config.attention.notifications && request.notification !== false
|
||||
const shouldNotify = notificationRequested && !notificationSkip
|
||||
const notification = shouldNotify
|
||||
? (() => {
|
||||
try {
|
||||
return input.renderer.triggerNotification(
|
||||
message,
|
||||
normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
|
||||
)
|
||||
} catch (error) {
|
||||
console.debug("failed to trigger attention notification", { error })
|
||||
return false
|
||||
}
|
||||
})()
|
||||
: false
|
||||
const volume = soundVolume(request, input.config)
|
||||
const requestedSound = typeof request.sound === "object" ? request.sound : undefined
|
||||
const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
|
||||
const soundName =
|
||||
requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default"
|
||||
const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
|
||||
|
||||
if (!notification && !sound) {
|
||||
if (notificationRequested && notificationSkip) return skipped(notificationSkip)
|
||||
if (soundSkip) return skipped(soundSkip)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: notification || sound,
|
||||
notification,
|
||||
sound,
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug("failed to handle attention notification", { error })
|
||||
return {
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
soundboard: {
|
||||
registerPack(pack) {
|
||||
const next = normalizePack(pack)
|
||||
if (!next) return () => {}
|
||||
packs.set(next.id, next)
|
||||
let disposed = false
|
||||
return () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
if (packs.get(next.id) === next) packs.delete(next.id)
|
||||
}
|
||||
},
|
||||
activate(id, options) {
|
||||
const pack = packs.get(id)
|
||||
if (!pack) return false
|
||||
activePackID = pack.id
|
||||
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
|
||||
return true
|
||||
},
|
||||
current() {
|
||||
return currentPack().id
|
||||
},
|
||||
list(): TuiAttentionSoundPackInfo[] {
|
||||
const current = currentPack().id
|
||||
return Array.from(packs.values()).map((pack) => ({
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
active: pack.id === current,
|
||||
builtin: pack.builtin,
|
||||
}))
|
||||
},
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
input.renderer.off("focus", onFocus)
|
||||
input.renderer.off("blur", onBlur)
|
||||
},
|
||||
}
|
||||
}
|
||||
9
packages/tui/src/audio.d.ts
vendored
Normal file
9
packages/tui/src/audio.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module "*.mp3" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
|
||||
declare module "@opencode-ai/ui/audio/*.mp3" {
|
||||
const path: string
|
||||
export default path
|
||||
}
|
||||
53
packages/tui/src/audio.ts
Normal file
53
packages/tui/src/audio.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
let audio: Audio | null | undefined
|
||||
const sounds = new Map<string, Promise<AudioSound | null>>()
|
||||
|
||||
function getAudio() {
|
||||
if (audio !== undefined) return audio
|
||||
try {
|
||||
const next = Audio.create({ autoStart: false })
|
||||
next.on("error", (error: Error, context: AudioErrorContext) => {
|
||||
console.debug("tui audio error", { error, context })
|
||||
})
|
||||
audio = next
|
||||
return next
|
||||
} catch (error) {
|
||||
console.debug("failed to create tui audio", { error })
|
||||
audio = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSoundFile(file: string) {
|
||||
const current = getAudio()
|
||||
if (!current) return Promise.resolve(null)
|
||||
const cached = sounds.get(file)
|
||||
if (cached) return cached
|
||||
const task = readFile(file)
|
||||
.then((bytes) => current.loadSound(bytes))
|
||||
.catch((error) => {
|
||||
console.debug("failed to load tui sound", { file, error })
|
||||
return null
|
||||
})
|
||||
sounds.set(file, task)
|
||||
return task
|
||||
}
|
||||
|
||||
export function play(sound: AudioSound, options?: AudioPlayOptions) {
|
||||
const current = getAudio()
|
||||
if (!current) return null
|
||||
if (!current.isStarted() && !current.start()) return null
|
||||
return current.play(sound, options)
|
||||
}
|
||||
|
||||
export function stopVoice(voice: AudioVoice) {
|
||||
return audio?.stopVoice(voice) ?? false
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
audio?.dispose()
|
||||
audio = undefined
|
||||
sounds.clear()
|
||||
}
|
||||
124
packages/tui/src/clipboard.ts
Normal file
124
packages/tui/src/clipboard.ts
Normal 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)
|
||||
}
|
||||
436
packages/tui/src/component/bg-pulse-render.ts
Normal file
436
packages/tui/src/component/bg-pulse-render.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
99
packages/tui/src/component/bg-pulse.tsx
Normal file
99
packages/tui/src/component/bg-pulse.tsx
Normal 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
|
||||
/>
|
||||
)
|
||||
}
|
||||
79
packages/tui/src/component/command-palette.tsx
Normal file
79
packages/tui/src/component/command-palette.tsx
Normal 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()} />
|
||||
}
|
||||
31
packages/tui/src/component/dialog-agent.tsx
Normal file
31
packages/tui/src/component/dialog-agent.tsx
Normal 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()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
103
packages/tui/src/component/dialog-console-org.tsx
Normal file
103
packages/tui/src/component/dialog-console-org.tsx
Normal 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()} />
|
||||
}
|
||||
85
packages/tui/src/component/dialog-mcp.tsx
Normal file
85
packages/tui/src/component/dialog-mcp.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
193
packages/tui/src/component/dialog-model.tsx
Normal file
193
packages/tui/src/component/dialog-model.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
238
packages/tui/src/component/dialog-move-session.tsx
Normal file
238
packages/tui/src/component/dialog-move-session.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
469
packages/tui/src/component/dialog-provider.tsx
Normal file
469
packages/tui/src/component/dialog-provider.tsx
Normal 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
|
||||
}
|
||||
160
packages/tui/src/component/dialog-retry-action.tsx
Normal file
160
packages/tui/src/component/dialog-retry-action.tsx
Normal 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),
|
||||
)
|
||||
})
|
||||
}
|
||||
99
packages/tui/src/component/dialog-session-delete-failed.tsx
Normal file
99
packages/tui/src/component/dialog-session-delete-failed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
306
packages/tui/src/component/dialog-session-list.tsx
Normal file
306
packages/tui/src/component/dialog-session-list.tsx
Normal 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}`
|
||||
}
|
||||
31
packages/tui/src/component/dialog-session-rename.tsx
Normal file
31
packages/tui/src/component/dialog-session-rename.tsx
Normal 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()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
36
packages/tui/src/component/dialog-skill.tsx
Normal file
36
packages/tui/src/component/dialog-skill.tsx
Normal 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()} />
|
||||
}
|
||||
87
packages/tui/src/component/dialog-stash.tsx
Normal file
87
packages/tui/src/component/dialog-stash.tsx
Normal 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)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
168
packages/tui/src/component/dialog-status.tsx
Normal file
168
packages/tui/src/component/dialog-status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
packages/tui/src/component/dialog-tag.tsx
Normal file
47
packages/tui/src/component/dialog-tag.tsx
Normal 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()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
50
packages/tui/src/component/dialog-theme-list.tsx
Normal file
50
packages/tui/src/component/dialog-theme-list.tsx
Normal 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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
packages/tui/src/component/dialog-variant.tsx
Normal file
39
packages/tui/src/component/dialog-variant.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
308
packages/tui/src/component/dialog-workspace-create.tsx
Normal file
308
packages/tui/src/component/dialog-workspace-create.tsx
Normal 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,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
144
packages/tui/src/component/dialog-workspace-file-changes.tsx
Normal file
144
packages/tui/src/component/dialog-workspace-file-changes.tsx
Normal 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),
|
||||
)
|
||||
})
|
||||
}
|
||||
112
packages/tui/src/component/dialog-workspace-list.tsx
Normal file
112
packages/tui/src/component/dialog-workspace-list.tsx
Normal 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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
69
packages/tui/src/component/dialog-workspace-unavailable.tsx
Normal file
69
packages/tui/src/component/dialog-workspace-unavailable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
packages/tui/src/component/error-component.tsx
Normal file
79
packages/tui/src/component/error-component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
885
packages/tui/src/component/logo.tsx
Normal file
885
packages/tui/src/component/logo.tsx
Normal 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 />
|
||||
}
|
||||
14
packages/tui/src/component/plugin-route-missing.tsx
Normal file
14
packages/tui/src/component/plugin-route-missing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
768
packages/tui/src/component/prompt/autocomplete.tsx
Normal file
768
packages/tui/src/component/prompt/autocomplete.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
0
packages/tui/src/component/prompt/cwd.ts
Normal file
0
packages/tui/src/component/prompt/cwd.ts
Normal file
1
packages/tui/src/component/prompt/frecency.tsx
Normal file
1
packages/tui/src/component/prompt/frecency.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/frecency"
|
||||
1
packages/tui/src/component/prompt/history.tsx
Normal file
1
packages/tui/src/component/prompt/history.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/history"
|
||||
1697
packages/tui/src/component/prompt/index.tsx
Normal file
1697
packages/tui/src/component/prompt/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
48
packages/tui/src/component/prompt/local-attachment.ts
Normal file
48
packages/tui/src/component/prompt/local-attachment.ts
Normal 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 }
|
||||
}
|
||||
193
packages/tui/src/component/prompt/move.tsx
Normal file
193
packages/tui/src/component/prompt/move.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
1
packages/tui/src/component/prompt/stash.tsx
Normal file
1
packages/tui/src/component/prompt/stash.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/stash"
|
||||
137
packages/tui/src/component/prompt/workspace.tsx
Normal file
137
packages/tui/src/component/prompt/workspace.tsx
Normal 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 }
|
||||
}
|
||||
24
packages/tui/src/component/spinner.tsx
Normal file
24
packages/tui/src/component/spinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
63
packages/tui/src/component/startup-loading.tsx
Normal file
63
packages/tui/src/component/startup-loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
packages/tui/src/component/todo-item.tsx
Normal file
32
packages/tui/src/component/todo-item.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
packages/tui/src/component/use-connected.tsx
Normal file
12
packages/tui/src/component/use-connected.tsx
Normal 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),
|
||||
),
|
||||
)
|
||||
}
|
||||
19
packages/tui/src/component/workspace-label.tsx
Normal file
19
packages/tui/src/component/workspace-label.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
129
packages/tui/src/config/index.tsx
Normal file
129
packages/tui/src/config/index.tsx
Normal 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
|
||||
}
|
||||
465
packages/tui/src/config/keybind.ts
Normal file
465
packages/tui/src/config/keybind.ts
Normal 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] }
|
||||
}
|
||||
}
|
||||
15
packages/tui/src/context/args.tsx
Normal file
15
packages/tui/src/context/args.tsx
Normal 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,
|
||||
})
|
||||
18
packages/tui/src/context/clipboard.tsx
Normal file
18
packages/tui/src/context/clipboard.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createContext, type JSX, useContext } from "solid-js"
|
||||
import { read, write } from "../clipboard"
|
||||
|
||||
export type ClipboardContent = Readonly<{ data: string; mime: string }>
|
||||
export type ClipboardService = Readonly<{
|
||||
read?(): Promise<ClipboardContent | undefined>
|
||||
write?(text: string): Promise<void>
|
||||
}>
|
||||
const clipboard = { read, write }
|
||||
const ClipboardContext = createContext<ClipboardService>(clipboard)
|
||||
|
||||
export function ClipboardProvider(props: { value?: ClipboardService; children: JSX.Element }) {
|
||||
return <ClipboardContext.Provider value={props.value ?? clipboard}>{props.children}</ClipboardContext.Provider>
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
return useContext(ClipboardContext)
|
||||
}
|
||||
587
packages/tui/src/context/data.tsx
Normal file
587
packages/tui/src/context/data.tsx
Normal 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
|
||||
},
|
||||
})
|
||||
17
packages/tui/src/context/directory.ts
Normal file
17
packages/tui/src/context/directory.ts
Normal 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
|
||||
})
|
||||
}
|
||||
408
packages/tui/src/context/editor.ts
Normal file
408
packages/tui/src/context/editor.ts
Normal 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
|
||||
}
|
||||
}
|
||||
6
packages/tui/src/context/epilogue.tsx
Normal file
6
packages/tui/src/context/epilogue.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useEpilogue, provider: EpilogueProvider } = createSimpleContext({
|
||||
name: "Epilogue",
|
||||
init: (props: { set(value?: string): void }) => props.set,
|
||||
})
|
||||
36
packages/tui/src/context/event.ts
Normal file
36
packages/tui/src/context/event.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
8
packages/tui/src/context/exit.tsx
Normal file
8
packages/tui/src/context/exit.tsx
Normal 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,
|
||||
})
|
||||
26
packages/tui/src/context/helper.tsx
Normal file
26
packages/tui/src/context/helper.tsx
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
66
packages/tui/src/context/kv.tsx
Normal file
66
packages/tui/src/context/kv.tsx
Normal 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
|
||||
},
|
||||
})
|
||||
540
packages/tui/src/context/local.tsx
Normal file
540
packages/tui/src/context/local.tsx
Normal 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
|
||||
},
|
||||
})
|
||||
40
packages/tui/src/context/path-format.tsx
Normal file
40
packages/tui/src/context/path-format.tsx
Normal 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)
|
||||
}
|
||||
116
packages/tui/src/context/project.tsx
Normal file
116
packages/tui/src/context/project.tsx
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
18
packages/tui/src/context/prompt.tsx
Normal file
18
packages/tui/src/context/prompt.tsx
Normal 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
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
60
packages/tui/src/context/route.tsx
Normal file
60
packages/tui/src/context/route.tsx
Normal 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 }>
|
||||
}
|
||||
62
packages/tui/src/context/runtime.tsx
Normal file
62
packages/tui/src/context/runtime.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createComponent, createContext, type JSX, useContext } from "solid-js"
|
||||
|
||||
export type TuiPaths = Readonly<{
|
||||
cwd: string
|
||||
home: string
|
||||
state: string
|
||||
worktree: string
|
||||
}>
|
||||
|
||||
export type TuiTerminalEnvironment = Readonly<{
|
||||
platform: string
|
||||
multiplexer?: "tmux" | "screen"
|
||||
displayServer?: "wayland" | "x11"
|
||||
}>
|
||||
|
||||
export type TuiStartup = Readonly<{
|
||||
initialRoute?: unknown
|
||||
skipInitialLoading: boolean
|
||||
}>
|
||||
|
||||
const PathsContext = createContext<TuiPaths>()
|
||||
const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
|
||||
const StartupContext = createContext<TuiStartup>()
|
||||
|
||||
function provider<T>(context: ReturnType<typeof createContext<T>>, value: T, children: () => JSX.Element) {
|
||||
return createComponent(context.Provider, {
|
||||
value: Object.freeze({ ...value }),
|
||||
get children() {
|
||||
return children()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element }) {
|
||||
return provider(PathsContext, props.value, () => props.children)
|
||||
}
|
||||
|
||||
export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) {
|
||||
return provider(TerminalEnvironmentContext, props.value, () => props.children)
|
||||
}
|
||||
|
||||
export function TuiStartupProvider(props: { value: TuiStartup; children: JSX.Element }) {
|
||||
return provider(StartupContext, props.value, () => props.children)
|
||||
}
|
||||
|
||||
function required<T>(context: ReturnType<typeof createContext<T>>, name: string) {
|
||||
const value = useContext(context)
|
||||
if (!value) throw new Error(`${name} is missing`)
|
||||
return value
|
||||
}
|
||||
|
||||
export function useTuiPaths() {
|
||||
return required(PathsContext, "TuiPathsProvider")
|
||||
}
|
||||
|
||||
export function useTuiTerminalEnvironment() {
|
||||
return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider")
|
||||
}
|
||||
|
||||
export function useTuiStartup() {
|
||||
return required(StartupContext, "TuiStartupProvider")
|
||||
}
|
||||
151
packages/tui/src/context/sdk.tsx
Normal file
151
packages/tui/src/context/sdk.tsx
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
640
packages/tui/src/context/sync.tsx
Normal file
640
packages/tui/src/context/sync.tsx
Normal 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
|
||||
},
|
||||
})
|
||||
332
packages/tui/src/context/theme.tsx
Normal file
332
packages/tui/src/context/theme.tsx
Normal 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
|
||||
})
|
||||
}
|
||||
67
packages/tui/src/context/thinking.ts
Normal file
67
packages/tui/src/context/thinking.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
286
packages/tui/src/editor-zed.ts
Normal file
286
packages/tui/src/editor-zed.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { statSync } from "node:fs"
|
||||
import { readFile as readFileAsync } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { EditorSelection } from "./context/editor"
|
||||
|
||||
const ZedEditorRowSchema = Schema.Struct({
|
||||
item_kind: Schema.String,
|
||||
editor_id: Schema.NullOr(Schema.Number),
|
||||
workspace_id: Schema.Number,
|
||||
workspace_paths: Schema.NullOr(Schema.String),
|
||||
timestamp: Schema.String,
|
||||
buffer_path: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
const ZedSelectionRowSchema = Schema.Struct({
|
||||
selection_start: Schema.NullOr(Schema.Number),
|
||||
selection_end: Schema.NullOr(Schema.Number),
|
||||
})
|
||||
|
||||
const ZedEditorContentsSchema = Schema.Struct({
|
||||
contents: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
|
||||
const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
|
||||
const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
|
||||
|
||||
const utf8 = new TextEncoder()
|
||||
|
||||
type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
|
||||
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
|
||||
|
||||
export type ZedSelectionResult =
|
||||
| { type: "selection"; selection: EditorSelection }
|
||||
| { type: "empty" }
|
||||
| { type: "unavailable" }
|
||||
|
||||
export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
|
||||
const active = queryZedActiveEditor(dbPath, cwd)
|
||||
if (active.type !== "row") return active
|
||||
|
||||
const row = active.row
|
||||
if (!row.buffer_path) return { type: "empty" }
|
||||
|
||||
const selections = queryZedEditorSelections(dbPath, row)
|
||||
if (selections.type !== "selections") return selections
|
||||
const byteRanges = selections.selections
|
||||
.flatMap((selection) => {
|
||||
if (selection.selection_start == null || selection.selection_end == null) return []
|
||||
return [
|
||||
{
|
||||
start: Math.min(selection.selection_start, selection.selection_end),
|
||||
end: Math.max(selection.selection_start, selection.selection_end),
|
||||
},
|
||||
]
|
||||
})
|
||||
.sort((left, right) => left.start - right.start || left.end - right.end)
|
||||
if (byteRanges.length === 0) return { type: "unavailable" }
|
||||
|
||||
const contents = queryZedEditorContents(dbPath, row)
|
||||
const text =
|
||||
contents.type === "contents" && contents.contents != null
|
||||
? contents.contents
|
||||
: await readFileAsync(row.buffer_path, "utf8").catch(() => undefined)
|
||||
if (text == null) return { type: "unavailable" }
|
||||
|
||||
const ranges = byteRanges.map((range) => {
|
||||
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
|
||||
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
|
||||
return {
|
||||
text: text.slice(startOffset, endOffset),
|
||||
selection: offsetsToSelection(text, startOffset, endOffset),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: "selection",
|
||||
selection: {
|
||||
filePath: row.buffer_path,
|
||||
source: "zed",
|
||||
ranges,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const raw = db
|
||||
.query(
|
||||
`select
|
||||
i.kind as item_kind,
|
||||
e.item_id as editor_id,
|
||||
i.workspace_id as workspace_id,
|
||||
w.paths as workspace_paths,
|
||||
w.timestamp as timestamp,
|
||||
e.buffer_path as buffer_path
|
||||
from items i
|
||||
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
|
||||
join workspaces w on w.workspace_id = i.workspace_id
|
||||
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
|
||||
where i.active = 1 and p.active = 1
|
||||
order by w.timestamp desc`,
|
||||
)
|
||||
.all()
|
||||
|
||||
const rows = raw.flatMap((row) => {
|
||||
const parsed = decodeZedEditorRow(row)
|
||||
return Option.isSome(parsed) ? [parsed.value] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
|
||||
|
||||
const row = rows
|
||||
.map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
|
||||
if (!row) return { type: "empty" as const }
|
||||
if (row.item_kind !== "Editor") return { type: "unavailable" as const }
|
||||
if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
|
||||
return { type: "row" as const, row }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const raw = db
|
||||
.query(
|
||||
`select
|
||||
start as selection_start,
|
||||
end as selection_end
|
||||
from editor_selections
|
||||
where editor_id = $editorID and workspace_id = $workspaceID`,
|
||||
)
|
||||
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
|
||||
|
||||
const selections = raw.flatMap((selection) => {
|
||||
const parsed = decodeZedSelectionRow(selection)
|
||||
return Option.isSome(parsed) ? [parsed.value] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
|
||||
return { type: "selections" as const, selections }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const parsed = decodeZedEditorContents(
|
||||
db
|
||||
.query(
|
||||
`select contents
|
||||
from editors
|
||||
where item_id = $editorID and workspace_id = $workspaceID`,
|
||||
)
|
||||
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
||||
)
|
||||
if (Option.isNone(parsed)) return { type: "unavailable" as const }
|
||||
return { type: "contents" as const, contents: parsed.value.contents }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
|
||||
return row.item_kind === "Editor" && row.editor_id != null
|
||||
}
|
||||
|
||||
export function resolveZedDbPath() {
|
||||
const candidates = [
|
||||
process.env.OPENCODE_ZED_DB,
|
||||
path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
|
||||
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
|
||||
].filter((item): item is string => Boolean(item))
|
||||
|
||||
return candidates.find((item) => isFile(item))
|
||||
}
|
||||
|
||||
export function isZedTerminal() {
|
||||
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
|
||||
}
|
||||
|
||||
function isFile(item: string) {
|
||||
try {
|
||||
return statSync(item).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
|
||||
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
|
||||
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
|
||||
return score
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function zedWorkspacePaths(value: string | null) {
|
||||
if (!value) return []
|
||||
const parsed = parseJson(value)
|
||||
if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
|
||||
return value.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export function offsetToPosition(text: string, offset: number) {
|
||||
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
|
||||
return offsetsToSelection(text, stringOffset, stringOffset).start
|
||||
}
|
||||
|
||||
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
|
||||
if (byteOffset <= 0) return 0
|
||||
|
||||
let bytes = 0
|
||||
for (let index = 0; index < text.length; ) {
|
||||
const codePoint = text.codePointAt(index)
|
||||
if (codePoint === undefined) return text.length
|
||||
|
||||
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
|
||||
bytes += utf8.encode(text.slice(index, nextIndex)).length
|
||||
if (bytes >= byteOffset) return nextIndex
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
return text.length
|
||||
}
|
||||
|
||||
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
|
||||
const start = Math.max(0, Math.min(startOffset, text.length))
|
||||
const end = Math.max(0, Math.min(endOffset, text.length))
|
||||
let line = 1
|
||||
let lineStart = 0
|
||||
let startPosition = position(line, lineStart, start)
|
||||
let endPosition = position(line, lineStart, end)
|
||||
|
||||
for (let index = 0; index <= end; index++) {
|
||||
if (index === start) startPosition = position(line, lineStart, index)
|
||||
if (index === end) {
|
||||
endPosition = position(line, lineStart, index)
|
||||
break
|
||||
}
|
||||
if (text[index] === "\n") {
|
||||
line += 1
|
||||
lineStart = index + 1
|
||||
}
|
||||
}
|
||||
|
||||
return { start: startPosition, end: endPosition }
|
||||
}
|
||||
|
||||
function position(line: number, lineStart: number, offset: number) {
|
||||
return {
|
||||
line,
|
||||
character: offset - lineStart + 1,
|
||||
}
|
||||
}
|
||||
|
||||
function pathContains(parent: string, child: string) {
|
||||
const relative = path.relative(path.resolve(parent), path.resolve(child))
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function parseJson(value: string) {
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
101
packages/tui/src/editor.ts
Normal file
101
packages/tui/src/editor.ts
Normal 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),
|
||||
}
|
||||
36
packages/tui/src/feature-plugins/builtins.ts
Normal file
36
packages/tui/src/feature-plugins/builtins.ts
Normal 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,
|
||||
]
|
||||
}
|
||||
100
packages/tui/src/feature-plugins/home/footer.tsx
Normal file
100
packages/tui/src/feature-plugins/home/footer.tsx
Normal 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
|
||||
287
packages/tui/src/feature-plugins/home/tips-view.tsx
Normal file
287
packages/tui/src/feature-plugins/home/tips-view.tsx
Normal 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")
|
||||
59
packages/tui/src/feature-plugins/home/tips.tsx
Normal file
59
packages/tui/src/feature-plugins/home/tips.tsx
Normal 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
|
||||
65
packages/tui/src/feature-plugins/sidebar/context.tsx
Normal file
65
packages/tui/src/feature-plugins/sidebar/context.tsx
Normal 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
|
||||
70
packages/tui/src/feature-plugins/sidebar/files.tsx
Normal file
70
packages/tui/src/feature-plugins/sidebar/files.tsx
Normal 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
|
||||
98
packages/tui/src/feature-plugins/sidebar/footer.tsx
Normal file
98
packages/tui/src/feature-plugins/sidebar/footer.tsx
Normal 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
|
||||
65
packages/tui/src/feature-plugins/sidebar/lsp.tsx
Normal file
65
packages/tui/src/feature-plugins/sidebar/lsp.tsx
Normal 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
|
||||
97
packages/tui/src/feature-plugins/sidebar/mcp.tsx
Normal file
97
packages/tui/src/feature-plugins/sidebar/mcp.tsx
Normal 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
|
||||
49
packages/tui/src/feature-plugins/sidebar/todo.tsx
Normal file
49
packages/tui/src/feature-plugins/sidebar/todo.tsx
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
103
packages/tui/src/feature-plugins/system/diff-viewer-ui.tsx
Normal file
103
packages/tui/src/feature-plugins/system/diff-viewer-ui.tsx
Normal 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 "├"
|
||||
}
|
||||
1061
packages/tui/src/feature-plugins/system/diff-viewer.tsx
Normal file
1061
packages/tui/src/feature-plugins/system/diff-viewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
94
packages/tui/src/feature-plugins/system/notifications.ts
Normal file
94
packages/tui/src/feature-plugins/system/notifications.ts
Normal 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
|
||||
269
packages/tui/src/feature-plugins/system/plugins.tsx
Normal file
269
packages/tui/src/feature-plugins/system/plugins.tsx
Normal 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
|
||||
608
packages/tui/src/feature-plugins/system/which-key.tsx
Normal file
608
packages/tui/src/feature-plugins/system/which-key.tsx
Normal 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
|
||||
1
packages/tui/src/index.tsx
Normal file
1
packages/tui/src/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { run, type TuiInput } from "./app"
|
||||
290
packages/tui/src/keymap.tsx
Normal file
290
packages/tui/src/keymap.tsx
Normal 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),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
11
packages/tui/src/logo.ts
Normal file
11
packages/tui/src/logo.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const logo = {
|
||||
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
|
||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const go = {
|
||||
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~,"
|
||||
386
packages/tui/src/parsers-config.ts
Normal file
386
packages/tui/src/parsers-config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
354
packages/tui/src/plugin/adapters.tsx
Normal file
354
packages/tui/src/plugin/adapters.tsx
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
52
packages/tui/src/plugin/api.ts
Normal file
52
packages/tui/src/plugin/api.ts
Normal 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 () => {}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
109
packages/tui/src/plugin/command-shim.ts
Normal file
109
packages/tui/src/plugin/command-shim.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
81
packages/tui/src/plugin/runtime.tsx
Normal file
81
packages/tui/src/plugin/runtime.tsx
Normal 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
|
||||
}
|
||||
65
packages/tui/src/plugin/slots.tsx
Normal file
65
packages/tui/src/plugin/slots.tsx
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
48
packages/tui/src/prompt/display.ts
Normal file
48
packages/tui/src/prompt/display.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
80
packages/tui/src/prompt/frecency.tsx
Normal file
80
packages/tui/src/prompt/frecency.tsx
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
111
packages/tui/src/prompt/history.tsx
Normal file
111
packages/tui/src/prompt/history.tsx
Normal 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
Reference in New Issue
Block a user