diff --git a/packages/app/public/apple-touch-icon.png b/packages/app/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..70fd01b0ea38ea8b3d0429a6438f5b8e85aba94a Binary files /dev/null and b/packages/app/public/apple-touch-icon.png differ diff --git a/packages/app/public/favicon.ico b/packages/app/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..34ca0b9c01b23ca30f64ed17bd0705bb08cca4b1 Binary files /dev/null and b/packages/app/public/favicon.ico differ diff --git a/packages/app/public/social-share-zen.png b/packages/app/public/social-share-zen.png new file mode 100644 index 0000000000000000000000000000000000000000..33e94144153e3c0549b94f238e70d6bad0fdc7a3 Binary files /dev/null and b/packages/app/public/social-share-zen.png differ diff --git a/packages/app/public/web-app-manifest-192x192.png b/packages/app/public/web-app-manifest-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..e637755490fb8b6f36252d6f740214c9203af27b Binary files /dev/null and b/packages/app/public/web-app-manifest-192x192.png differ diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts new file mode 100644 index 0000000000000000000000000000000000000000..8014ea1c439504d8ccace9381c627bde3bd5ef02 --- /dev/null +++ b/packages/app/src/components/command-palette.ts @@ -0,0 +1,281 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createMemo, onCleanup } from "solid-js" +import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" +import { useFile } from "@/context/file" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { useLayout, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" +import { useTabs } from "@/context/tabs" +import { displayName, projectForSession } from "@/pages/layout/helpers" +import { createSessionTabs } from "@/pages/session/helpers" +import { useSessionLayout } from "@/pages/session/session-layout" +import { normalizeSessionInfo } from "@/utils/session" + +export type CommandPaletteEntry = { + id: string + type: "command" | "file" | "session" + title: string + description?: string + keybind?: string + category: string + option?: CommandOption + path?: string + directory?: string + sessionID?: string + server?: ServerConnection.Key + project?: LocalProject + archived?: number + updated?: number +} + +const ENTRY_LIMIT = 5 +const COMMON_COMMAND_IDS = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", +] as const + +export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) { + const seen = new Set() + return items.filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return true + }) +} + +export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry { + return { + id: "file:" + path, + type: "file", + title: path, + category, + path, + } +} + +export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) { + const file = useFile() + const layout = useLayout() + const { tabs, view } = useSessionLayout() + + return (path: string) => { + const value = file.tab(path) + void tabs().open(value) + void file.load(path) + if (!view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.setTab("all") + onOpenFile?.(path) + tabs().setActive(value) + } +} + +export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) { + const command = useCommand() + const global = useGlobal() + const language = useLanguage() + const file = useFile() + const dialog = useDialog() + const serverSDK = useServerSDK()() + const serverCtx = global.ensureServerCtx(serverSDK.server) + const appTabs = useTabs() + const { tabs: sessionTabs } = useSessionLayout() + const openFile = createCommandPaletteFileOpener(props.onOpenFile) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const filesOnly = () => props.filesOnly?.() ?? false + + const allowedCommands = createMemo(() => { + if (filesOnly()) return [] + return commandPaletteOptions(command.options) + }) + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category)) + }) + const preferredCommandEntries = createMemo(() => { + const all = allowedCommands() + const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) + const picked = all.filter((option) => order.has(option.id)) + const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) + const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base + const category = language.t("palette.group.commands") + return sorted.map((option) => createCommandPaletteCommandEntry(option, category)) + }) + + const tabState = createSessionTabs({ + tabs: sessionTabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }) + const recentFileEntries = createMemo(() => { + const all = tabState.openedTabs() + const active = tabState.activeFileTab() + const order = active ? [active, ...all.filter((item) => item !== active)] : all + const seen = new Set() + const category = language.t("palette.group.files") + return order + .map((item) => file.pathFromTab(item)) + .filter((path): path is string => { + if (!path || seen.has(path)) return false + seen.add(path) + return true + }) + .slice(0, ENTRY_LIMIT) + .map((path) => createCommandPaletteFileEntry(path, category)) + }) + const rootFileEntries = createMemo(() => { + const category = language.t("palette.group.files") + return file.tree + .children("") + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort((a, b) => a.localeCompare(b)) + .slice(0, ENTRY_LIMIT) + .map((path) => createCommandPaletteFileEntry(path, category)) + }) + + const sessions = createServerSessionEntries({ + server: ServerConnection.key(serverSDK.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") { + if (!item.sessionID || !item.server) return + const directory = item.project?.worktree ?? item.directory + if (directory) { + serverCtx.projects.open(directory) + serverCtx.projects.touch(directory) + } + const tab = appTabs.addSessionTab({ + server: item.server, + sessionId: item.sessionID, + }) + appTabs.select(tab) + return + } + if (!item.path) return + openFile(item.path) + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return { + language, + file, + commandEntries, + preferredCommandEntries, + recentFileEntries, + rootFileEntries, + sessions, + highlight, + select, + close: () => dialog.close(), + } +} + +export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry { + return { + id: "command:" + option.id, + type: "command", + title: option.title, + description: option.description, + keybind: option.keybind, + category, + option, + } +} + +export function createServerSessionEntries(props: { + server: ServerConnection.Key + opened: () => LocalProject[] + stored: () => Project[] + load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> + untitled: () => string + category: () => string +}) { + let abort: AbortController | undefined + + onCleanup(() => abort?.abort()) + + return async (text: string): Promise => { + const search = text.trim() + if (!search) { + abort?.abort() + return [] + } + abort?.abort() + const current = new AbortController() + abort = current + await new Promise((resolve) => { + const timer = setTimeout(resolve, 100) + current.signal.addEventListener( + "abort", + () => { + clearTimeout(timer) + resolve() + }, + { once: true }, + ) + }) + if (current.signal.aborted) return [] + const opened = props.opened() + const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) + const stored = props.stored().map((project) => ({ ...project, expanded: false })) + const storedByID = new Map(stored.map((project) => [project.id, project] as const)) + return props + .load(search, current.signal) + .then((result) => + result.data + .map(normalizeSessionInfo) + .filter((session) => !session.time.archived) + .map((session) => { + const project = + projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) + return { + id: `session:${props.server}:${session.id}`, + type: "session" as const, + title: session.title || props.untitled(), + description: project ? displayName(project) : getFilename(session.directory), + category: props.category(), + directory: session.directory, + sessionID: session.id, + server: props.server, + project, + updated: session.time.updated, + } + }), + ) + .catch(() => [] as CommandPaletteEntry[]) + } +} diff --git a/packages/app/src/components/command-tooltip-keybind.test.ts b/packages/app/src/components/command-tooltip-keybind.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..63c7f32468e1c9506aab39caf9a756f5828d6449 --- /dev/null +++ b/packages/app/src/components/command-tooltip-keybind.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { newTabTooltipKeybind, reviewTooltipKeybind } from "./command-tooltip-keybind" + +describe("command tooltip keybinds", () => { + test("keeps localized review shortcut modifiers", () => { + const command = { + keybind: () => "Ctrl+Maj+R", + keybindParts: () => ["Ctrl", "Maj", "R"], + } + + expect(reviewTooltipKeybind(command, (key) => key)).toEqual(["Ctrl", "Maj", "R"]) + }) + + test("uses the configured new-tab shortcut", () => { + const command = { + keybind: () => "Alt+N", + keybindParts: () => ["Alt", "N"], + } + + expect(newTabTooltipKeybind(command, (key) => key)).toEqual(["Alt", "N"]) + }) +}) diff --git a/packages/app/src/components/command-tooltip-keybind.ts b/packages/app/src/components/command-tooltip-keybind.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6685b95a8f0122aca6f7393a21974abe1ed4ddb --- /dev/null +++ b/packages/app/src/components/command-tooltip-keybind.ts @@ -0,0 +1,11 @@ +type CommandKeybind = { + keybindParts: (id: string) => string[] +} + +export function reviewTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) { + return command.keybindParts("review.toggle") +} + +export function newTabTooltipKeybind(command: CommandKeybind, _translate?: (key: string) => string) { + return command.keybindParts("tab.new") +} diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9465374cbb063a56b3a2496d928eb8dc8f6fdbaa --- /dev/null +++ b/packages/app/src/components/debug-bar.tsx @@ -0,0 +1,587 @@ +import { useIsRouting, useLocation } from "@solidjs/router" +import { batch, createEffect, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" + +type Mem = Performance & { + memory?: { + usedJSHeapSize: number + jsHeapSizeLimit: number + } +} + +type Evt = PerformanceEntry & { + interactionId?: number + processingStart?: number +} + +type Shift = PerformanceEntry & { + hadRecentInput: boolean + value: number +} + +type Obs = PerformanceObserverInit & { + durationThreshold?: number +} + +const span = 5000 + +const ms = (n?: number, d = 0) => { + if (n === undefined || Number.isNaN(n)) return + return `${n.toFixed(d)}ms` +} + +const time = (n?: number) => { + if (n === undefined || Number.isNaN(n)) return + return `${Math.round(n)}` +} + +const mb = (n?: number) => { + if (n === undefined || Number.isNaN(n)) return + const v = n / 1024 / 1024 + return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB` +} + +const bad = (n: number | undefined, limit: number, low = false) => { + if (n === undefined || Number.isNaN(n)) return false + return low ? n < limit : n > limit +} + +const session = (path: string) => path.includes("/session") + +function Cell(props: { + bad?: boolean + dim?: boolean + inline?: boolean + label: string + tip: string + value: string + span?: 2 | 3 +}) { + const content = () => ( +
+
+
+ {props.label} +
+
+ {props.value} +
+
+
+ ) + + if (props.inline) { + return ( + + {content()} + + ) + } + + return ( + + {content()} + + ) +} + +function ToggleCell(props: { + active: boolean + inline?: boolean + label: string + onClick: () => void + tip: string + value: string +}) { + const content = () => ( + + ) + + if (props.inline) { + return ( + + {content()} + + ) + } + + return ( + + {content()} + + ) +} + +export function DebugBar(props: { inline?: boolean } = {}) { + const language = useLanguage() + const platform = usePlatform() + const location = useLocation() + const routing = useIsRouting() + const [state, setState] = createStore({ + cls: undefined as number | undefined, + delay: undefined as number | undefined, + fps: undefined as number | undefined, + gap: undefined as number | undefined, + focus: false, + heap: { + limit: undefined as number | undefined, + used: undefined as number | undefined, + }, + inp: undefined as number | undefined, + jank: undefined as number | undefined, + long: { + block: undefined as number | undefined, + count: undefined as number | undefined, + max: undefined as number | undefined, + }, + nav: { + dur: undefined as number | undefined, + pending: false, + }, + }) + + const na = () => language.t("debugBar.na").toUpperCase() + const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) + const heapv = () => { + const value = heap() + if (value === undefined) return na() + return `${Math.round(value * 100)}%` + } + const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) + const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) + const toggleFocus = async () => { + if (!platform.setForceFocus) return + const enabled = !state.focus + await platform.setForceFocus(enabled) + setState("focus", enabled) + } + + onCleanup(() => { + if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined) + }) + + let prev = "" + let start = 0 + let init = false + let one = 0 + let two = 0 + + createEffect(() => { + const busy = routing() + const next = `${location.pathname}${location.search}` + + if (!init) { + init = true + prev = next + return + } + + if (busy) { + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + one = 0 + two = 0 + if (start !== 0) return + start = performance.now() + if (session(prev)) setState("nav", { dur: undefined, pending: true }) + return + } + + if (start === 0) { + prev = next + return + } + + const at = start + const from = prev + start = 0 + prev = next + + if (!(session(from) || session(next))) return + + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + one = requestAnimationFrame(() => { + one = 0 + two = requestAnimationFrame(() => { + two = 0 + setState("nav", { dur: performance.now() - at, pending: false }) + }) + }) + }) + + onMount(() => { + const obs: PerformanceObserver[] = [] + const fps: Array<{ at: number; dur: number }> = [] + const long: Array<{ at: number; dur: number }> = [] + const seen = new Map() + let hasLong = false + let poll: number | undefined + let raf = 0 + let last = 0 + let snap = 0 + + const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => { + while (list[0] && at - list[0].at > span) list.shift() + } + + const syncFrame = (at: number) => { + trim(fps, span, at) + const total = fps.reduce((sum, entry) => sum + entry.dur, 0) + const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0) + const jank = fps.filter((entry) => entry.dur > 32).length + batch(() => { + setState("fps", total > 0 ? (fps.length * 1000) / total : undefined) + setState("gap", gap > 0 ? gap : undefined) + setState("jank", jank) + }) + } + + const syncLong = (at = performance.now()) => { + if (!hasLong) return + trim(long, span, at) + const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0) + const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0) + setState("long", { block, count: long.length, max }) + } + + const syncInp = (at = performance.now()) => { + for (const [key, entry] of seen) { + if (at - entry.at > span) seen.delete(key) + } + let delay = 0 + let inp = 0 + for (const entry of seen.values()) { + delay = Math.max(delay, entry.delay) + inp = Math.max(inp, entry.dur) + } + batch(() => { + setState("delay", delay > 0 ? delay : undefined) + setState("inp", inp > 0 ? inp : undefined) + }) + } + + const syncHeap = () => { + const mem = (performance as Mem).memory + if (!mem) return + setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize }) + } + + const reset = () => { + fps.length = 0 + long.length = 0 + seen.clear() + last = 0 + snap = 0 + batch(() => { + setState("fps", undefined) + setState("gap", undefined) + setState("jank", undefined) + setState("delay", undefined) + setState("inp", undefined) + if (hasLong) setState("long", { block: 0, count: 0, max: 0 }) + }) + } + + const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => { + if (typeof PerformanceObserver === "undefined") return false + if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false + const ob = new PerformanceObserver((list) => fn(list.getEntries())) + try { + ob.observe(init) + obs.push(ob) + return true + } catch { + ob.disconnect() + return false + } + } + + if ( + watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => { + const add = entries.reduce((sum, entry) => { + const item = entry as Shift + if (item.hadRecentInput) return sum + return sum + item.value + }, 0) + if (add === 0) return + setState("cls", (value) => (value ?? 0) + add) + }) + ) { + setState("cls", 0) + } + + if ( + watch("longtask", { buffered: true, type: "longtask" }, (entries) => { + const at = performance.now() + long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration }))) + syncLong(at) + }) + ) { + hasLong = true + setState("long", { block: 0, count: 0, max: 0 }) + } + + watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => { + for (const raw of entries) { + const entry = raw as Evt + if (entry.duration < 16) continue + const key = + entry.interactionId && entry.interactionId > 0 + ? entry.interactionId + : `${entry.name}:${Math.round(entry.startTime)}` + const prev = seen.get(key) + const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime) + seen.set(key, { + at: entry.startTime, + delay: Math.max(prev?.delay ?? 0, delay), + dur: Math.max(prev?.dur ?? 0, entry.duration), + }) + if (seen.size <= 200) continue + const first = seen.keys().next().value + if (first !== undefined) seen.delete(first) + } + syncInp() + }) + + const loop = (at: number) => { + if (document.visibilityState !== "visible") { + raf = 0 + return + } + + if (last === 0) { + last = at + raf = requestAnimationFrame(loop) + return + } + + fps.push({ at, dur: at - last }) + last = at + + if (at - snap >= 250) { + snap = at + syncFrame(at) + } + + raf = requestAnimationFrame(loop) + } + + const stop = () => { + if (raf !== 0) cancelAnimationFrame(raf) + raf = 0 + if (poll === undefined) return + clearInterval(poll) + poll = undefined + } + + const start = () => { + if (document.visibilityState !== "visible") return + if (poll === undefined) { + poll = window.setInterval(() => { + syncLong() + syncInp() + syncHeap() + }, 1000) + } + if (raf !== 0) return + raf = requestAnimationFrame(loop) + } + + const vis = () => { + if (document.visibilityState !== "visible") { + stop() + return + } + reset() + start() + } + + syncHeap() + start() + makeEventListener(document, "visibilitychange", vis) + + onCleanup(() => { + if (one !== 0) cancelAnimationFrame(one) + if (two !== 0) cancelAnimationFrame(two) + stop() + for (const ob of obs) ob.disconnect() + }) + }) + + return ( + + ) +} diff --git a/packages/app/src/components/dialog-command-palette-v2.css b/packages/app/src/components/dialog-command-palette-v2.css new file mode 100644 index 0000000000000000000000000000000000000000..a4019928aa1ed305d0873b91d3fdab033ac06d6f --- /dev/null +++ b/packages/app/src/components/dialog-command-palette-v2.css @@ -0,0 +1,220 @@ +.command-palette-v2 { + overflow: hidden; +} + +/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the + top stays put while the content-driven height grows and shrinks. */ +[data-component="dialog-v2"]:has(.command-palette-v2) { + align-items: flex-start; +} + +[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] { + width: min(calc(100vw - 24px), 640px); + height: auto; + min-height: 280px; + max-height: min(calc(100vh - 96px), 480px); + margin-top: max(48px, calc((100vh - 480px) / 2)); + border-radius: 12px; + background: var(--v2-background-bg-base); + box-shadow: var(--v2-elevation-floating); +} + +.command-palette-v2-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 0; + padding: 0; +} + +.command-palette-v2-search { + flex-shrink: 0; + padding: 6px; +} + +.command-palette-v2-search [data-component="text-input-v2"] { + width: 100%; + height: 36px; + border-radius: 6px; + outline: 0; + background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent); + box-shadow: none; + transition: + background-color 120ms ease-in-out, + box-shadow 120ms ease-in-out; +} + +.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]), +.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { + background: var(--v2-background-bg-layer-02); + box-shadow: none; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] { + border-radius: 6px; + background: transparent; + gap: 8px; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] { + padding-left: 12px; +} + +.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] { + border-radius: 6px; + background: transparent; +} + +.command-palette-v2-scroll { + min-height: 0; + flex: 1; +} + +.command-palette-v2-results { + display: flex; + flex-direction: column; + gap: 16px; + padding: 6px 6px 8px; +} + +.command-palette-v2-group { + display: flex; + flex-direction: column; + gap: 1px; +} + +.command-palette-v2-group-title { + margin: 6px 0; + padding: 0 12px; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; + user-select: none; +} + +.command-palette-v2-row { + display: flex; + width: 100%; + height: 36px; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--v2-text-text-base); + text-align: left; + cursor: default; + scroll-margin: 6px 0; +} + +.command-palette-v2-row[data-active] { + background: var(--v2-overlay-simple-overlay-hover); +} + +.command-palette-v2-row:focus-visible { + background: var(--v2-overlay-simple-overlay-hover); + outline: none; +} + +.command-palette-v2-row-main { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; +} + +.command-palette-v2-row-icon { + flex-shrink: 0; + color: var(--v2-icon-icon-muted); +} + +.command-palette-v2-row-text { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; +} + +.command-palette-v2-title { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-base); + font-size: 13px; + font-weight: 530; + line-height: 16px; + letter-spacing: -0.04px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-description, +.command-palette-v2-meta { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-meta { + flex-shrink: 0; +} + +.command-palette-v2-file-path { + display: flex; + min-width: 0; + align-items: baseline; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; +} + +.command-palette-v2-file-dir { + min-width: 0; + overflow: hidden; + color: var(--v2-text-text-muted); + font-weight: 440; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-v2-file-name { + flex-shrink: 0; + color: var(--v2-text-text-base); + font-weight: 530; + white-space: nowrap; +} + +.command-palette-v2-state { + display: grid; + min-height: 120px; + place-items: center; + color: var(--v2-text-text-muted); + font-size: 13px; + font-weight: 440; + line-height: 16px; + letter-spacing: -0.04px; +} + +@media (max-width: 640px) { + .command-palette-v2-row-text { + flex-direction: column; + align-items: flex-start; + gap: 1px; + } + + .command-palette-v2-description { + max-width: 100%; + } +} diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e996fd0be77c476de9884a8bd02c4735149a9742 --- /dev/null +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -0,0 +1,342 @@ +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" +import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { useTabs } from "@/context/tabs" +import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" +import { getRelativeTime } from "@/utils/time" +import { + createCommandPaletteCommandEntry, + createCommandPaletteFileEntry, + createCommandPaletteModel, + createServerSessionEntries, + uniqueCommandPaletteEntries, + type CommandPaletteEntry, +} from "./command-palette" +import "./dialog-command-palette-v2.css" + +function groups(entries: CommandPaletteEntry[]) { + const map = new Map() + for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry]) + return Array.from(map.entries()).map(([category, entries]) => ({ category, entries })) +} + +function matchesEntry(entry: CommandPaletteEntry, query: string) { + const value = query.toLowerCase() + return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value)) +} + +export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) { + const palette = createCommandPaletteModel(props) + const loadItems = async (text: string) => { + const q = text.trim() + if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] + + const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))]) + const category = palette.language.t("palette.group.files") + return [ + ...palette.commandEntries().filter((entry) => matchesEntry(entry, q)), + ...nextSessions, + ...files.map((path) => createCommandPaletteFileEntry(path, category)), + ] + } + + return ( + + ) +} + +export function DialogHomeCommandPaletteV2(props: { + server: ServerConnection.Any + onSelectSession: (entry: CommandPaletteEntry) => void +}) { + const command = useCommand() + const dialog = useDialog() + const global = useGlobal() + const language = useLanguage() + const serverCtx = global.ensureServerCtx(props.server) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const commandEntries = createMemo(() => { + const category = language.t("palette.group.commands") + return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category)) + }) + const sessions = createServerSessionEntries({ + server: ServerConnection.key(props.server), + opened: serverCtx.projects.list, + stored: () => serverCtx.sync.data.project, + load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + untitled: () => language.t("command.session.new"), + category: () => language.t("command.category.session"), + }) + + const highlight = (item: CommandPaletteEntry | undefined) => { + state.cleanup?.() + state.cleanup = undefined + if (item?.type !== "command") return + state.cleanup = item.option?.onHighlight?.() + } + const select = (item: CommandPaletteEntry | undefined) => { + if (!item) return + state.committed = true + state.cleanup = undefined + dialog.close() + if (item.type === "command") { + item.option?.onSelect?.("palette") + return + } + if (item.type === "session") props.onSelectSession(item) + } + const loadItems = async (text: string) => { + const query = text.trim() + if (!query) return commandEntries().slice(0, 5) + return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))] + } + + onCleanup(() => { + if (state.committed) return + state.cleanup?.() + }) + + return ( + dialog.close()} + /> + ) +} + +function CommandPaletteView(props: { + placeholder: string + loadItems: (text: string) => CommandPaletteEntry[] | Promise + highlight: (item: CommandPaletteEntry | undefined) => void + select: (item: CommandPaletteEntry | undefined) => void + close: () => void +}) { + const language = useLanguage() + const tabs = useTabs() + const [query, setQuery] = createSignal("") + const [active, setActive] = createSignal(0) + + const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] }) + // Render stale results while a new query loads to avoid flashing "Loading" per keystroke. + const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? [])) + const groupedEntries = createMemo(() => groups(visibleEntries())) + const activeEntry = createMemo(() => visibleEntries()[active()]) + const openSessions = createMemo( + () => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))), + ) + + createEffect(() => { + query() + visibleEntries() + setActive(0) + }) + + createEffect(() => { + props.highlight(activeEntry()) + }) + + let resultsRef: HTMLDivElement | undefined + + const move = (delta: -1 | 1) => { + const count = visibleEntries().length + if (count === 0) return + setActive((index) => (index + delta + count) % count) + requestAnimationFrame(() => { + resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }) + }) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault() + move(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + move(-1) + return + } + if (event.key === "Enter") { + event.preventDefault() + props.select(activeEntry()) + return + } + if (event.key === "Escape") { + event.preventDefault() + props.close() + } + } + + return ( + + + + (resultsRef = el)}> +
+ 0} + fallback={ +
+ {entries.loading ? language.t("common.loading") : language.t("palette.empty")} +
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+ + {(item) => ( + setActive(visibleEntries().findIndex((entry) => entry.id === item.id))} + onSelect={() => props.select(item)} + /> + )} + +
+ )} +
+
+
+
+
+
+ ) +} + +function PaletteRow(props: { + item: CommandPaletteEntry + active: boolean + language: ReturnType + sessionOpen: boolean + onActive: () => void + onSelect: () => void +}) { + const session = () => + props.item.server && props.item.directory && props.item.sessionID + ? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID } + : undefined + + return ( + + ) +} diff --git a/packages/app/src/components/dialog-connect-provider.stories.tsx b/packages/app/src/components/dialog-connect-provider.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3aec6c9de332a17002826bb14fe830b9a3f690f1 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider.stories.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { mockProviderAuth } from "@/context/server-sync" +import { onCleanup, onMount } from "solid-js" +import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" + +function ConnectProviderDialogStory() { + const dialog = useDialog() + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +function ProviderConnectionDialogStory(props) { + onCleanup(mockProviderAuth(props.provider, props.methods)) + const dialog = useDialog() + const controller = useProviderConnectController() + controller.select(props.provider) + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +function renderConnection(provider, methods) { + return () => ( + + + + ) +} + +export default { + title: "App/Dialogs/Connect Provider", + id: "app-dialog-connect-provider", +} + +export const V2 = { + render: () => ( + + + + ), +} + +export const ApiKey = { + render: renderConnection("openrouter", [{ type: "api", label: "API key" }]), +} + +export const OpenCodeZen = { + render: renderConnection("opencode", [{ type: "api", label: "API key" }]), +} + +export const LoginMethods = { + render: renderConnection("openai", [ + { type: "oauth", label: "ChatGPT Pro/Plus (browser)" }, + { type: "oauth", label: "ChatGPT Pro/Plus (headless)" }, + { type: "api", label: "API key" }, + ]), +} diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1081310e5f2172a56f6ae20ceeb94ddb0f12d1a5 --- /dev/null +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -0,0 +1,1191 @@ +import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { List, type ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Tag } from "@opencode-ai/ui/tag" +import { TextField } from "@opencode-ai/ui/text-field" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { showToast } from "@/utils/toast" +import { + type Accessor, + type Component, + createEffect, + createMemo, + createResource, + createUniqueId, + For, + Match, + onCleanup, + onMount, + Show, + Switch, +} from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useParams } from "@solidjs/router" +import { ExternalLink } from "@/components/external-link" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { CustomProviderForm } from "./dialog-custom-provider" +import { decode64 } from "@/utils/base64" + +const CUSTOM_ID = "_custom" +type ConnectMethod = Extract + +export function useProviderConnectController(options: { onBack?: () => void } = {}) { + const [store, setStore] = createStore({ selected: undefined as string | undefined }) + const reset = () => setStore("selected", undefined) + + return { + selected: () => store.selected, + select: (provider?: string) => setStore("selected", provider), + back: options.onBack ?? reset, + } +} + +export const DialogConnectProvider: Component<{ + directory?: Accessor + controller?: ReturnType +}> = (props) => { + const fallback = useProviderConnectController() + const controller = props.controller ?? fallback + const language = useLanguage() + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns + const reset = controller.back + const back = { current: reset } + let focusHost: HTMLDivElement | undefined + const holdFocus = () => focusHost?.focus({ preventScroll: true }) + const select = (provider?: string) => { + back.current = reset + controller.select(provider) + } + + function Content() { + return ( + + + + + + {(provider) => ( + (back.current = handler)} + /> + )} + + + + + + ) + } + + return ( + + back.current()} + aria-label={language.t("common.goBack")} + /> + + } + > + + + } + > + + + {language.t("command.provider.connect")}} + > + + + + +
+ +
+
+
+ + ) +} + +function ProviderPicker(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const settings = useSettings() + if (settings.general.newLayoutDesigns()) + return + const providers = useProviders(() => props.directory?.()) + const language = useLanguage() + const popularGroup = () => language.t("dialog.provider.group.popular") + const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline") + return undefined + } + + return ( + x?.id} + items={() => { + language.locale() + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()] + }} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} + sortBy={(a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + const popular = popularGroup() + if (a.category === popular && b.category !== popular) return -1 + if (b.category === popular && a.category !== popular) return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + props.onSelect(x.id) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("settings.providers.tag.custom")} + + + {language.t("dialog.provider.tag.recommended")} + + {(value) =>
{value()}
}
+ + {language.t("dialog.provider.tag.recommended")} + +
+ )} +
+ ) +} + +function ProviderPickerV2(props: { + directory?: Accessor + onSelect: (provider: string) => void + onPrepare?: () => void +}) { + const providers = useProviders(() => props.directory?.()) + const language = useLanguage() + const [store, setStore] = createStore({ + filter: "", + active: undefined as string | undefined, + connecting: undefined as string | undefined, + }) + const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"] + const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") }) + const all = createMemo(() => { + language.locale() + const query = store.filter.trim().toLowerCase() + const values = [custom(), ...providers.all().values()] + if (!query) return values + return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query)) + }) + const popular = createMemo(() => + all() + .filter((provider) => featured.includes(provider.id)) + .sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)), + ) + const other = createMemo(() => + all() + .filter((provider) => !featured.includes(provider.id)) + .sort((a, b) => { + if (a.id === CUSTOM_ID) return -1 + if (b.id === CUSTOM_ID) return 1 + return a.name.localeCompare(b.name) + }), + ) + const rows = createMemo(() => [...popular(), ...other()]) + let picker: HTMLDivElement | undefined + let search: HTMLInputElement | undefined + + onMount(() => search?.focus({ preventScroll: true })) + + const connect = (provider: string) => { + props.onPrepare?.() + props.onSelect(provider) + } + + const move = (event: KeyboardEvent, direction: number) => { + const items = rows() + if (items.length === 0) return + const index = items.findIndex((provider) => provider.id === store.active) + const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length + setStore("active", items[next].id) + picker + ?.querySelector(`[data-provider-id="${CSS.escape(items[next].id)}"]`) + ?.focus({ preventScroll: true }) + event.preventDefault() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") return move(event, 1) + if (event.key === "ArrowUp") return move(event, -1) + if (event.key !== "Enter" || !store.active) return + connect(store.active) + event.preventDefault() + } + + return ( +
+
+ } + placeholder={language.t("dialog.provider.search.placeholder")} + value={store.filter} + onInput={(event) => { + setStore({ filter: event.currentTarget.value, active: undefined }) + }} + /> +
+
+
+ + {(group) => ( + 0}> +
+
+ {group.title} +
+ + {(provider) => ( + + )} + +
+
+ )} +
+ +
+ {language.t("dialog.provider.empty")} +
+
+
+
+
+
+ ) +} + +function ProviderConnection(props: { + provider: string + directory?: Accessor + onBack: () => void + setBack: (handler: () => void) => void +}) { + const dialog = useDialog() + const serverSync = useServerSync() + const serverSDK = useServerSDK() + const params = useParams() + const language = useLanguage() + const platform = usePlatform() + const settings = useSettings() + const newLayout = settings.general.newLayoutDesigns + const providers = useProviders(() => props.directory?.()) + const directory = () => props.directory?.() ?? decode64(params.dir) + const location = () => { + const value = directory() + return value ? { directory: value } : undefined + } + + const alive = { value: true } + const timer = { current: undefined as ReturnType | undefined } + + onCleanup(() => { + alive.value = false + if (timer.current === undefined) return + clearTimeout(timer.current) + timer.current = undefined + }) + + const provider = createMemo( + () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, + ) + const fallback = createMemo(() => [ + { + type: "key" as const, + label: language.t("provider.connect.method.apiKey"), + }, + ]) + const [integration] = createResource( + () => ({ provider: props.provider, directory: directory() }), + (input) => + serverSDK() + .api.integration.get({ + integrationID: input.provider, + location: input.directory ? { directory: input.directory } : undefined, + }) + .then((result) => result.data), + ) + const loading = createMemo(() => integration.loading) + const methods = createMemo(() => { + const values = integration.latest?.methods.filter( + (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", + ) + return values?.length ? values : fallback() + }) + const [store, setStore] = createStore({ + methodIndex: undefined as undefined | number, + authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], + promptInputs: undefined as undefined | Record, + state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", + error: undefined as string | undefined, + }) + + type Action = + | { type: "method.select"; index: number } + | { type: "method.reset" } + | { type: "auth.prompt" } + | { type: "auth.inputs"; inputs: Record } + | { type: "auth.pending" } + | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } + | { type: "auth.error"; error: string } + + function dispatch(action: Action) { + setStore( + produce((draft) => { + if (action.type === "method.select") { + draft.methodIndex = action.index + draft.authorization = undefined + draft.promptInputs = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "method.reset") { + draft.methodIndex = undefined + draft.authorization = undefined + draft.promptInputs = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "auth.prompt") { + draft.state = "prompt" + draft.error = undefined + return + } + if (action.type === "auth.inputs") { + draft.promptInputs = action.inputs + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "auth.pending") { + draft.state = "pending" + draft.error = undefined + return + } + if (action.type === "auth.complete") { + draft.state = "complete" + draft.authorization = action.authorization + draft.error = undefined + return + } + draft.state = "error" + draft.error = action.error + }), + ) + } + + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) + + const methodLabel = (value?: { type?: string; label?: string }) => { + if (!value) return "" + if (value.type === "key") return language.t("provider.connect.method.apiKey") + return value.label ?? "" + } + + const methodDetails = (value?: { type?: string; label?: string }) => { + const label = methodLabel(value) + const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i) + const hint = suffix?.[1] + return { + label: suffix ? label.slice(0, -suffix[0].length) : label, + hint: + hint?.toLowerCase() === "headless" + ? language.t("provider.connect.method.headless") + : hint?.toLowerCase() === "browser" || (!hint && value?.type === "key") + ? language.t("provider.connect.method.browser") + : undefined, + } + } + + function formatError(value: unknown, fallback: string): string { + if (value && typeof value === "object" && "data" in value) { + const data = (value as { data?: { message?: unknown } }).data + if (typeof data?.message === "string" && data.message) return data.message + } + if (value && typeof value === "object" && "error" in value) { + const nested = formatError((value as { error?: unknown }).error, "") + if (nested) return nested + } + if (value && typeof value === "object" && "message" in value) { + const message = (value as { message?: unknown }).message + if (typeof message === "string" && message) return message + } + if (value instanceof Error && value.message) return value.message + if (typeof value === "string" && value) return value + return fallback + } + + async function selectMethod(index: number, inputs?: Record) { + if (timer.current !== undefined) { + clearTimeout(timer.current) + timer.current = undefined + } + + const method = methods()[index] + dispatch({ type: "method.select", index }) + + if (method.type === "oauth") { + if (method.prompts?.length && !inputs) { + dispatch({ type: "auth.prompt" }) + return + } + dispatch({ type: "auth.pending" }) + await serverSDK() + .api.integration.oauth.connect({ + integrationID: props.provider, + methodID: method.id, + inputs: inputs ?? {}, + location: location(), + }) + .then((x) => { + if (!alive.value) return + if (props.provider === "opencode" && platform.platform === "desktop") { + const url = new URL(x.data.url) + url.searchParams.set("client_id", "opencode-desktop") + x.data.url = url.href + } + dispatch({ type: "auth.complete", authorization: x.data }) + }) + .catch((e) => { + if (!alive.value) return + dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) + }) + } + } + + function AuthPromptsView() { + const [formStore, setFormStore] = createStore({ + value: {} as Record, + index: 0, + }) + + const prompts = createMemo(() => { + const value = method() + return value?.type === "oauth" ? (value.prompts ?? []) : [] + }) + const matches = (prompt: NonNullable[number]>, value: Record) => { + if (!prompt.when) return true + const actual = value[prompt.when.key] + if (actual === undefined) return false + return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value + } + const current = createMemo(() => { + const all = prompts() + const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value)) + if (index === -1) return + return { + index, + prompt: all[index], + } + }) + const valid = createMemo(() => { + const item = current() + if (!item || item.prompt.type !== "text") return false + const value = formStore.value[item.prompt.key] ?? "" + return value.trim().length > 0 + }) + + async function next(index: number, value: Record) { + if (store.methodIndex === undefined) return + const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value)) + if (next !== -1) { + setFormStore("index", next) + return + } + await selectMethod(store.methodIndex, value) + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + const item = current() + if (!item || item.prompt.type !== "text") return + if (!valid()) return + await next(item.index, formStore.value) + } + + const item = () => current() + const text = createMemo(() => { + const prompt = item()?.prompt + if (!prompt || prompt.type !== "text") return + return prompt + }) + const select = createMemo(() => { + const prompt = item()?.prompt + if (!prompt || prompt.type !== "select") return + return prompt + }) + + return ( +
+ + + { + const prompt = text() + if (!prompt) return + setFormStore("value", prompt.key, value) + }} + /> + + + +
+
{select()?.message}
+
+ x.value} + current={select()?.options.find((x) => x.value === formStore.value[select()!.key])} + onSelect={(value) => { + if (!value) return + const prompt = select() + if (!prompt) return + const nextValue = { + ...formStore.value, + [prompt.key]: value.value, + } + setFormStore("value", prompt.key, value.value) + void next(item()!.index, nextValue) + }} + > + {(option) => ( +
+
+ + {option.label} + {option.hint} +
+ )} + +
+
+ + + + ) + } + + let listRef: ListRef | undefined + function handleKey(e: KeyboardEvent) { + if (e.key === "Enter" && e.target instanceof HTMLInputElement) { + return + } + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + let auto = false + createEffect(() => { + if (auto) return + if (loading()) return + if (methods().length === 1) { + auto = true + void selectMethod(0) + } + }) + + async function complete() { + await serverSync() + .refreshProviders() + .catch(() => undefined) + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: provider().name }), + description: language.t("provider.connect.toast.connected.description", { provider: provider().name }), + }) + } + + function goBack() { + if (methods().length > 1 && store.methodIndex !== undefined) { + dispatch({ type: "method.reset" }) + return + } + props.onBack() + } + + props.setBack(goBack) + + function MethodSelection() { + if (newLayout()) + return ( +
+
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ + {(item, index) => { + const details = () => methodDetails(item) + return ( + + ) + }} + +
+
+ ) + + return ( + <> +
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ { + listRef = ref + }} + items={methods} + key={(m) => m?.label ?? m?.type} + onSelect={async (selected, index) => { + if (!selected) return + void selectMethod(index) + }} + > + {(i) => ( +
+
+ + {methodLabel(i)} +
+ )} + +
+ + ) + } + + function ApiAuthView() { + let apiKey: HTMLInputElement | undefined + const errorID = createUniqueId() + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (!newLayout()) return + apiKey?.focus({ preventScroll: true }) + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", language.t("provider.connect.apiKey.required")) + return + } + + setFormStore("error", undefined) + await serverSDK().api.integration.connect.key({ + integrationID: props.provider, + location: location(), + key: apiKey, + }) + await complete() + } + + if (newLayout()) + return ( +
+ +
+
{language.t("provider.connect.opencodeZen.line1")}
+
{language.t("provider.connect.opencodeZen.line2")}
+
+ {language.t("provider.connect.opencodeZen.visit.prefix")} + + {language.t("provider.connect.opencodeZen.visit.link")} + + {language.t("provider.connect.opencodeZen.visit.suffix")} +
+
+
+
+ + + {(error) => ( + + )} + + + {language.t("common.continue")} + +
+
+ ) + + return ( +
+ + +
+
{language.t("provider.connect.opencodeZen.line1")}
+
{language.t("provider.connect.opencodeZen.line2")}
+
+ {language.t("provider.connect.opencodeZen.visit.prefix")} + + {language.t("provider.connect.opencodeZen.visit.link")} + + {language.t("provider.connect.opencodeZen.visit.suffix")} +
+
+
+ +
+ {language.t("provider.connect.apiKey.description", { provider: provider().name })} +
+
+
+
+ setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + + +
+ ) + } + + function OAuthCodeView() { + let codeInput: HTMLInputElement | undefined + const errorID = createUniqueId() + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (!newLayout()) return + codeInput?.focus({ preventScroll: true }) + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", language.t("provider.connect.oauth.code.required")) + return + } + + setFormStore("error", undefined) + const result = await serverSDK() + .api.integration.oauth.complete({ + integrationID: props.provider, + attemptID: store.authorization!.attemptID, + location: location(), + code, + }) + .then(() => ({ ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + if (result.ok) { + await complete() + return + } + setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) + } + + if (newLayout()) + return ( +
+
+ {language.t("provider.connect.oauth.code.visit.prefix")} + + {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} +
+
+ + + {(error) => ( + + )} + + + {language.t("common.continue")} + +
+
+ ) + + return ( +
+
+ {language.t("provider.connect.oauth.code.visit.prefix")} + + {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} +
+
+ setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + + +
+ ) + } + + function OAuthAutoView() { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions.split(":").pop()?.trim() + } + return instructions + }) + + onMount(() => { + const poll = async () => { + const authorization = store.authorization + if (!authorization || !alive.value) return + const result = await serverSDK() + .api.integration.oauth.status({ + integrationID: props.provider, + attemptID: authorization.attemptID, + location: location(), + }) + .then((value) => ({ ok: true as const, status: value.data })) + .catch((error) => ({ ok: false as const, error })) + if (!alive.value) return + if (!result.ok) { + dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) + return + } + if (result.status.status === "complete") { + await complete() + return + } + if (result.status.status === "failed") { + dispatch({ type: "auth.error", error: result.status.message }) + return + } + if (result.status.status === "expired") { + dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) + return + } + timer.current = setTimeout(poll, 1_000) + } + void poll() + }) + + return ( +
+
+ {language.t("provider.connect.oauth.auto.visit.prefix")} + + {language.t("provider.connect.oauth.auto.visit.link")} + + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} +
+ +
+ + {language.t("provider.connect.status.waiting")} +
+
+ ) + } + + return ( +
+
+ +
+ + + {language.t("provider.connect.title.anthropicProMax")} + + {language.t("provider.connect.title", { provider: provider().name })} + +
+
+
+
+ + +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
+ + + + +
+
+ + {language.t("provider.connect.status.failed", { error: store.error ?? "" })} +
+
+
+ + + + + + + + + + + + + +
+
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..e26dcb09710dc91a2a5441b276126f6fc3e3cc4d --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -0,0 +1,158 @@ +const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ +const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" + +type Translator = (key: string, vars?: Record) => string + +export type ModelErr = { + id?: string + name?: string +} + +export type HeaderErr = { + key?: string + value?: string +} + +export type ModelRow = { + row: string + id: string + name: string + err: ModelErr +} + +export type HeaderRow = { + row: string + key: string + value: string + err: HeaderErr +} + +export type FormState = { + providerID: string + name: string + baseURL: string + apiKey: string + models: ModelRow[] + headers: HeaderRow[] + err: { + providerID?: string + name?: string + baseURL?: string + } +} + +type ValidateArgs = { + form: FormState + t: Translator + disabledProviders: string[] + existingProviderIDs: Set +} + +export function validateCustomProvider(input: ValidateArgs) { + const providerID = input.form.providerID.trim() + const name = input.form.name.trim() + const baseURL = input.form.baseURL.trim() + const apiKey = input.form.apiKey.trim() + + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + + const idError = !providerID + ? input.t("provider.custom.error.providerID.required") + : !PROVIDER_ID.test(providerID) + ? input.t("provider.custom.error.providerID.format") + : undefined + + const nameError = !name ? input.t("provider.custom.error.name.required") : undefined + const urlError = !baseURL + ? input.t("provider.custom.error.baseURL.required") + : !/^https?:\/\//.test(baseURL) + ? input.t("provider.custom.error.baseURL.format") + : undefined + + const disabled = input.disabledProviders.includes(providerID) + const existsError = idError + ? undefined + : input.existingProviderIDs.has(providerID) && !disabled + ? input.t("provider.custom.error.providerID.exists") + : undefined + + const seenModels = new Set() + const models = input.form.models.map((m) => { + const id = m.id.trim() + const idError = !id + ? input.t("provider.custom.error.required") + : seenModels.has(id) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenModels.add(id) + return undefined + })() + const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined + return { id: idError, name: nameError } + }) + const modelsValid = models.every((m) => !m.id && !m.name) + const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) + + const seenHeaders = new Set() + const headers = input.form.headers.map((h) => { + const key = h.key.trim() + const value = h.value.trim() + + if (!key && !value) return {} + const keyError = !key + ? input.t("provider.custom.error.required") + : seenHeaders.has(key.toLowerCase()) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenHeaders.add(key.toLowerCase()) + return undefined + })() + const valueError = !value ? input.t("provider.custom.error.required") : undefined + return { key: keyError, value: valueError } + }) + const headersValid = headers.every((h) => !h.key && !h.value) + const headerConfig = Object.fromEntries( + input.form.headers + .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) + .filter((h) => !!h.key && !!h.value) + .map((h) => [h.key, h.value]), + ) + + const err = { + providerID: idError ?? existsError, + name: nameError, + baseURL: urlError, + } + + const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid + if (!ok) return { err, models, headers } + + return { + err, + models, + headers, + result: { + providerID, + name, + key, + config: { + npm: OPENAI_COMPATIBLE, + name, + ...(env ? { env: [env] } : {}), + options: { + baseURL, + ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}), + }, + models: modelConfig, + }, + }, + } +} + +let row = 0 + +const nextRow = () => `row-${row++}` + +export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} }) +export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} }) diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..07dd26ecd679f95cbd7f9dffbde53af9e6593be8 --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import { validateCustomProvider } from "./dialog-custom-provider-form" + +const t = (key: string) => key + +describe("validateCustomProvider", () => { + test("builds trimmed config payload", () => { + const result = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: " Custom Provider ", + baseURL: "https://api.example.com ", + apiKey: " {env: CUSTOM_PROVIDER_KEY} ", + models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }], + headers: [ + { row: "h0", key: " X-Test ", value: " enabled ", err: {} }, + { row: "h1", key: "", value: "", err: {} }, + ], + err: {}, + }, + t, + disabledProviders: [], + existingProviderIDs: new Set(), + }) + + expect(result.result).toEqual({ + providerID: "custom-provider", + name: "Custom Provider", + key: undefined, + config: { + npm: "@ai-sdk/openai-compatible", + name: "Custom Provider", + env: ["CUSTOM_PROVIDER_KEY"], + options: { + baseURL: "https://api.example.com", + headers: { + "X-Test": "enabled", + }, + }, + models: { + "model-a": { name: "Model A" }, + }, + }, + }) + }) + + test("flags duplicate rows and allows reconnecting disabled providers", () => { + const result = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: "Provider", + baseURL: "https://api.example.com", + apiKey: "secret", + models: [ + { row: "m0", id: "model-a", name: "Model A", err: {} }, + { row: "m1", id: "model-a", name: "Model A 2", err: {} }, + ], + headers: [ + { row: "h0", key: "Authorization", value: "one", err: {} }, + { row: "h1", key: "authorization", value: "two", err: {} }, + ], + err: {}, + }, + t, + disabledProviders: ["custom-provider"], + existingProviderIDs: new Set(["custom-provider"]), + }) + + expect(result.result).toBeUndefined() + expect(result.err.providerID).toBeUndefined() + expect(result.models[1]).toEqual({ + id: "provider.custom.error.duplicate", + name: undefined, + }) + expect(result.headers[1]).toEqual({ + key: "provider.custom.error.duplicate", + value: undefined, + }) + }) +}) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e3c11c335ed5b792011f97b580198e42f7929658 --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -0,0 +1,330 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { useMutation } from "@tanstack/solid-query" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast } from "@/utils/toast" +import { batch, For } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { ExternalLink } from "@/components/external-link" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { useLanguage } from "@/context/language" +import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" + +type Props = { + onBack: () => void +} + +export function DialogCustomProvider(props: Props) { + const language = useLanguage() + + return ( + + } + transition + > + + + ) +} + +export function CustomProviderForm(props: { autofocus?: boolean } = {}) { + const dialog = useDialog() + const serverSync = useServerSync() + const serverSDK = useServerSDK() + const language = useLanguage() + + const [form, setForm] = createStore({ + providerID: "", + name: "", + baseURL: "", + apiKey: "", + models: [modelRow()], + headers: [headerRow()], + err: {}, + }) + + const addModel = () => { + setForm( + "models", + produce((rows) => { + rows.push(modelRow()) + }), + ) + } + + const removeModel = (index: number) => { + if (form.models.length <= 1) return + setForm( + "models", + produce((rows) => { + rows.splice(index, 1) + }), + ) + } + + const addHeader = () => { + setForm( + "headers", + produce((rows) => { + rows.push(headerRow()) + }), + ) + } + + const removeHeader = (index: number) => { + if (form.headers.length <= 1) return + setForm( + "headers", + produce((rows) => { + rows.splice(index, 1) + }), + ) + } + + const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => { + setForm(key, value) + if (key === "apiKey") return + setForm("err", key, undefined) + } + + const setModel = (index: number, key: "id" | "name", value: string) => { + batch(() => { + setForm("models", index, key, value) + setForm("models", index, "err", key, undefined) + }) + } + + const setHeader = (index: number, key: "key" | "value", value: string) => { + batch(() => { + setForm("headers", index, key, value) + setForm("headers", index, "err", key, undefined) + }) + } + + const validate = () => { + const output = validateCustomProvider({ + form, + t: language.t, + disabledProviders: serverSync().data.config.disabled_providers ?? [], + existingProviderIDs: new Set(serverSync().data.provider.all.keys()), + }) + batch(() => { + setForm("err", output.err) + output.models.forEach((err, index) => setForm("models", index, "err", err)) + output.headers.forEach((err, index) => setForm("headers", index, "err", err)) + }) + return output.result + } + + const saveMutation = useMutation(() => ({ + mutationFn: async (result: NonNullable>) => { + if ((await serverSDK().protocol) !== "v1") throw new Error(language.t("provider.custom.unavailable")) + const disabledProviders = serverSync().data.config.disabled_providers ?? [] + const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) + + if (result.key) { + await serverSDK().client.auth.set({ + providerID: result.providerID, + auth: { + type: "api", + key: result.key, + }, + }) + } + + await serverSync().updateConfig({ + provider: { [result.providerID]: result.config }, + disabled_providers: nextDisabled, + }) + return result + }, + onSuccess: (result) => { + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.connect.toast.connected.title", { provider: result.name }), + description: language.t("provider.connect.toast.connected.description", { provider: result.name }), + }) + }, + onError: (err) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }, + })) + + const save = (e: SubmitEvent) => { + e.preventDefault() + if (saveMutation.isPending) return + + const result = validate() + if (!result) return + saveMutation.mutate(result) + } + + return ( +
+
+ +
{language.t("provider.custom.title")}
+
+ +
+

+ {language.t("provider.custom.description.prefix")} + + {language.t("provider.custom.description.link")} + + {language.t("provider.custom.description.suffix")} +

+ +
+ setField("providerID", v)} + validationState={form.err.providerID ? "invalid" : undefined} + error={form.err.providerID} + /> + setField("name", v)} + validationState={form.err.name ? "invalid" : undefined} + error={form.err.name} + /> + setField("baseURL", v)} + validationState={form.err.baseURL ? "invalid" : undefined} + error={form.err.baseURL} + /> + setField("apiKey", v)} + /> +
+ +
+ + + {(m, i) => ( +
+
+ setModel(i(), "id", v)} + validationState={m.err.id ? "invalid" : undefined} + error={m.err.id} + /> +
+
+ setModel(i(), "name", v)} + validationState={m.err.name ? "invalid" : undefined} + error={m.err.name} + /> +
+ removeModel(i())} + disabled={form.models.length <= 1} + aria-label={language.t("provider.custom.models.remove")} + /> +
+ )} +
+ +
+ +
+ + + {(h, i) => ( +
+
+ setHeader(i(), "key", v)} + validationState={h.err.key ? "invalid" : undefined} + error={h.err.key} + /> +
+
+ setHeader(i(), "value", v)} + validationState={h.err.value ? "invalid" : undefined} + error={h.err.value} + /> +
+ removeHeader(i())} + disabled={form.headers.length <= 1} + aria-label={language.t("provider.custom.headers.remove")} + /> +
+ )} +
+ +
+ + +
+
+ ) +} diff --git a/packages/app/src/components/dialog-edit-project-v2.tsx b/packages/app/src/components/dialog-edit-project-v2.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc9714aa06252148145bd988cfd9a4afa74671ad --- /dev/null +++ b/packages/app/src/components/dialog-edit-project-v2.tsx @@ -0,0 +1,156 @@ +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { Field } from "@opencode-ai/ui/v2/field-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2" +import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { For, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { getProjectAvatarSource } from "@/pages/layout/helpers" +import { createEditProjectModel } from "./edit-project" + +export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) { + const language = useLanguage() + const model = createEditProjectModel(props) + + return ( + +
+ + {language.t("dialog.project.edit.title")} + + + + + {language.t("dialog.project.edit.name")} + model.setStore("name", event.currentTarget.value)} + /> + + +
+
+ {language.t("dialog.project.edit.icon")} +
+
+ + { + model.setIconInput(element) + }} + type="file" + accept="image/*" + class="hidden" + onChange={model.inputChange} + /> +
+ {language.t("dialog.project.edit.icon.hint")} + {language.t("dialog.project.edit.icon.recommended")} +
+
+
+ + +
+
+ {language.t("dialog.project.edit.color")} +
+
+ + {(color) => ( + + )} + +
+
+
+ + + {language.t("dialog.project.edit.worktree.startup")} + {language.t("dialog.project.edit.worktree.startup.description")} + model.setStore("startup", event.currentTarget.value)} + /> + +
+ + + {language.t("common.cancel")} + + + {model.save.isPending ? language.t("common.saving") : language.t("common.save")} + + + +
+ ) +} diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx new file mode 100644 index 0000000000000000000000000000000000000000..86a9630359baef7fc356d590db8d476fdf36d073 --- /dev/null +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -0,0 +1,170 @@ +import { Button } from "@opencode-ai/ui/button" +import { Dialog } from "@opencode-ai/ui/dialog" +import { TextField } from "@opencode-ai/ui/text-field" +import { Icon } from "@opencode-ai/ui/icon" +import { For, Show } from "solid-js" +import { type LocalProject, getAvatarColors } from "@/context/layout" +import { Avatar } from "@opencode-ai/ui/avatar" +import { useLanguage } from "@/context/language" +import { getProjectAvatarSource } from "@/pages/layout/helpers" +import { ServerConnection } from "@/context/server" +import { createEditProjectModel } from "./edit-project" + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const + +export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) { + const language = useLanguage() + const model = createEditProjectModel(props) + + return ( + +
+
+ model.setStore("name", v)} + /> + +
+ +
+
model.setStore("iconHover", true)} + onMouseLeave={() => model.setStore("iconHover", false)} + > +
+ + +
+ } + > + {(src) => ( + {language.t("dialog.project.edit.icon.alt")} + )} + +
+
+ +
+
+ +
+
+ { + model.setIconInput(el) + }} + type="file" + accept="image/*" + class="hidden" + onChange={model.inputChange} + /> +
+ {language.t("dialog.project.edit.icon.hint")} + {language.t("dialog.project.edit.icon.recommended")} +
+
+
+ + +
+ +
+ + {(color) => ( + + )} + +
+
+
+ + model.setStore("startup", v)} + spellcheck={false} + class="max-h-14 w-full overflow-y-auto font-mono text-xs" + /> +
+ +
+ + +
+ + + ) +} diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5187d980ea263961e366e7ce021d678b933724c0 --- /dev/null +++ b/packages/app/src/components/dialog-fork.tsx @@ -0,0 +1,104 @@ +import { Component, createMemo } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { usePrompt } from "@/context/prompt" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { showToast } from "@/utils/toast" +import { extractPromptFromParts } from "@/utils/prompt" +import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { useLanguage } from "@/context/language" + +interface ForkableMessage { + id: string + text: string + time: string +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString(undefined, { timeStyle: "short" }) +} + +export const DialogFork: Component = () => { + const params = useParams() + const navigate = useNavigate() + const sync = useSync() + const sdk = useSDK() + const prompt = usePrompt() + const dialog = useDialog() + const language = useLanguage() + + const messages = createMemo((): ForkableMessage[] => { + const sessionID = params.id + if (!sessionID) return [] + + const msgs = sync().data.message[sessionID] ?? [] + const result: ForkableMessage[] = [] + + for (const message of msgs) { + if (message.role !== "user") continue + + const parts = sync().data.part[message.id] ?? [] + const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored) + if (!textPart) continue + + result.push({ + id: message.id, + text: textPart.text.replace(/\n/g, " ").slice(0, 200), + time: formatTime(new Date(message.time.created)), + }) + } + + return result.reverse() + }) + + const handleSelect = (item: ForkableMessage | undefined) => { + if (!item) return + + const sessionID = params.id + if (!sessionID) return + + const parts = sync().data.part[item.id] ?? [] + const restored = extractPromptFromParts(parts, { + directory: sdk().directory, + attachmentName: language.t("common.attachment"), + }) + const dir = base64Encode(sdk().directory) + + sdk() + .api.session.fork({ sessionID, messageID: item.id }) + .then((forked) => { + dialog.close() + prompt.set(restored, undefined, { dir, id: forked.id }) + navigate(`/${dir}/session/${forked.id}`) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } + + return ( + + x.id} + items={messages} + filterKeys={["text"]} + onSelect={handleSelect} + > + {(item) => ( +
+ {item.text} + {item.time} +
+ )} +
+
+ ) +} diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d6c5e99186704f2e00fef9855a477bd25ae99f1a --- /dev/null +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -0,0 +1,265 @@ +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Button } from "@opencode-ai/ui/button" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { For, Show, type Component } from "solid-js" +import { useLocal } from "@/context/local" +import { popularProviders } from "@/hooks/use-providers" +import { useLanguage } from "@/context/language" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { decode64 } from "@/utils/base64" +import { SettingsListV2 } from "./settings-v2/parts/list" +import { SettingsRowV2 } from "./settings-v2/parts/row" +import "./settings-v2/settings-v2.css" + +type ModelItem = ReturnType["model"]["list"]>[number] + +export const DialogManageModels: Component = () => { + const local = useLocal() + const language = useLanguage() + const dialog = useDialog() + const directory = () => decode64(local.slug()) + + const handleConnectProvider = () => { + void dialog.show(() => ) + } + const providerRank = (id: string) => popularProviders.indexOf(id) + const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) + const providerVisible = (providerID: string) => + providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id })) + const setProviderVisibility = (providerID: string, checked: boolean) => { + providerList(providerID).forEach((x) => { + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked) + }) + } + + return ( + + {language.t("command.provider.connect")} + + } + > + `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.id} + groupHeader={(group) => { + const provider = group.items[0].provider + return ( + <> + {provider.name} + + setProviderVisibility(provider.id, checked)} + hideLabel + > + {provider.name} + + + + ) + }} + sortGroupsBy={(a, b) => { + const aRank = providerRank(a.items[0].provider.id) + const bRank = providerRank(b.items[0].provider.id) + const aPopular = aRank >= 0 + const bPopular = bRank >= 0 + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + return aRank - bRank + }} + onSelect={(x) => { + if (!x) return + const key = { modelID: x.id, providerID: x.provider.id } + local.model.setVisibility(key, !local.model.visible(key)) + }} + > + {(i) => ( +
+ {i.name} +
e.stopPropagation()}> + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+
+ )} +
+
+ ) +} + +export const DialogManageModelsV2: Component = () => { + const local = useLocal() + const language = useLanguage() + const dialog = useDialog() + const directory = () => decode64(local.slug()) + + const handleConnectProvider = () => { + void dialog.show(() => ) + } + const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) + const providerVisible = (providerID: string) => + providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id })) + const setProviderVisibility = (providerID: string, checked: boolean) => { + providerList(providerID).forEach((x) => { + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked) + }) + } + const setModelVisibility = (item: ModelItem, checked: boolean) => { + local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked) + } + const list = useFilteredList({ + items: () => local.model.list(), + key: (x) => `${x.provider.id}:${x.id}`, + filterKeys: ["provider.name", "name", "id"], + sortBy: (a, b) => a.name.localeCompare(b.name), + groupBy: (x) => x.provider.id, + sortGroupsBy: (a, b) => { + const aRank = popularProviders.indexOf(a.category) + const bRank = popularProviders.indexOf(b.category) + const aPopular = aRank >= 0 + const bPopular = bRank >= 0 + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + return aRank - bRank + }, + }) + + return ( + + + + + {language.t("command.provider.connect")} + + + +
+
+ list.onInput(event.currentTarget.value)} + placeholder={language.t("dialog.model.search.placeholder")} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + autofocus + aria-label={language.t("dialog.model.search.placeholder")} + /> + + } + onClick={() => list.clear()} + aria-label={language.t("common.clear")} + /> + +
+
+
+
+ + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + 0} + fallback={ +
+ {language.t("dialog.model.empty")} + + "{list.filter()}" + +
+ } + > + + {(group) => ( +
+
+
+ +

{group.items[0].provider.name}

+
+
+ setProviderVisibility(group.category, checked)} + hideLabel + > + {group.items[0].provider.name} + +
+
+ + + {(item) => ( + +
+ setModelVisibility(item, checked)} + hideLabel + > + {item.name} + +
+
+ )} +
+
+
+ )} +
+
+ +
+
+ + + ) +} diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0a35b71beb22ee2777a9833108c029a2d8df0e3 --- /dev/null +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -0,0 +1,144 @@ +import { createSignal } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" + +export type Highlight = { + title: string + description: string + media?: { + type: "image" | "video" + src: string + alt?: string + } +} + +export function DialogReleaseNotes(props: { highlights: Highlight[] }) { + const dialog = useDialog() + const language = useLanguage() + const settings = useSettings() + const [index, setIndex] = createSignal(0) + + const total = () => props.highlights.length + const last = () => Math.max(0, total() - 1) + const feature = () => props.highlights[index()] ?? props.highlights[last()] + const isFirst = () => index() === 0 + const isLast = () => index() >= last() + const paged = () => total() > 1 + + function handleNext() { + if (isLast()) return + setIndex(index() + 1) + } + + function handleClose() { + dialog.close() + } + + function handleDisable() { + settings.general.setReleaseNotes(false) + handleClose() + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault() + handleClose() + return + } + + if (!paged()) return + if (e.key === "ArrowLeft" && !isFirst()) { + e.preventDefault() + setIndex(index() - 1) + } + if (e.key === "ArrowRight" && !isLast()) { + e.preventDefault() + setIndex(index() + 1) + } + } + + return ( + +
+ {/* Left side - Text content */} +
+ {/* Top section - feature content (fixed position from top) */} +
+
+

{feature()?.title ?? ""}

+
+

{feature()?.description ?? ""}

+
+ + {/* Spacer to push buttons to bottom */} +
+ + {/* Bottom section - buttons and indicators (fixed position) */} +
+
+ {isLast() ? ( + + ) : ( + + )} + + +
+ + {paged() && ( +
+ {props.highlights.map((_, i) => ( + + ))} +
+ )} +
+
+ + {/* Right side - Media content (edge to edge) */} + {feature()?.media && ( +
+ {feature()!.media!.type === "image" ? ( + {feature()!.media!.alt + ) : ( +
+ )} +
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-directory-v2.css b/packages/app/src/components/dialog-select-directory-v2.css new file mode 100644 index 0000000000000000000000000000000000000000..e1d22f19c6816b795c227af602c51cd63633a642 --- /dev/null +++ b/packages/app/src/components/dialog-select-directory-v2.css @@ -0,0 +1,107 @@ +.directory-picker-v2-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 12px; + padding: 2px 16px 0; +} + +.directory-picker-v2-path { + position: relative; + z-index: 10; + display: flex; + gap: 8px; +} + +.directory-picker-v2-actions { + display: flex; + flex-shrink: 0; + gap: 2px; +} + +.directory-picker-v2-suggestions { + position: absolute; + z-index: 20; + top: 36px; + right: 0; + left: 0; + display: flex; + flex-direction: column; + padding: 4px; + border: 1px solid var(--v2-border-border-base); + border-radius: 6px; + background: var(--v2-background-bg-layer-02); + box-shadow: var(--v2-elevation-overlay); +} + +.directory-picker-v2-suggestions button { + overflow: hidden; + padding: 6px 8px; + border-radius: 4px; + color: var(--v2-text-text-muted); + font-size: 12px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.directory-picker-v2-suggestions button:hover, +.directory-picker-v2-suggestions button[data-active] { + color: var(--v2-text-text-base); + background: var(--v2-overlay-simple-overlay-hover); +} + +.directory-picker-v2-browser { + position: relative; + z-index: 0; + isolation: isolate; + min-height: 0; + flex: 1; + overflow: auto; + border: 1px solid var(--v2-border-border-base); + border-radius: 6px; + background: transparent; +} + +.directory-picker-v2-tree { + display: block; + width: 100%; + height: 100%; + --trees-bg-override: transparent; + --trees-fg-override: var(--v2-text-text-base); + --trees-fg-muted-override: var(--v2-text-text-muted); + --trees-bg-muted-override: transparent; + --trees-selected-bg-override: transparent; + --trees-selected-fg-override: var(--v2-text-text-base); + --trees-selected-focused-border-color-override: transparent; + --trees-focus-ring-color-override: transparent; + --trees-focus-ring-width-override: 0px; + --trees-focus-ring-offset-override: 0px; + --trees-border-color-override: var(--v2-border-border-base); + --trees-font-family-override: var(--font-family-sans); + --trees-font-size-override: 12px; + --trees-item-height: 24px; + --trees-border-radius-override: 4px; +} + +.directory-picker-v2-state { + position: absolute; + z-index: 1; + inset: 0; + display: grid; + place-items: center; + color: var(--v2-text-text-muted); + font-size: 12px; + pointer-events: none; +} + +.directory-picker-v2-selection { + overflow: hidden; + flex-shrink: 0; + color: var(--v2-text-text-muted); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f3376ad35f260036cb1ca35b2094031b0ef23ccc --- /dev/null +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -0,0 +1,386 @@ +import "@pierre/trees/web-components" +import { FileTree } from "@pierre/trees" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import type { Path } from "@opencode-ai/sdk/v2/client" +import { + absoluteTreePath, + activeTreeNavigation, + advanceTreePreload, + nextSuggestionIndex, + nextTreeScrollTop, + pickerFileSearchQuery, + pickerAbsoluteInput, + pickerMode, + preloadTreeDirectories, + cleanPickerInput, + createPriorityTaskQueue, + createDirectorySearch, + currentPickerSuggestions, + displayPickerPath, + pickerParent, + pickerRoot, +} from "./directory-picker-domain" +import "./dialog-select-directory-v2.css" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { getFilename } from "@opencode-ai/core/util/path" + +interface DialogSelectDirectoryV2Props { + title?: string + multiple?: boolean + onSelect: (result: string | string[] | null) => void + server: ServerConnection.Any + mode?: "directory" | "file" + start?: string +} + +export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { + const global = useGlobal() + const { sync, sdk } = global.ensureServerCtx(props.server) + const dialog = useDialog() + const language = useLanguage() + const policy = pickerMode(props.mode ?? "directory", props.start) + const action = { + file: language.t("dialog.directory.action.selectFile"), + directory: language.t("dialog.directory.action.selectFolder"), + } + const [root, setRoot] = createSignal("") + const [input, setInput] = createSignal("") + const [selected, setSelected] = createSignal("") + const [suggestionsOpen, setSuggestionsOpen] = createSignal(false) + const [activeSuggestion, setActiveSuggestion] = createSignal(-1) + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(false) + const [rootValid, setRootValid] = createSignal(false) + const listings = new Map | undefined>>() + const loads = createPriorityTaskQueue | undefined>(3) + const advanced = new Set() + let tree: FileTree | undefined + let container: HTMLDivElement | undefined + let pathArea: HTMLDivElement | undefined + let navigation = 0 + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async (): Promise => { + if ((await sdk.protocol) !== "v1") return + return sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const start = createMemo( + () => + props.start || + sync.data.path.home || + sync.data.path.directory || + fallbackPath()?.home || + fallbackPath()?.directory, + ) + const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) + const [suggestions] = createResource(input, async (value) => { + const cleaned = cleanPickerInput(value) + const typed = cleaned.replace(/\/+$/, "") + const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") + if (!cleaned || (root() && typed === current)) return { query: value, items: [] } + const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) + if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } + const base = pickerRoot(cleaned) || root() || start() + if (!base) return { query: value, items: directories.slice(0, 5) } + const files = await sdk.api.file + .find({ + location: { directory: base }, + query: pickerFileSearchQuery(base, value, home()), + type: "file", + limit: 20, + }) + .then((result) => result.data) + .catch(() => []) + const results = [ + ...directories, + ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), + ] + return { + query: value, + items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8), + } + }) + const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input())) + + async function load(path: string, generation: number, eager = false) { + const key = path.replace(/\/+$/, "") + setError(false) + const absolute = absoluteTreePath(root(), key) + const existing = listings.get(key) + if (existing && !eager) loads.promote(`${generation}:${key}`) + const request = + existing ?? + loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { + if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) + return sdk.api.file + .list({ location: { directory: absolute } }) + .then((result) => + result.data.map((entry) => ({ + name: getFilename(entry.path.replace(/[\\/]+$/, "")), + type: entry.type, + })), + ) + .catch(() => undefined) + }) + listings.set(key, request) + const nodes = await request + if (!activeTreeNavigation(generation, navigation)) return false + if (!nodes) { + listings.delete(key) + if (!key) setError(true) + return false + } + tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item }))) + if (!eager && advanceTreePreload(advanced, key)) { + for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true) + } + return true + } + + async function navigate(path: string) { + const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home())) + if (!value) return + const token = ++navigation + setLoading(true) + setRootValid(false) + setSelected("") + setSuggestionsOpen(false) + setActiveSuggestion(-1) + setRoot(value) + setInput(displayPickerPath(value, value, home())) + listings.clear() + advanced.clear() + tree?.resetPaths([]) + const valid = await load("", token) + if (!activeTreeNavigation(token, navigation)) return + setRootValid(valid) + setLoading(false) + } + + function complete() { + const items = currentSuggestions() + const match = items[activeSuggestion()] ?? items[0] + if (!match) return + const value = displayPickerPath(match.absolute, input(), home()) + setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value) + if (match.type === "file") { + setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "") + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + } + + function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) { + if (suggestion.type === "directory") { + void navigate(suggestion.absolute) + return + } + setInput(displayPickerPath(suggestion.absolute, input(), home())) + setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "") + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + + function moveSuggestion(delta: -1 | 1) { + setSuggestionsOpen(true) + setActiveSuggestion((current) => nextSuggestionIndex(current, delta, currentSuggestions().length)) + } + + function activeSuggestionValue() { + const items = currentSuggestions() + return items[activeSuggestion()] ?? items[0] + } + + const keyActions: Partial void>> = { + ArrowDown: () => moveSuggestion(1), + ArrowUp: () => moveSuggestion(-1), + Enter: () => { + const suggestion = activeSuggestionValue() + if (suggestion) chooseSuggestion(suggestion) + if (!suggestion) void navigate(input()) + }, + Tab: complete, + } + + function handleInputKey(event: KeyboardEvent) { + const action = keyActions[event.key] + if (!action) return + if (event.key === "Tab" && event.shiftKey) return + event.preventDefault() + action() + } + + function resolve() { + const path = policy.result(root(), selected(), rootValid()) + if (!path) return + props.onSelect(props.multiple ? [path] : path) + dialog.close() + } + + onMount(() => { + const closeSuggestions = (event: PointerEvent) => { + if (pathArea?.contains(event.target as Node)) return + setSuggestionsOpen(false) + setActiveSuggestion(-1) + } + document.addEventListener("pointerdown", closeSuggestions) + onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions)) + tree = new FileTree({ + paths: [], + flattenEmptyDirectories: false, + initialExpansion: "closed", + stickyFolders: true, + unsafeCSS: ` + button[data-type="item"] { + background: transparent !important; + box-shadow: none !important; + } + button[data-type="item"]:hover { + background: var(--v2-overlay-simple-overlay-hover) !important; + } + button[data-type="item"]:focus-visible { + outline: none !important; + box-shadow: none !important; + } + [data-file-tree-virtualized-scroll] { + overscroll-behavior: contain; + scrollbar-width: thin; + } + `, + onExpansionChange(change) { + if (change.expanded) void load(change.path, navigation) + }, + onSelectionChange(paths) { + const path = paths.at(-1) + setSelected(path ? (policy.selection(root(), path) ?? "") : "") + }, + }) + if (!container) return + tree.render({ containerWrapper: container }) + tree.getFileTreeContainer()?.classList.add("directory-picker-v2-tree") + }) + + createEffect(() => { + const path = start() + if (!path || root()) return + void navigate(path) + }) + + onCleanup(() => tree?.cleanUp()) + + return ( + + + {props.title ?? language.t("command.project.open")} + + + +
+ { + setInput(cleanPickerInput(event.currentTarget.value)) + setSelected("") + setSuggestionsOpen(true) + setActiveSuggestion(-1) + }} + role="combobox" + aria-autocomplete="list" + aria-expanded={suggestionsOpen()} + aria-controls="directory-picker-v2-suggestions" + aria-activedescendant={ + activeSuggestion() >= 0 ? `directory-picker-v2-suggestion-${activeSuggestion()}` : undefined + } + onKeyDown={handleInputKey} + /> +
+ void navigate(home())}> + ~ + + void navigate(pickerRoot(root()) || root())}> + {language.t("dialog.directory.root")} + + void navigate(pickerParent(root()))}> + {language.t("dialog.directory.parent")} + +
+ 0}> +
+ + {(suggestion, index) => ( + + )} + +
+
+
+
{ + const scroller = tree + ?.getFileTreeContainer() + ?.shadowRoot?.querySelector("[data-file-tree-virtualized-scroll]") + if (!scroller) return + const next = nextTreeScrollTop( + scroller.scrollTop, + event.deltaY, + scroller.scrollHeight, + scroller.clientHeight, + ) + if (next === scroller.scrollTop) return + event.preventDefault() + scroller.scrollTop = next + scroller.dispatchEvent(new Event("scroll")) + }} + > + +
{language.t("common.loading")}
+
+ +
{language.t("dialog.directory.readError")}
+
+
+
{policy.result(root(), selected(), rootValid())}
+
+ + dialog.close()}> + {language.t("common.cancel")} + + + {action[policy.action]} + + +
+ ) +} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fc248d821e29db11c6ee15c1bb6f94ea3743e685 --- /dev/null +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -0,0 +1,200 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { List } from "@opencode-ai/ui/list" +import type { ListRef } from "@opencode-ai/ui/list" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { createMemo, createResource, createSignal } from "solid-js" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { useGlobal } from "@/context/global" +import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" +import type { Path } from "@opencode-ai/sdk/v2/client" + +interface DialogSelectDirectoryProps { + title?: string + multiple?: boolean + onSelect: (result: string | string[] | null) => void + server: ServerConnection.Any +} + +const RECENT_PROJECT_LIMIT = 5 + +type Row = { + absolute: string + search: string + group: "recent" | "folders" +} + +function toRow(absolute: string, home: string, group: Row["group"]): Row { + const full = displayPickerPath(absolute, "", "") + const tilde = displayPickerPath(full, "~", home) + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" + } + + const search = Array.from( + new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), + ).join("\n") + return { absolute: full, search, group } +} + +function uniqueRows(rows: Row[]) { + const seen = new Set() + return rows.filter((row) => { + if (seen.has(row.absolute)) return false + seen.add(row.absolute) + return true + }) +} + +export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { + const global = useGlobal() + const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server) + const dialog = useDialog() + const language = useLanguage() + + const [filter, setFilter] = createSignal("") + let list: ListRef | undefined + + const missingHome = createMemo(() => !sync.data.path.home) + const [fallbackPath] = createResource( + () => (missingHome() ? true : undefined), + async (): Promise => { + if ((await sdk.protocol) !== "v1") return + return sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) + + const directories = createDirectorySearch({ + sdk, + home, + base: start, + }) + + const recentProjects = createMemo(() => { + const projects = serverCtx.projects.list() + const byProject = new Map() + + for (const project of projects) { + let at = 0 + const dirs = [project.worktree, ...(project.sandboxes ?? [])] + for (const directory of dirs) { + const sessions = sync.child(directory, { bootstrap: false })[0].session + for (const session of sessions) { + if (session.time.archived) continue + const updated = session.time.updated ?? session.time.created + if (updated > at) at = updated + } + } + byProject.set(project.worktree, at) + } + + return projects + .map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index })) + .sort((a, b) => b.at - a.at || a.index - b.index) + .map(({ project }) => { + const row = toRow(project.worktree, home(), "recent") + const name = project.name || getFilename(project.worktree) + return { + ...row, + search: `${row.search}\n${name}`, + } + }) + }) + + const items = async (value: string) => { + const results = await directories(value) + const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders")) + // Cap the idle list only. Once a query narrows the results, every project stays searchable. + const recent = recentProjects() + const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT) + return uniqueRows([...visible, ...directoryRows]) + } + + function resolve(absolute: string) { + props.onSelect(props.multiple ? [absolute] : absolute) + dialog.close() + } + + return ( + + x.absolute} + filterKeys={["search"]} + groupBy={(item) => item.group} + sortGroupsBy={(a, b) => { + if (a.category === b.category) return 0 + return a.category === "recent" ? -1 : 1 + }} + groupHeader={(group) => + group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open") + } + ref={(r) => (list = r)} + onFilter={(value) => setFilter(cleanPickerInput(value))} + onKeyEvent={(e, item) => { + if (e.key !== "Tab") return + if (e.shiftKey) return + if (!item) return + + e.preventDefault() + e.stopPropagation() + + const value = displayPickerPath(item.absolute, filter(), home()) + list?.setFilter(value.endsWith("/") ? value : value + "/") + }} + onSelect={(path) => { + if (!path) return + resolve(path.absolute) + }} + > + {(item) => { + const path = displayPickerPath(item.absolute, filter(), home()) + if (path === "~") { + return ( +
+
+ +
+ ~ + / +
+
+
+ ) + } + return ( +
+
+ +
+ + {getDirectory(path)} + + {getFilename(path)} + / +
+
+
+ ) + }} +
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx new file mode 100644 index 0000000000000000000000000000000000000000..905abe1e2dd3955e774d73de58b438fc4e986201 --- /dev/null +++ b/packages/app/src/components/dialog-select-file.tsx @@ -0,0 +1,189 @@ +import { Dialog } from "@opencode-ai/ui/dialog" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { Keybind } from "@opencode-ai/ui/keybind" +import { List } from "@opencode-ai/ui/list" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js" +import { formatKeybind } from "@/context/command" +import { useServerSDK } from "@/context/server-sdk" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { useSessionLayout } from "@/pages/session/session-layout" +import { decode64 } from "@/utils/base64" +import { getRelativeTime } from "@/utils/time" +import { + createCommandPaletteFileEntry, + createCommandPaletteFileOpener, + createCommandPaletteModel, + uniqueCommandPaletteEntries, + type CommandPaletteEntry, +} from "./command-palette" +import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2" + +const DialogSelectFileV2 = lazy(() => + import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), +) +type DialogSelectFileMode = "all" | "files" + +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { + const platform = usePlatform() + const settings = useSettings() + const filesOnly = () => props.mode === "files" + + if (!filesOnly() && settings.general.newLayoutDesigns()) { + return + } + + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + return + } + + return +} + +function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) { + const language = useLanguage() + const serverSDK = useServerSDK() + const { params } = useSessionLayout() + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const openFile = createCommandPaletteFileOpener(props.onOpenFile) + + return ( + { + if (typeof result !== "string") return + openFile(result) + }} + /> + ) +} + +function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) { + const palette = createCommandPaletteModel(props) + const [grouped, setGrouped] = createSignal(false) + + const items = async (text: string) => { + const query = text.trim() + setGrouped(query.length > 0) + + if (!query && props.filesOnly()) { + const loaded = palette.file.tree.state("")?.loaded + const pending = loaded ? Promise.resolve() : palette.file.tree.list("") + const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) + + if (loaded || next.length > 0) { + void pending + return next + } + + await pending + return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()]) + } + + if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()] + + if (props.filesOnly()) { + const files = await palette.file.searchFiles(query) + const category = palette.language.t("palette.group.files") + return files.map((path) => createCommandPaletteFileEntry(path, category)) + } + + const [files, nextSessions] = await Promise.all([ + palette.file.searchFiles(query), + Promise.resolve(palette.sessions(query)), + ]) + const category = palette.language.t("palette.group.files") + const entries = files.map((path) => createCommandPaletteFileEntry(path, category)) + return [...palette.commandEntries(), ...nextSessions, ...entries] + } + + return ( + + item.id} + filterKeys={["title", "description", "category"]} + skipFilter={(item) => item.type === "file"} + groupBy={grouped() ? (item) => item.category : () => ""} + onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)} + onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)} + > + {(item) => ( + +
+ +
+ + {getDirectory(item.path ?? "")} + + {getFilename(item.path ?? "")} +
+
+
+ } + > + +
+
+ {item.title} + + {item.description} + +
+ + {formatKeybind(item.keybind ?? "", palette.language.t)} + +
+
+ +
+
+ +
+ + {item.title} + + + + {item.description} + + +
+
+ + + {getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)} + + +
+
+ + )} + + + ) +} diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4f1a3cd2392c63a5adfcfe26605a6c9e074483d8 --- /dev/null +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -0,0 +1,92 @@ +import { Component, createMemo, Show } from "solid-js" +import { useSync } from "@/context/sync" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" +import { useLanguage } from "@/context/language" +import { useMcpToggle } from "@/context/mcp" + +const statusLabels = { + connected: "mcp.status.connected", + failed: "mcp.status.failed", + needs_auth: "mcp.status.needs_auth", + needs_client_registration: "mcp.status.needs_client_registration", + disabled: "mcp.status.disabled", +} as const + +export const DialogSelectMcp: Component = () => { + const sync = useSync() + const language = useLanguage() + + const items = createMemo(() => + Object.entries(sync().data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const toggle = useMcpToggle() + + const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) + const totalCount = createMemo(() => items().length) + + return ( + + x?.name ?? ""} + items={items} + filterKeys={["name", "status"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + onSelect={(x) => { + if (!x || x.status === "pending" || toggle.isPending) return + toggle.mutate(x.name) + }} + > + {(i) => { + const mcpStatus = () => sync().data.mcp[i.name] + const status = () => mcpStatus()?.status + const statusLabel = () => { + const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined + if (!key) return + return language.t(key) + } + const error = () => { + const s = mcpStatus() + if (s?.status === "failed" || s?.status === "needs_client_registration") return s.error + } + const enabled = () => status() === "connected" + return ( +
+
+
+ {i.name} + + {statusLabel()} + +
+ + {error()} + +
+
e.stopPropagation()}> + { + if (toggle.isPending) return + toggle.mutate(i.name) + }} + /> +
+
+ ) + }} +
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-model-search.test.ts b/packages/app/src/components/dialog-select-model-search.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ee8009140428adcd31e7dfe38b11820f93dbe53 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-search.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { matchesModelSearch } from "./dialog-select-model-search" + +describe("matchesModelSearch", () => { + test("matches model names across separators", () => { + expect(matchesModelSearch("gpt 5", ["GPT-5.5"])).toBe(true) + expect(matchesModelSearch("gpt-5", ["GPT-5.5"])).toBe(true) + expect(matchesModelSearch("gpt5", ["GPT-5.5"])).toBe(true) + }) + + test("matches any searchable model field", () => { + expect(matchesModelSearch("open ai", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true) + expect(matchesModelSearch("gpt 5", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(true) + }) + + test("does not match unrelated searches", () => { + expect(matchesModelSearch("claude", ["GPT-5.5", "gpt-5.5", "OpenAI"])).toBe(false) + }) +}) diff --git a/packages/app/src/components/dialog-select-model-search.ts b/packages/app/src/components/dialog-select-model-search.ts new file mode 100644 index 0000000000000000000000000000000000000000..901f3fc114a6e5396f410437dfa692e8033367b7 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-search.ts @@ -0,0 +1,18 @@ +export const normalizeModelSearch = (value: string) => + value + .toLowerCase() + .replace(/[^\p{Letter}\p{Number}]+/gu, " ") + .trim() + .replace(/\s+/g, " ") + +export const compactModelSearch = (value: string) => normalizeModelSearch(value).replaceAll(" ", "") + +export const matchesModelSearch = (query: string, values: string[]) => { + const search = normalizeModelSearch(query) + if (!search) return true + + const compactSearch = compactModelSearch(query) + return values.some( + (value) => normalizeModelSearch(value).includes(search) || compactModelSearch(value).includes(compactSearch), + ) +} diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bd4beb2ec9b18a1160b646b5ad70b6bd39ab4fb4 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.stories.tsx @@ -0,0 +1,55 @@ +// @ts-nocheck +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createSignal, onMount } from "solid-js" +import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2" + +const names = [ + "MiMo V2.5 Free", + "Nemotron 3 Ultra Free", + "Deepseek V4 Flash Free", + "North Mini Code Free", + "Hy3 Free", + "Big Pickle", +] + +function SelectModelWithoutProviders() { + const dialog = useDialog() + const models = names.map((name, index) => ({ + id: name.toLowerCase().replaceAll(" ", "-"), + name, + provider: { id: "opencode", name: "OpenCode" }, + cost: { input: 0, output: 0 }, + limit: { context: 128_000 }, + capabilities: { + reasoning: index !== 5, + input: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + })) + const [current, setCurrent] = createSignal(models[2]) + const model = { + list: () => models, + current, + set(value) { + setCurrent(models.find((item) => item.id === value?.modelID)) + }, + } + const open = () => dialog.show(() => ) + + onMount(open) + + return ( + + ) +} + +export default { + title: "App/Dialogs/Select Model", + id: "app-dialog-select-model", +} + +export const WithoutProviders = { + render: () => , +} diff --git a/packages/app/src/components/dialog-select-model-unpaid-v2.tsx b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2d0c3a7305c70b5be719af2a126377cf5e901fe2 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-unpaid-v2.tsx @@ -0,0 +1,176 @@ +import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useTheme } from "@opencode-ai/ui/theme" +import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useProviders } from "@/hooks/use-providers" +import { decode64 } from "@/utils/base64" +import { useLanguage } from "@/context/language" +import { ModelTooltip } from "./model-tooltip" + +type ModelState = ReturnType["model"] +const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"] +const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "") + +export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => { + const local = useLocal() + const model = props.model ?? local.model + const dialog = useDialog() + const theme = useTheme() + const directory = () => decode64(local.slug()) + const providers = useProviders(directory) + const language = useLanguage() + const modelKey = (item: ReturnType[number]) => `${item.provider.id}:${item.id}` + const currentKey = createMemo(() => { + const c = model.current() + return c ? `${c.provider.id}:${c.id}` : undefined + }) + const isFree = (item: ReturnType[number]) => + item.provider.id === "opencode" && (!item.cost || item.cost.input === 0) + const freeModels = createMemo(() => model.list().filter(isFree)) + + const openProviders = (provider?: string) => { + void import("./dialog-connect-provider").then((x) => { + const controller = x.useProviderConnectController() + controller.select(provider) + void dialog.show(() => ) + }) + } + + const selectModel = (item: ReturnType[number]) => { + model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true }) + dialog.close() + } + + // Focus starts on the dialog's close button, outside the list, so listen at the + // document level while the dialog is mounted instead of on the list container. + let listEl: HTMLDivElement | undefined + onMount(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return + if (!listEl) return + const buttons = Array.from(listEl.querySelectorAll("button")) + if (buttons.length === 0) return + const index = buttons.indexOf(document.activeElement as HTMLButtonElement) + const next = + index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1) + buttons[(next + buttons.length) % buttons.length]?.focus() + e.preventDefault() + } + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + }) + + return ( + + + {language.t("dialog.model.select.title")} + + +
+
+
+
+ {language.t("dialog.model.unpaid.freeModels.title")} +
+
+ + {(item) => ( + + } + > + + + )} + +
+ +
+
+
+
+ {language.t("dialog.model.unpaid.addMore.title")} +
+
+
+ featuredProviders.includes(provider.id)) + .sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))} + > + {(provider) => ( + + )} + + +
+
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4611a36c950e0e993eddcd637c03a92907ec1104 --- /dev/null +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -0,0 +1,147 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, type ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tag } from "@opencode-ai/ui/tag" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { type Component, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { ModelTooltip } from "./model-tooltip" +import { useLanguage } from "@/context/language" +import { decode64 } from "@/utils/base64" + +type ModelState = ReturnType["model"] + +export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => { + const local = useLocal() + const model = props.model ?? local.model + const dialog = useDialog() + const directory = () => decode64(local.slug()) + const providers = useProviders(directory) + const language = useLanguage() + + const openProviders = (provider?: string) => { + void import("./dialog-connect-provider").then((x) => { + const controller = x.useProviderConnectController() + controller.select(provider) + void dialog.show(() => ) + }) + } + + const connect = (provider: string) => openProviders(provider) + const all = () => openProviders() + + let listRef: ListRef | undefined + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + return ( + +
+
{language.t("dialog.model.unpaid.freeModels.title")}
+ (listRef = ref)} + items={model.list} + current={model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + itemWrapper={(item, node) => ( + + } + > + {node} + + )} + onSelect={(x) => { + model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.close() + }} + > + {(i) => ( +
+ {i.name} + {language.t("model.tag.free")} + + {language.t("model.tag.latest")} + +
+ )} +
+
+
+
+
+
{language.t("dialog.model.unpaid.addMore.title")}
+
+ p.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + connect(x.id) + }} + > + {(i) => ( +
+ + {i.name} + +
{language.t("dialog.provider.opencode.tagline")}
+
+ + {language.t("dialog.provider.tag.recommended")} + + + <> +
+ {language.t("dialog.provider.opencodeGo.tagline")} +
+ {language.t("dialog.provider.tag.recommended")} + +
+ +
{language.t("dialog.provider.anthropic.note")}
+
+
+ )} +
+ +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx new file mode 100644 index 0000000000000000000000000000000000000000..102c1a7cd14bd3f45df4f4f29e5067f3c96aaa28 --- /dev/null +++ b/packages/app/src/components/dialog-select-server.tsx @@ -0,0 +1,722 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { List } from "@opencode-ai/ui/list" +import { TextField } from "@opencode-ai/ui/text-field" +import { useMutation } from "@tanstack/solid-query" +import { showToast } from "@/utils/toast" +import { useNavigate } from "@solidjs/router" +import { createEffect, createMemo, createResource, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { detectServerProtocol } from "@/utils/server-protocol" +import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" +import { useSettings } from "@/context/settings" +import { useTabs } from "@/context/tabs" + +const DEFAULT_USERNAME = "opencode" + +interface ServerFormProps { + value: string + name: string + username: string + password: string + placeholder: string + busy: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onNameChange: (value: string) => void + onUsernameChange: (value: string) => void + onPasswordChange: (value: string) => void + onSubmit: () => void + onBack: () => void +} + +function showRequestError(language: ReturnType, err: unknown) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useDefaultServer() { + const language = useLanguage() + const platform = usePlatform() + const [defaultKey, defaultUrlActions] = createResource( + async () => { + try { + const key = await platform.getDefaultServer?.() + if (!key) return null + return key + } catch (err) { + showRequestError(language, err) + return null + } + }, + { initialValue: null }, + ) + + const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) + const setDefault = async (key: ServerConnection.Key | null) => { + try { + await platform.setDefaultServer?.(key) + defaultUrlActions.mutate(key) + } catch (err) { + showRequestError(language, err) + } + } + + return { defaultKey: () => defaultKey.latest, canDefault, setDefault } +} + +function useServerPreview() { + const checkServerHealth = useCheckServerHealth() + + const looksComplete = (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) return false + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return false + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true + return host.includes(".") || host.includes(":") + } + + const previewStatus = async ( + value: string, + username: string, + password: string, + setStatus: (value: boolean | undefined) => void, + ) => { + setStatus(undefined) + if (!looksComplete(value)) return + const normalized = normalizeServerUrl(value) + if (!normalized) return + const http: ServerConnection.HttpBase = { url: normalized } + if (username) http.username = username + if (password) http.password = password + const result = await checkServerHealth(http) + setStatus(result.healthy) + } + + return { previewStatus } +} + +function ServerForm(props: ServerFormProps) { + const language = useLanguage() + const keyDown = (event: KeyboardEvent) => { + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + props.onBack() + return + } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + props.onSubmit() + } + + return ( +
+
+
+ +
+ +
+ + +
+
+
+ ) +} + +export function DialogSelectServer() { + const dialog = useDialog() + const controller = useServerManagementController({ onSelect: dialog.close }) + + return ( + +
+ }> + + +
+
+ ) +} + +export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) { + const navigate = useNavigate() + const server = useServer() + const tabs = useTabs() + const global = useGlobal() + const platform = usePlatform() + const language = useLanguage() + const { defaultKey, canDefault, setDefault } = useDefaultServer() + const { previewStatus } = useServerPreview() + const checkServerHealth = useCheckServerHealth() + const [store, setStore] = createStore({ + addServer: { + url: "", + name: "", + username: DEFAULT_USERNAME, + password: "", + error: "", + showForm: false, + status: undefined as boolean | undefined, + }, + editServer: { + id: undefined as string | undefined, + value: "", + name: "", + username: "", + password: "", + error: "", + status: undefined as boolean | undefined, + }, + }) + + const resetAdd = () => { + setStore("addServer", { + url: "", + name: "", + username: DEFAULT_USERNAME, + password: "", + error: "", + showForm: false, + status: undefined, + }) + } + const resetEdit = () => { + setStore("editServer", { + id: undefined, + value: "", + name: "", + username: "", + password: "", + error: "", + status: undefined, + }) + } + + const addMutation = useMutation(() => ({ + mutationFn: async (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetAdd() + return + } + + const conn: ServerConnection.Http = { + type: "http", + http: { url: normalized }, + } + if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim() + if (store.addServer.password) conn.http.password = store.addServer.password + if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username + const result = await checkServerHealth(conn.http) + if (!result.healthy) { + setStore("addServer", { error: language.t("dialog.server.add.error") }) + return + } + if ( + !settings.general.newLayoutDesigns() && + (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" + ) { + setStore("addServer", { error: language.t("dialog.server.add.error") }) + return + } + + resetAdd() + if (options.navigateOnAdd === false) { + server.add(conn) + options.onSelect?.() + return + } + await select(conn, true) + }, + })) + + const editMutation = useMutation(() => ({ + mutationFn: async (input: { original: ServerConnection.Any; value: string }) => { + if (input.original.type !== "http") return + const normalized = normalizeServerUrl(input.value) + if (!normalized) { + resetEdit() + return + } + + const name = store.editServer.name.trim() || undefined + const username = store.editServer.username || undefined + const password = store.editServer.password || undefined + const existingName = input.original.displayName + if ( + normalized === input.original.http.url && + name === existingName && + username === input.original.http.username && + password === input.original.http.password + ) { + resetEdit() + return + } + + const conn: ServerConnection.Http = { + type: "http", + displayName: name, + http: { url: normalized, username, password }, + } + const result = await checkServerHealth(conn.http) + if (!result.healthy) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } + if ( + !settings.general.newLayoutDesigns() && + (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" + ) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } + if (normalized === input.original.http.url) { + server.add(conn) + } else { + replaceServer(input.original, conn) + } + + resetEdit() + }, + })) + + const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { + const originalKey = ServerConnection.key(original) + const active = server.key + tabs.removeServer(originalKey) + const newConn = server.add(next) + if (!newConn) return + const nextActive = active === originalKey ? ServerConnection.key(newConn) : active + if (nextActive) server.setActive(nextActive) + server.remove(originalKey) + } + + const items = createMemo(() => { + const current = server.current + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((x) => x !== current)] + }) + + const settings = useSettings() + const current = createMemo(() => + settings.general.newLayoutDesigns() + ? undefined + : (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]), + ) + + const sortedItems = createMemo(() => { + const raw = items() + const list = settings.general.newLayoutDesigns() + ? raw + : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") + if (!list.length) return list + const active = current() + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerHealth) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = + rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + async function select(conn: ServerConnection.Any, persist?: boolean) { + if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return + options.onSelect?.() + if (persist && conn.type === "http") { + server.add(conn) + navigate("/") + return + } + navigate("/") + queueMicrotask(() => server.setActive(ServerConnection.key(conn))) + } + + const handleAddChange = (value: string) => { + if (addMutation.isPending) return + setStore("addServer", { url: value, error: "" }) + void previewStatus(value, store.addServer.username, store.addServer.password, (next) => + setStore("addServer", { status: next }), + ) + } + + const handleAddNameChange = (value: string) => { + if (addMutation.isPending) return + setStore("addServer", { name: value, error: "" }) + } + + const handleAddUsernameChange = (value: string) => { + if (addMutation.isPending) return + setStore("addServer", { username: value, error: "" }) + void previewStatus(store.addServer.url, value, store.addServer.password, (next) => + setStore("addServer", { status: next }), + ) + } + + const handleAddPasswordChange = (value: string) => { + if (addMutation.isPending) return + setStore("addServer", { password: value, error: "" }) + void previewStatus(store.addServer.url, store.addServer.username, value, (next) => + setStore("addServer", { status: next }), + ) + } + + const handleEditChange = (value: string) => { + if (editMutation.isPending) return + setStore("editServer", { value, error: "" }) + void previewStatus(value, store.editServer.username, store.editServer.password, (next) => + setStore("editServer", { status: next }), + ) + } + + const handleEditNameChange = (value: string) => { + if (editMutation.isPending) return + setStore("editServer", { name: value, error: "" }) + } + + const handleEditUsernameChange = (value: string) => { + if (editMutation.isPending) return + setStore("editServer", { username: value, error: "" }) + void previewStatus(store.editServer.value, value, store.editServer.password, (next) => + setStore("editServer", { status: next }), + ) + } + + const handleEditPasswordChange = (value: string) => { + if (editMutation.isPending) return + setStore("editServer", { password: value, error: "" }) + void previewStatus(store.editServer.value, store.editServer.username, value, (next) => + setStore("editServer", { status: next }), + ) + } + + const mode = createMemo<"list" | "add" | "edit">(() => { + if (store.editServer.id) return "edit" + if (store.addServer.showForm) return "add" + return "list" + }) + + const editing = createMemo(() => { + if (!store.editServer.id) return + return items().find((x) => x.type === "http" && x.http.url === store.editServer.id) + }) + + const resetForm = () => { + resetAdd() + resetEdit() + } + + const startAdd = () => { + resetEdit() + setStore("addServer", { + showForm: true, + url: "", + name: "", + username: DEFAULT_USERNAME, + password: "", + error: "", + status: undefined, + }) + } + + const startEdit = (conn: ServerConnection.Http) => { + resetAdd() + setStore("editServer", { + id: conn.http.url, + value: conn.http.url, + name: conn.displayName ?? "", + username: conn.http.username ?? "", + password: conn.http.password ?? "", + error: "", + status: global.servers.health[ServerConnection.key(conn)]?.healthy, + }) + } + + const submitForm = () => { + if (mode() === "add") { + if (addMutation.isPending) return + setStore("addServer", { error: "" }) + addMutation.mutate(store.addServer.url) + return + } + const original = editing() + if (!original) return + if (editMutation.isPending) return + setStore("editServer", { error: "" }) + editMutation.mutate({ original, value: store.editServer.value }) + } + + const isFormMode = createMemo(() => mode() !== "list") + const isAddMode = createMemo(() => mode() === "add") + const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending)) + + const formTitle = createMemo(() => { + if (!isFormMode()) return language.t("dialog.server.title") + return ( +
+ + {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} +
+ ) + }) + + createEffect(() => { + if (!store.editServer.id) return + if (editing()) return + resetEdit() + }) + + async function handleRemove(key: ServerConnection.Key) { + try { + if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) + tabs.removeServer(key) + server.remove(key) + if ((await platform.getDefaultServer?.()) === key) { + await setDefault(null) + } + } catch (err) { + showRequestError(language, err) + } + } + + return { + defaultKey, + canDefault, + current, + sortedItems, + status: () => global.servers.health, + isFormMode, + isAddMode, + formTitle, + formBusy, + formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value), + formName: () => (isAddMode() ? store.addServer.name : store.editServer.name), + formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username), + formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password), + formError: () => (isAddMode() ? store.addServer.error : store.editServer.error), + formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status), + select, + setDefault, + startAdd, + startEdit, + resetForm, + submitForm, + handleRemove, + handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange), + handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange), + handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange), + handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange), + } +} + +export function ServerConnectionList(props: { controller: ReturnType }) { + const language = useLanguage() + const settings = useSettings() + + return ( +
+ x.http.url} + onSelect={(x) => { + if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x) + }} + divider={true} + > + {(i) => { + const key = ServerConnection.key(i) + return ( +
+
+ +
+ + + {language.t("dialog.server.status.default")} + + + } + showCredentials + /> +
+ + + + + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + { + if (i.type !== "http") return + props.controller.startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + props.controller.setDefault(key)}> + {language.t("dialog.server.menu.default")} + + + + props.controller.setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + + + props.controller.handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + {language.t("dialog.server.menu.delete")} + + + + + +
+
+ ) + }} +
+ +
+ +
+
+ ) +} + +export function ServerConnectionForm(props: { controller: ReturnType }) { + const language = useLanguage() + + return ( +
+ +
+ +
+
+ ) +} diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b554fa79a822942b03b8d83b62d48805d3cbcbda --- /dev/null +++ b/packages/app/src/components/dialog-settings.tsx @@ -0,0 +1,94 @@ +import { Component, createSignal, startTransition } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Tabs } from "@opencode-ai/ui/tabs" +import { Icon } from "@opencode-ai/ui/icon" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { SettingsGeneral } from "./settings-general" +import { SettingsKeybinds } from "./settings-keybinds" +import { SettingsProviders } from "./settings-providers" +import { SettingsModels } from "./settings-models" +import { SettingsServers } from "./settings-servers" + +export const DialogSettings: Component<{ defaultValue?: string }> = (props) => { + const language = useLanguage() + const platform = usePlatform() + const dialog = useDialog() + const [tab, setTab] = createSignal(props.defaultValue ?? "general") + + const showProviders = () => { + void dialog.show(() => ) + } + + return ( + + void startTransition(() => setTab(value))} + class="h-full settings-dialog" + > + +
+
+
+
+ {language.t("settings.section.desktop")} +
+ + + {language.t("settings.tab.general")} + + + + {language.t("settings.tab.shortcuts")} + + + + {language.t("status.popover.tab.servers")} + +
+
+ +
+ {language.t("settings.section.server")} +
+ + + {language.t("settings.providers.title")} + + + + {language.t("settings.models.title")} + +
+
+
+
+
+ {language.t("app.name.desktop")} + v{platform.version} +
+
+
+ + + + + + + + + + + + + + + +
+
+ ) +} diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx new file mode 100644 index 0000000000000000000000000000000000000000..de6451a63b6a335481666ce8d82610f008406c01 --- /dev/null +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -0,0 +1,46 @@ +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { JSX } from "solid-js" + +export type DialogGoUpsellProps = { + title: string + description: JSX.Element + link?: string + actionLabel: string + onClose?: (dontShowAgain?: boolean) => void +} + +export function DialogUsageExceeded(props: DialogGoUpsellProps) { + const dialog = useDialog() + const language = useLanguage() + const platform = usePlatform() + + const runAction = () => { + if (props.link) platform.openExternal(props.link) + props.onClose?.() + dialog.close() + } + + const dismiss = () => { + props.onClose?.(true) + dialog.close() + } + + return ( + +
+
+ + +
+
+
+ ) +} diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aeaaafde3ed1507937e1c45be2c80129ade57ed7 --- /dev/null +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -0,0 +1,310 @@ +import { expect, test } from "bun:test" +import { + absoluteTreePath, + activeTreeNavigation, + advanceTreePreload, + nextSuggestionIndex, + nextTreeScrollTop, + pickerTreeEntries, + pickerSearchEntries, + pickerFileSearchQuery, + pickerMode, + preloadTreeDirectories, + selectedTreePath, + treeEntries, + treePathWithin, + currentPickerSuggestions, + createDirectorySearch, + createPriorityTaskQueue, + displayPickerPath, + pickerParent, + pickerRoot, + pickerAbsoluteInput, +} from "./directory-picker-domain" + +test("maps server directory entries into Pierre paths", () => { + expect( + treeEntries("src/", [ + { name: "components", type: "directory" }, + { name: "index.ts", type: "file" }, + ]), + ).toEqual(["src/components/", "src/index.ts"]) +}) + +test("maps Pierre paths back to the selected server root", () => { + expect(absoluteTreePath("C:/Users/luke", "src/components/")).toBe("C:/Users/luke/src/components") + expect(absoluteTreePath("C:/", "")).toBe("C:/") + expect(absoluteTreePath("C:/", "README.md")).toBe("C:/README.md") + expect(absoluteTreePath("/home/luke", "README.md")).toBe("/home/luke/README.md") +}) + +test("includes files only when the picker selects files", () => { + const nodes = [ + { name: "components", type: "directory" as const }, + { name: "index.ts", type: "file" as const }, + ] + expect(pickerTreeEntries("", nodes, "directory")).toEqual(["components/"]) + expect(pickerTreeEntries("", nodes, "file")).toEqual(["components/", "index.ts"]) +}) + +test("includes files in file autocomplete while preserving directory navigation", () => { + const nodes = [ + { name: "src", absolute: "/repo/src", type: "directory" as const }, + { name: "README.md", absolute: "/repo/README.md", type: "file" as const }, + ] + expect(pickerSearchEntries(nodes, "directory")).toEqual([nodes[0]]) + expect(pickerSearchEntries(nodes, "file")).toEqual(nodes) +}) + +test("centralizes file and directory selection policy", () => { + const file = pickerMode("file", "/repo") + expect(file.includeFiles).toBeTrue() + expect(file.selection("/repo/src", "index.ts")).toBe("src/index.ts") + expect(file.selection("/repo", "src/")).toBeUndefined() + expect(file.result("/repo", "src/index.ts")).toBe("src/index.ts") + expect(file.selection("/tmp", "example.txt")).toBeUndefined() + expect(file.navigation("/repo/src")).toBe("/repo/src") + expect(file.navigation("/tmp")).toBeUndefined() + + const directory = pickerMode("directory") + expect(directory.includeFiles).toBeFalse() + expect(directory.selection("/repo", "src/")).toBe("/repo/src") + expect(directory.selection("C:/Users/luke", "repos/")).toBe("C:\\Users\\luke\\repos") + expect(directory.selection("//Server/Share", "repo/")).toBe("\\\\Server\\Share\\repo") + expect(directory.navigation("/tmp")).toBe("/tmp") + expect(directory.result("/repo", "")).toBe("/repo") + expect(directory.result("C:/Users/luke", "")).toBe("C:\\Users\\luke") + expect(directory.result("//Server/Share/repo", "")).toBe("\\\\Server\\Share\\repo") + expect(directory.result("/repo", "", false)).toBeUndefined() +}) + +test("accepts mutations only from the active navigation", () => { + expect(activeTreeNavigation(3, 3)).toBeTrue() + expect(activeTreeNavigation(2, 3)).toBeFalse() +}) + +test("preserves POSIX case while matching Windows drives case-insensitively", () => { + expect(treePathWithin("/repo", "/Repo")).toBeFalse() + expect(treePathWithin("C:/Repo", "c:/repo/src")).toBeTrue() + expect(treePathWithin("//Server/Share/Repo", "//server/share/repo/src")).toBeTrue() + expect(pickerMode("file", "//Server/Share/Repo").selection("//server/share/repo/src", "file.ts")).toBe("src/file.ts") + expect(treePathWithin("/repo", "/repo/../tmp")).toBeFalse() + expect(treePathWithin("/", "/src")).toBeTrue() + expect(pickerMode("file", "C:/Repo").selection("c:/repo/src", "file.ts")).toBe("src/file.ts") + expect(pickerMode("file", "C:/").selection("C:/", "file.ts")).toBe("file.ts") +}) + +test("displays paths using the selected server path format", () => { + expect(displayPickerPath("C:/Users/luke/repos", "C:/Users/luke/repos", "C:/Users/luke")).toBe( + "C:\\Users\\luke\\repos", + ) + expect(displayPickerPath("C:/Users/luke/repos", "C:\\Users\\luke\\repos", "C:/Users/luke")).toBe( + "C:\\Users\\luke\\repos", + ) + expect(displayPickerPath("/home/luke/repos", "repos", "/home/luke")).toBe("~/repos") + expect(displayPickerPath("/home/luke/repos", "~/repos", "/home/luke")).toBe("~/repos") +}) + +test("treats the server share prefix as the UNC root", () => { + expect(pickerRoot("//Server/Share/repo/src")).toBe("//Server/Share") + expect(pickerRoot("\\\\Server\\Share\\repo\\src")).toBe("//Server/Share") + expect(pickerParent("//Server/Share")).toBe("//Server/Share") + expect(pickerParent("//Server/Share/repo")).toBe("//Server/Share") +}) + +test("resolves relative input against the current picker root", () => { + expect(pickerAbsoluteInput("src", "/home/luke", "/home/luke/repo")).toBe("/home/luke/repo/src") + expect(pickerAbsoluteInput("../other", "/home/luke", "/home/luke/repo")).toBe("/home/luke/other") + expect(pickerAbsoluteInput("~/.config", "/home/luke", "/home/luke/repo")).toBe("/home/luke/.config") + expect(pickerAbsoluteInput("src", "C:/Users/luke", "C:/Users/luke/repo")).toBe("C:/Users/luke/repo/src") +}) + +test("exposes autocomplete results only for their source query", () => { + const result = { query: "/repo/src", items: ["/repo/src/index.ts"] } + expect(currentPickerSuggestions(result, "/repo/src")).toEqual(result.items) + expect(currentPickerSuggestions(result, "/repo/test")).toEqual([]) +}) + +test("scopes file autocomplete to the current browser root", () => { + expect(pickerFileSearchQuery("/home/luke/repos", "/home/luke/repos/src/in", "/home/luke")).toBe("src/in") + expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op") +}) + +test("resolves directory autocomplete from the current browser root", async () => { + const directories: string[] = [] + const sdk = { + api: { + file: { + find: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") + return Promise.resolve({ data: [] }) + }, + list: () => Promise.resolve({ data: [] }), + }, + }, + } as unknown as Parameters[0]["sdk"] + let base = "/repo" + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base }) + + await search("components") + base = "/repo/src" + await search("components") + + expect(directories).toEqual(["/repo", "/repo/src"]) +}) + +test("keeps indexed directory results for servers that support empty search", async () => { + const sdk = { + api: { + file: { + find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }), + list: () => Promise.reject(new Error("listing should not run when search returns results")), + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + + expect(await search("")).toEqual(["/home/luke/projects"]) +}) + +test("lists the default directory when empty search is unsupported", async () => { + const calls: string[] = [] + const directories = Array.from({ length: 60 }, (_, index) => ({ + path: `project-${index}/`, + type: "directory" as const, + })) + const sdk = { + api: { + file: { + find: () => Promise.resolve({ data: [] }), + list: (input: { location?: { directory?: string } }) => { + calls.push(input.location?.directory ?? "") + return Promise.resolve({ + data: [...directories, { path: "README.md", type: "file" }], + }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + + const results = await search("") + expect(results).toHaveLength(60) + expect(results.at(-1)).toBe("/home/luke/project-59") + expect(calls).toEqual(["/home/luke"]) +}) + +test("matches the default directory listing when typed search is unsupported", async () => { + const sdk = { + api: { + file: { + find: () => Promise.resolve({ data: [] }), + list: () => + Promise.resolve({ + data: [ + { path: "Documents/", type: "directory" }, + { path: "Downloads/", type: "directory" }, + ], + }), + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" }) + + expect(await search("documents")).toEqual(["/home/luke/Documents"]) +}) + +test("searches from an absolute root without a default base", async () => { + const directories: string[] = [] + const sdk = { + api: { + file: { + list: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") + return Promise.resolve({ + data: [ + { path: "Users/", type: "directory" }, + { path: "tmp/", type: "directory" }, + ], + }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) + + expect(await search("/")).toEqual(["/Users", "/tmp"]) + expect(directories).toEqual(["/"]) +}) + +test("identifies the next directory level to preload", () => { + expect( + preloadTreeDirectories("src/", [ + { name: "components", type: "directory" }, + { name: "index.ts", type: "file" }, + { name: "utils", type: "directory" }, + ]), + ).toEqual(["src/components/", "src/utils/"]) +}) + +test("advances preloading once for every expanded directory", () => { + const advanced = new Set() + expect(advanceTreePreload(advanced, "")).toBeTrue() + expect(advanceTreePreload(advanced, "")).toBeFalse() + expect(advanceTreePreload(advanced, "repos/")).toBeTrue() +}) + +test("limits background tasks and prioritizes newly requested work", async () => { + const queue = createPriorityTaskQueue(2) + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const started: string[] = [] + let active = 0 + let maximum = 0 + const task = (name: string, blocker?: Promise) => async () => { + started.push(name) + active++ + maximum = Math.max(maximum, active) + await blocker + active-- + } + + const running = [ + queue.schedule("first", "background", task("first", first.promise)), + queue.schedule("second", "background", task("second", second.promise)), + queue.schedule("preload", "background", task("preload")), + queue.schedule("opened", "user", task("opened")), + ] + await Promise.resolve() + expect(started).toEqual(["first", "second"]) + + first.resolve() + await running[0] + await Promise.resolve() + expect(started).toEqual(["first", "second", "opened"]) + + second.resolve() + await Promise.all(running) + expect(started).toEqual(["first", "second", "opened", "preload"]) + expect(maximum).toBe(2) +}) + +test("clamps bridged tree wheel scrolling", () => { + expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140) + expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0) + expect(nextTreeScrollTop(290, 40, 500, 200)).toBe(300) +}) + +test("wraps autocomplete keyboard navigation", () => { + expect(nextSuggestionIndex(-1, 1, 4)).toBe(0) + expect(nextSuggestionIndex(3, 1, 4)).toBe(0) + expect(nextSuggestionIndex(0, -1, 4)).toBe(3) + expect(nextSuggestionIndex(0, 1, 0)).toBe(-1) +}) + +test("returns absolute directories and relative files", () => { + expect(selectedTreePath("/home/luke/repo", "src/", "directory")).toBe("/home/luke/repo/src") + expect(selectedTreePath("/home/luke/repo", "src/index.ts", "file")).toBe("src/index.ts") + expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts") + expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined() +}) diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfd8ee74addce37f1faf4d2e0d90f8b1d46b3e8f --- /dev/null +++ b/packages/app/src/components/directory-picker-domain.ts @@ -0,0 +1,414 @@ +export function treeEntries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + const prefix = parent.replace(/^\/+|\/+$/g, "") + return nodes.map((node) => { + const path = prefix ? `${prefix}/${node.name}` : node.name + return node.type === "directory" ? path + "/" : path + }) +} + +export function pickerTreeEntries( + parent: string, + nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>, + mode: "directory" | "file", +) { + return treeEntries(parent, mode === "directory" ? nodes.filter((node) => node.type === "directory") : nodes) +} + +export function pickerSearchEntries( + nodes: readonly T[], + mode: "directory" | "file", +) { + return mode === "directory" ? nodes.filter((node) => node.type === "directory") : [...nodes] +} + +export function pickerMode(mode: "directory" | "file", base?: string) { + if (mode === "file") { + return { + includeFiles: true, + action: "file" as const, + entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + return treeEntries(parent, nodes) + }, + navigation(path: string) { + return treePathWithin(base, path) ? path : undefined + }, + result(root: string, selected: string) { + return selected || undefined + }, + selection(root: string, path: string) { + if (!treePathWithin(base, root)) return + return selectedTreePath(root, path, "file", base) + }, + } + } + return { + includeFiles: false, + action: "directory" as const, + entries(parent: string, nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>) { + return treeEntries( + parent, + nodes.filter((node) => node.type === "directory"), + ) + }, + navigation(path: string) { + return path + }, + result(root: string, selected: string, valid = true) { + if (!valid) return + return selected || (root ? nativePickerPath(root) : undefined) + }, + selection(root: string, path: string) { + return selectedTreePath(root, path, "directory") + }, + } +} + +export function pickerFileSearchQuery(root: string, input: string, home: string) { + const value = input + .replace(/\\/g, "/") + .replace(/^~(?=\/|$)/, home) + .replace(/\/+$/, "") + const base = root.replace(/\\/g, "/").replace(/\/+$/, "") + if (value === base) return "" + if (value.startsWith(base + "/")) return value.slice(base.length + 1) + return value +} + +export function pickerAbsoluteInput(input: string, home: string, current: string) { + const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home)) + const absolute = pickerRoot(value) ? value : joinPickerPath(current, value) + return canonicalPickerPath(absolute) +} + +export function treePathWithin(base: string | undefined, path: string) { + return pickerRelativePath(base, path) !== undefined +} + +export function canonicalPickerPath(path: string) { + const value = normalizePickerDrive(path) + const root = pickerRoot(value) + const parts = value.slice(root.length).split("/") + const resolved = parts.reduce((output, part) => { + if (!part || part === ".") return output + if (part === "..") { + output.pop() + return output + } + output.push(part) + return output + }, []) + return joinPickerPath(root, resolved.join("/")) +} + +export function pickerRelativePath(base: string | undefined, path: string) { + if (!base) return + const rootPath = canonicalPickerPath(base) + const targetPath = canonicalPickerPath(path) + const insensitive = /^[A-Za-z]:\//.test(rootPath) || rootPath.startsWith("//") + const root = insensitive ? rootPath.toLowerCase() : rootPath + const target = insensitive ? targetPath.toLowerCase() : targetPath + if (target === root) return "" + const prefix = root.endsWith("/") ? root : root + "/" + if (!target.startsWith(prefix)) return + return targetPath.slice(prefix.length) +} + +export function currentPickerSuggestions(result: { query: string; items: readonly T[] } | undefined, query: string) { + if (result?.query !== query) return [] + return result.items +} + +export function preloadTreeDirectories( + parent: string, + nodes: ReadonlyArray<{ name: string; type: "file" | "directory" }>, +) { + return treeEntries( + parent, + nodes.filter((node) => node.type === "directory"), + ) +} + +export function advanceTreePreload(advanced: Set, path: string) { + if (advanced.has(path)) return false + advanced.add(path) + return true +} + +export function activeTreeNavigation(request: number, current: number) { + return request === current +} + +export function createPriorityTaskQueue(concurrency: number) { + type Job = { + key: string + priority: "user" | "background" + promise: Promise + run: () => void + } + + const jobs = new Map() + const user: Job[] = [] + const background: Job[] = [] + let active = 0 + + const drain = () => { + while (active < concurrency) { + const job = user.pop() ?? background.shift() + if (!job) return + active++ + job.run() + } + } + + const schedule = (key: string, priority: Job["priority"], task: () => Promise) => { + const existing = jobs.get(key) + if (existing) { + if (priority === "user") promote(key) + return existing.promise + } + + const deferred = Promise.withResolvers() + const job: Job = { + key, + priority, + promise: deferred.promise, + run: () => { + const complete = () => { + active-- + jobs.delete(key) + drain() + } + Promise.resolve() + .then(task) + .then( + (value) => { + complete() + deferred.resolve(value) + }, + (error) => { + complete() + deferred.reject(error) + }, + ) + }, + } + jobs.set(key, job) + ;(priority === "user" ? user : background).push(job) + drain() + return job.promise + } + + const promote = (key: string) => { + const job = jobs.get(key) + if (!job || job.priority === "user") return + const index = background.indexOf(job) + if (index === -1) return + background.splice(index, 1) + job.priority = "user" + user.push(job) + } + + return { schedule, promote } +} + +export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) { + return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta)) +} + +export function nextSuggestionIndex(current: number, delta: -1 | 1, count: number) { + if (count === 0) return -1 + return (current + delta + count) % count +} + +export function absoluteTreePath(root: string, path: string) { + const base = trimPickerPath(root) + const relative = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") + if (!relative) return base || "/" + if (!base || base === "/") return "/" + relative + if (base.endsWith("/")) return base + relative + return `${base}/${relative}` +} + +export function selectedTreePath(root: string, path: string, mode: "directory" | "file", base?: string) { + const directory = path.endsWith("/") + if (mode === "file") { + if (directory) return + if (!base) return path + const absolute = absoluteTreePath(root, path) + return pickerRelativePath(base, absolute) + } + return directory ? nativePickerPath(absoluteTreePath(root, path)) : undefined +} + +export function nativePickerPath(path: string) { + const value = trimPickerPath(path) + if (/^[A-Za-z]:\//.test(value) || value.startsWith("//")) return value.replaceAll("/", "\\") + return value +} +import { getFilename } from "@opencode-ai/core/util/path" +import fuzzysort from "fuzzysort" +import { ServerSDK } from "@/context/server-sdk" + +export function cleanPickerInput(value: string) { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() +} + +export function normalizePickerPath(input: string) { + const value = input.replaceAll("\\", "/") + if (value.startsWith("//") && !value.startsWith("///")) return "//" + value.slice(2).replace(/\/+/g, "/") + return value.replace(/\/+/g, "/") +} + +export function normalizePickerDrive(input: string) { + const value = normalizePickerPath(input) + if (/^[A-Za-z]:$/.test(value)) return value + "/" + return value +} + +export function trimPickerPath(input: string) { + const value = normalizePickerDrive(input) + if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value + return value.replace(/\/+$/, "") +} + +export function joinPickerPath(base: string | undefined, relative: string) { + const root = trimPickerPath(base ?? "") + const path = trimPickerPath(relative).replace(/^\/+/, "") + if (!root) return path + if (!path) return root + if (root.endsWith("/")) return root + path + return root + "/" + path +} + +export function pickerRoot(input: string) { + const value = normalizePickerDrive(input) + if (value.startsWith("//")) { + const [server, share] = value.slice(2).split("/") + if (server && share) return `//${server}/${share}` + return "//" + } + if (value.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(value)) return value.slice(0, 3) + return "" +} + +export function pickerParent(input: string) { + const value = trimPickerPath(input) + const root = pickerRoot(value) + if (value === root) return value + if (value === "/" || value === "//" || /^[A-Za-z]:\/$/.test(value)) return value + const index = value.lastIndexOf("/") + if (index < root.length) return root + if (index <= 0) return "/" + if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3) + return value.slice(0, index) +} + +function pickerTilde(absolute: string, home: string) { + const path = trimPickerPath(absolute) + if (!home) return "" + const root = trimPickerPath(home) + if (/^[A-Za-z]:\//.test(root)) return "" + if (path === root) return "~" + if (path.startsWith(root + "/")) return "~" + path.slice(root.length) + return "" +} + +export function displayPickerPath(path: string, input: string, home: string) { + const value = trimPickerPath(path) + if (/^[A-Za-z]:\//.test(trimPickerPath(home)) || /^[A-Za-z]:\//.test(value)) return value.replaceAll("/", "\\") + return pickerTilde(value, home) || value +} + +export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) { + const cache = new Map>>() + let current = 0 + + const scoped = (value: string) => { + const raw = normalizePickerDrive(value) + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } + const base = args.base() + if (!base) return + if (!raw) return { directory: trimPickerPath(base), path: "" } + const home = args.home() + if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } + if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } + return { directory: trimPickerPath(base), path: raw } + } + + const directories = async (directory: string) => { + const key = trimPickerPath(directory) + const existing = cache.get(key) + if (existing) return existing + const request = args.sdk.api.file + .list({ location: { directory: key } }) + .then((result) => result.data) + .catch(() => []) + .then((nodes) => + nodes + .filter((node) => node.type === "directory") + .map((node) => { + const relative = trimPickerPath(normalizePickerDrive(node.path)) + return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } + }), + ) + cache.set(key, request) + return request + } + + const match = async (directory: string, query: string, limit: number) => { + const items = await directories(directory) + if (!query) return items.slice(0, limit).map((item) => item.absolute) + return fuzzysort.go(query, items, { key: "name", limit }).map((item) => item.obj.absolute) + } + + return async (filter: string) => { + const token = ++current + const active = () => token === current + const value = cleanPickerInput(filter) + const input = scoped(value) + if (!input) return [] as string[] + const raw = normalizePickerDrive(value) + const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") + const query = normalizePickerDrive(input.path) + if (!pathInput) { + const results = await args.sdk.api.file + .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) + .then((result) => result.data.map((entry) => entry.path)) + .catch(() => []) + if (!active()) return [] + if (results.length) { + return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) + } + const fallback = query + ? await match(input.directory, query, 50) + : (await directories(input.directory)).map((item) => item.absolute) + if (!active()) return [] + return fallback + } + const segments = query.replace(/^\/+/, "").split("/") + const head = segments.slice(0, -1).filter((part) => part && part !== ".") + const tail = segments.at(-1) ?? "" + let paths = [input.directory] + for (const part of head) { + if (!active()) return [] + if (part === "..") { + paths = paths.map(pickerParent) + continue + } + paths = Array.from(new Set((await Promise.all(paths.map((path) => match(path, part, 4)))).flat())).slice(0, 12) + if (!active() || paths.length === 0) return [] + } + const matches = Array.from(new Set((await Promise.all(paths.map((path) => match(path, tail, 50)))).flat())) + if (!active()) return [] + const base = raw.startsWith("~") ? trimPickerPath(input.directory) : "" + if (raw.endsWith("/") || !tail) return Array.from(new Set([base, ...matches].filter(Boolean))).slice(0, 50) + const target = matches.find((path) => getFilename(path).toLowerCase() === tail.toLowerCase()) + if (!target) return matches.slice(0, 50) + const children = await match(target, "", 30) + if (!active()) return [] + return Array.from(new Set([base, ...matches, ...children].filter(Boolean))).slice(0, 50) + } +} diff --git a/packages/app/src/components/directory-picker-policy.ts b/packages/app/src/components/directory-picker-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b3e20f68e51618b6d7203987d3e8d8a8afbccaf --- /dev/null +++ b/packages/app/src/components/directory-picker-policy.ts @@ -0,0 +1,7 @@ +import { ServerConnection } from "@/context/server" +import type { Platform } from "@/context/platform" + +export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) { + if (platform === "desktop" && ServerConnection.local(server)) return "native" as const + return "server" as const +} diff --git a/packages/app/src/components/directory-picker.test.ts b/packages/app/src/components/directory-picker.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..98cf605c6435dcd92368ef11f83e1e05f0a8e506 --- /dev/null +++ b/packages/app/src/components/directory-picker.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test" +import { directoryPickerKind } from "./directory-picker-policy" + +const local = { + type: "sidecar", + variant: "base", + http: { url: "http://localhost:4096" }, +} as const +const remote = { + type: "ssh", + host: "example.test", + http: { url: "http://localhost:4096" }, +} as const + +describe("directoryPickerKind", () => { + test("uses the native picker only for local desktop projects", () => { + expect(directoryPickerKind("desktop", local)).toBe("native") + expect(directoryPickerKind("desktop", remote)).toBe("server") + expect(directoryPickerKind("web", local)).toBe("server") + }) +}) diff --git a/packages/app/src/components/directory-picker.tsx b/packages/app/src/components/directory-picker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31b15b7a6eb24cc07cba0bd32b73a7ff11b433fa --- /dev/null +++ b/packages/app/src/components/directory-picker.tsx @@ -0,0 +1,45 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ServerConnection } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { lazy } from "solid-js" +import { DialogSelectDirectory } from "./dialog-select-directory" +import { directoryPickerKind } from "./directory-picker-policy" + +const DialogSelectDirectoryV2 = lazy(() => + import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })), +) + +type DirectoryPickerInput = { + server: ServerConnection.Any + title?: string + multiple?: boolean + onSelect: (result: string | string[] | null) => void +} + +export function useDirectoryPicker() { + const platform = usePlatform() + const settings = useSettings() + const dialog = useDialog() + + return (input: DirectoryPickerInput) => { + if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") { + void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect) + return + } + + let selected = false + const onSelect = (result: string | string[] | null) => { + selected = result !== null + input.onSelect(result) + } + const cancel = () => { + if (!selected) input.onSelect(null) + } + if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) { + dialog.show(() => , cancel) + return + } + dialog.show(() => , cancel) + } +} diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c5d576e934798ef9ee260aae74140a77f5e5d37 --- /dev/null +++ b/packages/app/src/components/edit-project.ts @@ -0,0 +1,133 @@ +import { getFilename } from "@opencode-ai/core/util/path" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMutation } from "@tanstack/solid-query" +import { normalizeProjectInfo } from "@/context/global-sync/utils" +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useGlobal } from "@/context/global" +import { type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" + +export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) { + const dialog = useDialog() + const global = useGlobal() + const serverCtx = createMemo(() => global.ensureServerCtx(props.server)) + const folderName = createMemo(() => getFilename(props.project.worktree)) + const defaultName = createMemo(() => props.project.name || folderName()) + const [store, setStore] = createStore({ + name: defaultName(), + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, + startup: props.project.commands?.start ?? "", + dragOver: false, + iconHover: false, + }) + let iconInput: HTMLInputElement | undefined + + function selectFile(file: File) { + if (!file.type.startsWith("image/")) return + const reader = new FileReader() + reader.onload = (event) => { + const result = event.target?.result + if (typeof result !== "string") return + setStore("iconOverride", result) + setStore("iconHover", false) + } + reader.readAsDataURL(file) + } + + function drop(event: DragEvent) { + event.preventDefault() + setStore("dragOver", false) + const file = event.dataTransfer?.files[0] + if (file) selectFile(file) + } + + function dragOver(event: DragEvent) { + event.preventDefault() + setStore("dragOver", true) + } + + function dragLeave() { + setStore("dragOver", false) + } + + function inputChange(event: Event) { + const file = (event.currentTarget as HTMLInputElement).files?.[0] + if (file) selectFile(file) + } + + function iconClick() { + if (store.iconOverride && store.iconHover) { + setStore("iconOverride", "") + return + } + iconInput?.click() + } + + const save = useMutation(() => ({ + mutationFn: async () => { + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() + + if (props.project.id && props.project.id !== "global") { + if ((await serverCtx().sdk.protocol) !== "v1") return + const project = await serverCtx() + .sdk.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + .then((result) => result.data) + if (!project) return + // const project = await serverCtx().sdk.api.project.update({ + // projectID: props.project.id, + // name, + // icon: { color: store.color || "", override: store.iconOverride || "" }, + // commands: { start }, + // }) + serverCtx().sync.set("project", (items) => + items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), + ) + serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) + dialog.close() + return + } + + serverCtx().sync.project.meta(props.project.worktree, { + name, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }, + })) + + function submit(event: SubmitEvent) { + event.preventDefault() + if (save.isPending) return + save.mutate() + } + + return { + store, + setStore, + folderName, + defaultName, + save, + submit, + drop, + dragOver, + dragLeave, + inputChange, + iconClick, + close() { + dialog.close() + }, + setIconInput(input: HTMLInputElement) { + iconInput = input + }, + } +} diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx new file mode 100644 index 0000000000000000000000000000000000000000..133e752eab3fd8fe3b26acd284db4979e2f3bb29 --- /dev/null +++ b/packages/app/src/components/external-link.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, splitProps } from "solid-js" + +export interface ExternalLinkProps extends Omit, "href"> { + href: string +} + +export function ExternalLink(props: ExternalLinkProps) { + const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"]) + + return ( + + {local.children} + + ) +} diff --git a/packages/app/src/components/file-tree-v2-model.test.ts b/packages/app/src/components/file-tree-v2-model.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2a63fe86d3de94dd34815b6769aa0eabdaf62d6 --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test" +import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model" +import type { FileNode } from "@opencode-ai/sdk/v2" + +describe("buildFileTreeV2Model", () => { + test("builds a sorted tree and flattens expanded directories", () => { + const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"]) + + expect(model.total).toBe(8) + expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([ + ["docs", "directory", 0], + ["docs/guide.md", "file", 1], + ["src", "directory", 0], + ["src/lib", "directory", 1], + ["src/lib/a.ts", "file", 2], + ["src/lib/b.ts", "file", 2], + ["src/z.ts", "file", 1], + ["README.md", "file", 0], + ]) + }) + + test("skips children of collapsed directories", () => { + const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"]) + + expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([ + "src", + "src/lib", + "src/z.ts", + ]) + }) + + test("normalizes duplicate and messy paths", () => { + const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"]) + const rows = flattenFileTreeV2(model, () => true) + + expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"]) + expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts") + }) + + test("handles deeply nested paths", () => { + const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts" + const model = buildFileTreeV2Model([file]) + + expect(flattenFileTreeV2(model, () => true)).toHaveLength(131) + }) +}) + +describe("flattenLiveFileTreeV2", () => { + test("flattens live children using original paths for nested lookups", () => { + const nodes: Record = { + "": [ + { name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false }, + { name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false }, + ], + src: [ + { name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false }, + { name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false }, + ], + "src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }], + } + + expect( + flattenLiveFileTreeV2( + (path) => nodes[path] ?? [], + (path) => path === "src", + ).map((row) => [row.node.path, row.node.originalPath, row.level]), + ).toEqual([ + ["src", "src", 0], + ["src/a.ts", "src/a.ts", 1], + ["src/lib", "src/lib", 1], + ["README.md", "README.md", 0], + ]) + }) +}) diff --git a/packages/app/src/components/file-tree-v2-model.ts b/packages/app/src/components/file-tree-v2-model.ts new file mode 100644 index 0000000000000000000000000000000000000000..27783a9bb16bafa7e0ebe871b8aa6d1daa6ec495 --- /dev/null +++ b/packages/app/src/components/file-tree-v2-model.ts @@ -0,0 +1,107 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +export type FileTreeV2Model = { + children: ReadonlyMap + total: number +} + +export type FileTreeV2Node = FileNode & { originalPath: string } + +export type FileTreeV2Row = { + node: FileTreeV2Node + level: number +} + +export function normalizeFileTreeV2Path(value: string) { + return value + .replaceAll("\\", "/") + .replace(/^\/+|\/+$/g, "") + .replace(/\/{2,}/g, "/") +} + +export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model { + const nodes = new Map() + + paths.forEach((value) => { + const file = normalizeFileTreeV2Path(value) + if (!file) return + + const parts = file.split("/") + parts.forEach((name, index) => { + const path = parts.slice(0, index + 1).join("/") + if (nodes.has(path)) return + nodes.set(path, { + name, + path, + absolute: path, + type: index === parts.length - 1 ? "file" : "directory", + ignored: false, + originalPath: index === parts.length - 1 ? value : path, + }) + }) + }) + + const children = new Map() + nodes.forEach((node) => { + const index = node.path.lastIndexOf("/") + const parent = index === -1 ? "" : node.path.slice(0, index) + const list = children.get(parent) + if (list) list.push(node) + else children.set(parent, [node]) + }) + children.forEach((nodes) => + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 + return a.name.localeCompare(b.name) + }), + ) + + return { children, total: nodes.size } +} + +export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) { + const rows: FileTreeV2Row[] = [] + const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const children = model.children.get(row.node.path) ?? [] + for (let index = children.length - 1; index >= 0; index--) { + stack.push({ node: children[index]!, level: row.level + 1 }) + } + } + + return rows +} + +export function flattenLiveFileTreeV2( + children: (path: string) => readonly FileNode[], + expanded: (path: string) => boolean, +) { + const rows: FileTreeV2Row[] = [] + const stack = children("") + .toReversed() + .map((node) => ({ node: toLiveNode(node), level: 0 })) + + while (stack.length > 0) { + const row = stack.pop()! + rows.push(row) + if (row.node.type !== "directory" || !expanded(row.node.path)) continue + const nested = children(row.node.originalPath) + for (let index = nested.length - 1; index >= 0; index--) { + stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 }) + } + } + + return rows +} + +function toLiveNode(node: FileNode): FileTreeV2Node { + return { + ...node, + path: normalizeFileTreeV2Path(node.path), + originalPath: node.path, + } +} diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx new file mode 100644 index 0000000000000000000000000000000000000000..091e489c32c418a01d41243591ba4100772aab76 --- /dev/null +++ b/packages/app/src/components/file-tree-v2.tsx @@ -0,0 +1,301 @@ +import { useFile } from "@/context/file" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import "@opencode-ai/ui/v2/file-tree-v2.css" +import { + createEffect, + createMemo, + createSignal, + For, + Show, + splitProps, + type ComponentProps, + type ParentProps, +} from "solid-js" +import { Dynamic } from "solid-js/web" +import type { FileNode } from "@opencode-ai/sdk/v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree" +import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" +import { + buildFileTreeV2Model, + flattenFileTreeV2, + flattenLiveFileTreeV2, + normalizeFileTreeV2Path, + type FileTreeV2Node, +} from "@/components/file-tree-v2-model" +import { virtualScrollElement } from "@/components/virtual-scroll-element" + +export type { Kind } from "@/components/file-tree" + +const INDENT_STEP = 16 + +function rowPaddingStart(level: number, type: FileNode["type"]) { + if (type === "directory") return 8 + level * INDENT_STEP + if (level === 0) return 8 + return 8 + level * INDENT_STEP - INDENT_STEP +} + +function guideLineStart(level: number) { + return rowPaddingStart(level, "directory") + 8 +} + +export const kindLabel = (kind: Kind) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + return "M" +} + +export const kindChange = (kind: Kind) => { + if (kind === "add") return "added" + if (kind === "del") return "deleted" + return "modified" +} + +const FileTreeNodeV2 = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + draggable: boolean + kinds?: ReadonlyMap + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "draggable", + "kinds", + "as", + "children", + "class", + "classList", + ]) + const kind = () => local.kinds?.get(normalizeFileTreeV2Path(local.node.path)) + + return ( + { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...rest} + > + {local.children} + + {local.node.name} + + {(() => { + const value = kind() + if (!value || local.node.type !== "file") return null + return ( + + {kindLabel(value)} + + ) + })()} + + ) +} + +function GuideLines(props: { level: number }) { + return ( + + {(_, index) =>
} + + ) +} + +export default function FileTreeV2(props: { + active?: string + allowed?: readonly string[] + kinds?: ReadonlyMap + draggable?: boolean + onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void +}) { + const file = useFile() + const live = () => props.allowed === undefined + const draggable = () => props.draggable ?? true + const active = () => normalizeFileTreeV2Path(props.active ?? "") + const model = createMemo(() => (live() ? undefined : buildFileTreeV2Model(props.allowed ?? []))) + const expanded = (path: string) => file.tree.state(path)?.expanded ?? !live() + const rows = createMemo(() => { + if (live()) return flattenLiveFileTreeV2((path) => file.tree.children(path), expanded) + return flattenFileTreeV2(model()!, expanded) + }) + const [root, setRoot] = createSignal() + const [focused, setFocused] = createSignal() + const virtualizer = createVirtualizer({ + get count() { + return rows().length + }, + getScrollElement: () => virtualScrollElement(root()), + initialRect: { width: 0, height: 600 }, + estimateSize: () => 28, + gap: 2, + overscan: 10, + get getItemKey() { + const current = rows() + return (index: number) => current[index]?.node.path ?? index + }, + rangeExtractor: (range) => { + const indexes = defaultRangeExtractor(range) + const path = focused() + const index = path ? rows().findIndex((row) => row.node.path === path) : -1 + if (index < 0 || indexes.includes(index)) return indexes + return [...indexes, index].sort((a, b) => a - b) + }, + }) + + createEffect(() => { + if (!live()) return + void file.tree.list("") + }) + + // Only scroll when the active path changes (or first appears in the tree). + // Do not re-scroll when expand/collapse reshuffles `rows()`. + let scrolledActive: string | undefined + createEffect(() => { + const path = active() + if (!path) { + scrolledActive = undefined + return + } + const index = rows().findIndex((row) => row.node.path === path) + if (index < 0) return + if (scrolledActive === path) return + scrolledActive = path + queueMicrotask(() => { + const next = rows().findIndex((row) => row.node.path === path) + if (next < 0) return + if (virtualizer.range && next >= virtualizer.range.startIndex && next <= virtualizer.range.endIndex) return + virtualizer.scrollToIndex(next, { align: "auto" }) + }) + }) + + const selectFile = (node: FileTreeV2Node, action?: (file: FileNode) => void) => { + action?.({ + ...node, + path: node.originalPath, + absolute: node.originalPath, + }) + } + + const toggleDirectory = (path: string, originalPath: string) => { + if (expanded(path)) { + file.tree.collapse(originalPath) + return + } + file.tree.expand(originalPath, live() ? undefined : { list: false }) + } + + const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const))) + const virtualItemByKey = createMemo( + () => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)), + ) + const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) + + return ( +
+ + {(key) => ( + + {(item) => ( +
+ + {(row) => ( + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + onClick={() => selectFile(row().node, props.onFileClick)} + onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} + > + + 0}> +
+ + + + + + + } + > + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + aria-expanded={expanded(row().node.path)} + onClick={() => toggleDirectory(row().node.path, row().node.originalPath)} + > + +
+ +
+
+ + )} + +
+ )} +
+ )} + +
+ ) +} diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..20bffc41a3dc9950ddcfd47c55883662534d7918 --- /dev/null +++ b/packages/app/src/components/file-tree.test.ts @@ -0,0 +1,80 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" + +let shouldListRoot: typeof import("./file-tree").shouldListRoot +let shouldListExpanded: typeof import("./file-tree").shouldListExpanded +let dirsToExpand: typeof import("./file-tree").dirsToExpand + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, + useParams: () => ({}), + useLocation: () => ({}), + useSearchParams: () => [{}, () => undefined], + })) + mock.module("@/context/file", () => ({ + useFile: () => ({ + tree: { + state: () => undefined, + list: () => Promise.resolve(), + children: () => [], + expand: () => {}, + collapse: () => {}, + }, + }), + })) + mock.module("@opencode-ai/ui/collapsible", () => ({ + Collapsible: { + Trigger: (props: { children?: unknown }) => props.children, + Content: (props: { children?: unknown }) => props.children, + }, + })) + mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null })) + mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null })) + mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children })) + const mod = await import("./file-tree") + shouldListRoot = mod.shouldListRoot + shouldListExpanded = mod.shouldListExpanded + dirsToExpand = mod.dirsToExpand +}) + +describe("file tree fetch discipline", () => { + test("root lists on mount unless already loaded or loading", () => { + expect(shouldListRoot({ level: 0 })).toBe(true) + expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false) + expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false) + expect(shouldListRoot({ level: 1 })).toBe(false) + }) + + test("nested dirs list only when expanded and stale", () => { + expect(shouldListExpanded({ level: 1 })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true) + expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false) + expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false) + expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false) + }) + + test("allowed auto-expand picks only collapsed dirs", () => { + const expanded = new Set() + const filter = { dirs: new Set(["src", "src/components"]) } + + const first = dirsToExpand({ + level: 0, + filter, + expanded: (dir) => expanded.has(dir), + }) + + expect(first).toEqual(["src", "src/components"]) + + for (const dir of first) expanded.add(dir) + + const second = dirsToExpand({ + level: 0, + filter, + expanded: (dir) => expanded.has(dir), + }) + + expect(second).toEqual([]) + expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([]) + }) +}) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9853c0533f5c4a461e6b58a5fdfc62e0685b0baa --- /dev/null +++ b/packages/app/src/components/file-tree.tsx @@ -0,0 +1,509 @@ +import { useFile } from "@/context/file" +import { encodeFilePath } from "@/context/file/path" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { + createEffect, + createMemo, + For, + Match, + on, + Show, + splitProps, + Switch, + untrack, + type ComponentProps, + type ParentProps, +} from "solid-js" +import { Dynamic } from "solid-js/web" +import type { FileNode } from "@opencode-ai/sdk/v2" + +const MAX_DEPTH = 128 + +export function pathToFileUrl(filepath: string): string { + return `file://${encodeFilePath(filepath)}` +} + +export type Kind = "add" | "del" | "mix" + +export type Filter = { + files: Set + dirs: Set +} + +export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { + if (input.level !== 0) return false + if (input.dir?.loaded) return false + if (input.dir?.loading) return false + return true +} + +export function shouldListExpanded(input: { + level: number + dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean } +}) { + if (input.level === 0) return false + if (!input.dir?.expanded) return false + if (input.dir.loaded) return false + if (input.dir.loading) return false + return true +} + +export function dirsToExpand(input: { + level: number + filter?: { dirs: Set } + expanded: (dir: string) => boolean +}) { + if (input.level !== 0) return [] + if (!input.filter) return [] + return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) +} + +const kindLabel = (kind: Kind) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + return "M" +} + +const kindTextColor = (kind: Kind) => { + if (kind === "add") return "color: var(--icon-diff-add-base)" + if (kind === "del") return "color: var(--icon-diff-delete-base)" + return "color: var(--icon-diff-modified-base)" +} + +const kindDotColor = (kind: Kind) => { + if (kind === "add") return "background-color: var(--icon-diff-add-base)" + if (kind === "del") return "background-color: var(--icon-diff-delete-base)" + return "background-color: var(--icon-diff-modified-base)" +} + +export const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { + const kind = kinds?.get(node.path) + if (!kind) return + if (!marks?.has(node.path)) return + return kind +} + +const buildDragImage = (target: HTMLElement) => { + const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") + const text = target.querySelector("span") + if (!icon || !text) return + + const image = document.createElement("div") + image.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + image.style.position = "absolute" + image.style.top = "-1000px" + image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + return image +} + +export const withFileDragImage = (event: DragEvent) => { + const image = buildDragImage(event.currentTarget as HTMLElement) + if (!image) return + document.body.appendChild(image) + event.dataTransfer?.setDragImage(image, 0, 12) + setTimeout(() => document.body.removeChild(image), 0) +} + +const FileTreeNode = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + nodeClass?: string + draggable: boolean + kinds?: ReadonlyMap + marks?: Set + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "nodeClass", + "draggable", + "kinds", + "marks", + "as", + "children", + "class", + "classList", + ]) + const kind = () => visibleKind(local.node, local.kinds, local.marks) + const active = () => !!kind() && !local.node.ignored + const color = () => { + const value = kind() + if (!value) return + return kindTextColor(value) + } + + return ( + { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...rest} + > + {local.children} + + {local.node.name} + + {(() => { + const value = kind() + if (!value) return null + if (local.node.type === "file") { + return ( + + {kindLabel(value)} + + ) + } + return
+ })()} + + ) +} + +export default function FileTree(props: { + path: string + class?: string + nodeClass?: string + active?: string + level?: number + allowed?: readonly string[] + modified?: readonly string[] + kinds?: ReadonlyMap + draggable?: boolean + onFileClick?: (file: FileNode) => void + onFileDoubleClick?: (file: FileNode) => void + + _filter?: Filter + _marks?: Set + _deeps?: Map + _kinds?: ReadonlyMap + _chain?: readonly string[] +}) { + const file = useFile() + const level = props.level ?? 0 + const draggable = () => props.draggable ?? true + + const key = (p: string) => + file + .normalize(p) + .replace(/[\\/]+$/, "") + .replaceAll("\\", "/") + const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)] + + const filter = createMemo(() => { + if (props._filter) return props._filter + + const allowed = props.allowed + if (!allowed) return + + const files = new Set(allowed) + const dirs = new Set() + + for (const item of allowed) { + const parts = item.split("/") + const parents = parts.slice(0, -1) + for (const [idx] of parents.entries()) { + const dir = parents.slice(0, idx + 1).join("/") + if (dir) dirs.add(dir) + } + } + + return { files, dirs } + }) + + const marks = createMemo(() => { + if (props._marks) return props._marks + + const out = new Set() + for (const item of props.modified ?? []) out.add(item) + for (const item of props.kinds?.keys() ?? []) out.add(item) + if (out.size === 0) return + return out + }) + + const kinds = createMemo(() => { + if (props._kinds) return props._kinds + return props.kinds + }) + + const deeps = createMemo(() => { + if (props._deeps) return props._deeps + + const out = new Map() + + const root = props.path + if (!(file.tree.state(root)?.expanded ?? false)) return out + + const seen = new Set() + const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] + + const push = (dir: string, lvl: number) => { + const id = key(dir) + if (seen.has(id)) return + seen.add(id) + + const kids = file.tree + .children(dir) + .filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false)) + .map((node) => node.path) + + stack.push({ dir, lvl, i: 0, kids, max: lvl }) + } + + push(root, level - 1) + + while (stack.length > 0) { + const top = stack[stack.length - 1]! + + if (top.i < top.kids.length) { + const next = top.kids[top.i]! + top.i++ + push(next, top.lvl + 1) + continue + } + + out.set(top.dir, top.max) + stack.pop() + + const parent = stack[stack.length - 1] + if (!parent) continue + parent.max = Math.max(parent.max, top.max) + } + + return out + }) + + createEffect(() => { + const current = filter() + const dirs = dirsToExpand({ + level, + filter: current, + expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, + }) + for (const dir of dirs) file.tree.expand(dir) + }) + + createEffect( + on( + () => props.path, + (path) => { + const dir = untrack(() => file.tree.state(path)) + if (!shouldListRoot({ level, dir })) return + void file.tree.list(path) + }, + { defer: false }, + ), + ) + + const nodes = createMemo(() => { + const nodes = file.tree.children(props.path) + const current = filter() + if (!current) return nodes + + const parent = (path: string) => { + const idx = path.lastIndexOf("/") + if (idx === -1) return "" + return path.slice(0, idx) + } + + const leaf = (path: string) => { + const idx = path.lastIndexOf("/") + return idx === -1 ? path : path.slice(idx + 1) + } + + const out = nodes.filter((node) => { + if (node.type === "file") return current.files.has(node.path) + return current.dirs.has(node.path) + }) + + const seen = new Set(out.map((node) => node.path)) + + for (const dir of current.dirs) { + if (parent(dir) !== props.path) continue + if (seen.has(dir)) continue + out.push({ + name: leaf(dir), + path: dir, + absolute: dir, + type: "directory", + ignored: false, + }) + seen.add(dir) + } + + for (const item of current.files) { + if (parent(item) !== props.path) continue + if (seen.has(item)) continue + out.push({ + name: leaf(item), + path: item, + absolute: item, + type: "file", + ignored: false, + }) + seen.add(item) + } + + out.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + return out + }) + + return ( +
+ + {(node) => { + const expanded = () => file.tree.state(node.path)?.expanded ?? false + const deep = () => deeps().get(node.path) ?? -1 + const kind = () => visibleKind(node, kinds(), marks()) + const active = () => !!kind() && !node.ignored + + return ( + + + (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + > + + +
+ +
+
+
+ +
+ ...
} + > + + +
+
+
+ + props.onFileClick?.(node)} + onDblClick={() => props.onFileDoubleClick?.(node)} + > +
+ + + + + + + + + + + + + + + + + + ) + }} + +
+ ) +} diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e12b9e2145eb317742a5a319d9940f2b6d86edf0 --- /dev/null +++ b/packages/app/src/components/help-button.tsx @@ -0,0 +1,143 @@ +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { isRTL } from "@kobalte/core/i18n" +import { createSignal, Show } from "solid-js" +import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { useLanguage } from "@/context/language" +import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" +import homeImage from "@/assets/help/home.png" +import tabsImage from "@/assets/help/tabs.png" + +// TODO: wire to changelog / seen-state when available +const showPopover = () => true + +// can remove this after the tabs rollout has been out for a while +export function TabsInfoPopup() { + const settings = useSettings() + const platform = usePlatform() + const language = useLanguage() + const [drawerOpen, setDrawerOpen] = createSignal(false) + const windows = () => platform.platform === "desktop" && platform.os === "windows" + const rtl = () => isRTL(language.intl()) + + return ( + + +
+ + +
+
+ + + } + class="absolute top-[10px] start-[-36px]" + /> + +
+

+ {language.t("help.tabs.date")} +

+ + } + /> + +
+
+

+ {language.t("help.tabs.title")} +

+
+

{language.t("help.tabs.introduction")}

+ +

{language.t("help.tabs.sessions")}

+

{language.t("help.tabs.organize")}

+

{language.t("help.tabs.home")}

+ +

{language.t("help.tabs.persistence")}

+

{language.t("help.tabs.worktrees")}

+
+
+
+
+ ) +} diff --git a/packages/app/src/components/model-tooltip.tsx b/packages/app/src/components/model-tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7253c069173f3c1e200938426b988e6cfb36d866 --- /dev/null +++ b/packages/app/src/components/model-tooltip.tsx @@ -0,0 +1,124 @@ +import { Show, type Component, type JSX } from "solid-js" +import { useLanguage } from "@/context/language" + +type InputKey = "text" | "image" | "audio" | "video" | "pdf" +type InputMap = Record + +type ModelInfo = { + id: string + name: string + provider: { + name: string + } + capabilities?: { + reasoning: boolean + input: InputMap + } + modalities?: { + input: Array + } + reasoning?: boolean + limit: { + context: number + } +} + +function ModelTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { + return ( +
+ {props.name} + {props.value} +
+ ) +} + +export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?: boolean; v2?: boolean }> = ( + props, +) => { + const language = useLanguage() + const sourceName = (model: ModelInfo) => { + const value = `${model.id} ${model.name}`.toLowerCase() + + if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic") + if (/gpt|o[1-4]|codex|openai/.test(value)) return language.t("model.provider.openai") + if (/gemini|palm|bard|google/.test(value)) return language.t("model.provider.google") + if (/grok|xai/.test(value)) return language.t("model.provider.xai") + if (/llama|meta/.test(value)) return language.t("model.provider.meta") + + return model.provider.name + } + const inputLabel = (value: string) => { + if (value === "text") return language.t("model.input.text") + if (value === "image") return language.t("model.input.image") + if (value === "audio") return language.t("model.input.audio") + if (value === "video") return language.t("model.input.video") + if (value === "pdf") return language.t("model.input.pdf") + return value + } + const title = () => { + const tags: Array = [] + if (props.latest) tags.push(language.t("model.tag.latest")) + if (props.free) tags.push(language.t("model.tag.free")) + const suffix = tags.length ? ` (${tags.join(", ")})` : "" + return `${sourceName(props.model)} ${props.model.name}${suffix}` + } + const name = () => { + const tags: Array = [] + if (props.latest) tags.push(language.t("model.tag.latest")) + if (props.free) tags.push(language.t("model.tag.free")) + const suffix = tags.length ? ` (${tags.join(", ")})` : "" + return `${props.model.name}${suffix}` + } + const inputs = () => { + if (props.model.capabilities) { + const input = props.model.capabilities.input + const order: Array = ["text", "image", "audio", "video", "pdf"] + const entries = order.filter((key) => input[key]).map((key) => inputLabel(key)) + return entries.length ? entries.join(", ") : undefined + } + const raw = props.model.modalities?.input + if (!raw) return + const entries = raw.map((value) => inputLabel(value)) + return entries.length ? entries.join(", ") : undefined + } + const reasoning = () => { + if (props.model.capabilities) + return props.model.capabilities.reasoning + ? language.t("model.tooltip.reasoning.allowed") + : language.t("model.tooltip.reasoning.none") + return props.model.reasoning + ? language.t("model.tooltip.reasoning.allowed") + : language.t("model.tooltip.reasoning.none") + } + const context = () => language.t("model.tooltip.context", { limit: props.model.limit.context.toLocaleString() }) + const contextLimit = () => props.model.limit.context.toLocaleString(language.intl()) + + if (props.v2) { + return ( +
+ + + + {(value) => } + + + +
+ ) + } + + return ( +
+
{title()}
+ + {(value) => ( +
+ {language.t("model.tooltip.allows", { inputs: value() })} +
+ )} +
+
{reasoning()}
+
{context()}
+
+ ) +} diff --git a/packages/app/src/components/pierre-tree.test.ts b/packages/app/src/components/pierre-tree.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bd40a11d0e73ea94a708c732b16d5537dfdf260 --- /dev/null +++ b/packages/app/src/components/pierre-tree.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test" +import { FileTree, type FileTreeDirectoryHandle } from "@pierre/trees" + +test("reports directory expansion changes", () => { + const changes: Array<{ path: string; expanded: boolean }> = [] + const tree = new FileTree({ + paths: ["src/"], + onExpansionChange: (change) => changes.push(change), + }) + + const src = tree.getItem("src/") + if (!src || !src.isDirectory()) throw new Error("Expected src to be a directory") + const directory = src as FileTreeDirectoryHandle + + directory.expand() + directory.collapse() + + expect(changes).toEqual([ + { path: "src/", expanded: true }, + { path: "src/", expanded: false }, + ]) + tree.cleanUp() +}) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx new file mode 100644 index 0000000000000000000000000000000000000000..481b7922cf36d0d7e0b1a48e2ba45c2a00960037 --- /dev/null +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -0,0 +1,589 @@ +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" +import { createEffect, createMemo, on, Show } from "solid-js" +import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import type { PromptInputProps } from "@/components/prompt-input/contracts" +import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history" +import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store" +import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder" +import { createPromptSubmit } from "@/components/prompt-input/submit" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { usePermission } from "@/context/permission" +import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" +import { usePlatform } from "@/context/platform" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { createSessionTabs } from "@/pages/session/helpers" +import { showToast } from "@/utils/toast" +import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" +import { + createPromptInputV2Controller, + createPromptInputV2State, + type PromptInputV2Interaction, +} from "@opencode-ai/session-ui/v2/prompt-input/interaction" + +export type PromptInputV2ComposerProps = { + class?: string + controller: PromptInputV2ComposerController + borderUnderlay?: boolean +} + +export type PromptInputV2ControllerProps = Omit +export type PromptInputV2ComposerController = PromptInputV2Interaction & { + readonly model: PromptInputProps["controls"]["model"] +} + +export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { + const dialog = useDialog() + const command = useCommand() + const language = useLanguage() + + return ( +
+ + dialog.show(() => ) + } + /> + } + /> +
+ ) +} + +export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController { + const sdk = useSDK() + const sync = useSync() + const files = useFile() + const layout = useLayout() + const comments = useComments() + const dialog = useDialog() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + const prompt = props.state ?? usePrompt() + let editor: HTMLDivElement | undefined + + const interaction = createPromptInputV2State() + const mode = () => interaction[0].mode + const history = props.history ?? createPersistedPromptInputHistory() + const tabs = () => props.controls.session.tabs + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + const recent = createMemo(() => { + const all = tabs().all() + const active = activeFileTab() + const order = active ? [active, ...all.filter((tab) => tab !== active)] : all + return order.reduce((result, tab) => { + const path = files.pathFromTab(tab) + if (!path || result.includes(path)) return result + return [...result, path] + }, []) + }) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const attachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + const commentCount = createMemo(() => { + if (mode() === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + const blank = createMemo(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0 + }) + const stopping = createMemo(() => working() && blank()) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: mode(), + commentCount: commentCount(), + example: mode() === "shell" ? "git status" : "", + suggest: false, + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const designPlaceholder = () => + promptDesignPlaceholder(mode(), placeholder(), (key, params) => + language.t(key as Parameters[0], params as never), + ) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + const comment = item.comment?.trim() + if (!comment) return [] + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + const restoreHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file", + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const accepting = createMemo(() => { + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) + }) + const submission = createPromptSubmit({ + prompt, + info, + imageAttachments: attachments, + commentCount, + autoAccept: accepting, + mode, + working, + editor: () => editor, + queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })), + promptLength, + addToHistory: (value, mode) => controller.addHistory(value, mode), + resetHistoryNavigation: () => controller.resetHistory(), + setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }), + setPopover: (popover) => { + if (!popover) controller.dispatch({ type: "popover.close" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + const references = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map((reference) => ({ + id: `reference:${reference.name}`, + kind: "reference" as const, + label: `@${reference.name}`, + path: reference.path, + description: reference.description ?? referenceDescription(reference), + mention: { + type: "file" as const, + path: reference.path, + content: `@${reference.name}`, + start: 0, + end: 0, + mime: "application/x-directory", + filename: reference.name, + }, + })), + ) + const resources = createMemo(() => + Object.values(sync().data.mcp_resource).map((resource) => ({ + id: `resource:${resource.server}:${resource.uri}`, + kind: "resource" as const, + label: `@${resource.name}`, + path: resource.uri, + description: resource.description, + mention: { + type: "file" as const, + path: resource.uri, + content: `@${resource.name}`, + start: 0, + end: 0, + mime: resource.mimeType ?? "text/plain", + filename: resource.name, + url: resource.uri, + source: { + type: "resource" as const, + text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, + clientName: resource.server, + uri: resource.uri, + }, + }, + resource, + })), + ) + const context = createMemo(() => [ + ...references(), + ...props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent) => ({ + id: `agent:${agent.name}`, + kind: "agent" as const, + label: `@${agent.name}`, + mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 }, + })), + ...resources(), + ...recent().map((path) => ({ + id: `file:${path}`, + kind: "file" as const, + label: path, + path, + recent: true, + mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 }, + })), + ]) + const slashCommands = createMemo(() => [ + ...sync().data.command.map((item) => ({ + id: `custom.${item.name}`, + trigger: item.name, + title: item.name, + description: item.description, + type: "custom" as const, + })), + ...command.options + .filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash) + .map((item) => ({ + id: item.id, + trigger: item.slash!, + title: item.title, + description: item.description, + type: "builtin" as const, + })), + ]) + const commands = createMemo(() => + slashCommands().map((item) => ({ + id: item.id, + kind: "command", + label: `/${item.trigger}`, + trigger: item.trigger, + title: item.title, + description: item.description, + keybind: command.keybindParts(item.id), + })), + ) + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) + const controller = createPromptInputV2Controller({ + store: () => prompt.capture().store, + state: interaction, + identity: () => prompt.capture(), + history: { + entries: (mode) => + history.entries(mode).map((value) => { + const entry = normalizePromptHistoryEntry(value) + return { prompt: entry.prompt, metadata: entry.comments } + }), + add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()), + capture: historyComments, + restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]), + }, + commands, + context, + searchContextFiles: async (query) => + (await files.searchFilesAndDirectories(query)).map((path) => ({ + id: `file:${path}`, + kind: "file", + label: path, + path, + mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 }, + })), + onContextRemove(item) { + if (item?.commentID) comments.remove(item.path, item.commentID) + }, + openAttachment: (attachment) => + dialog.show(() => ), + openContext(key) { + const item = controller.contextItem(key) + if (item) openComment(item, props, sync, layout, files, comments) + }, + onEditor(element) { + editor = element as HTMLDivElement + props.ref?.(editor) + }, + onSuggestionSelect(item) { + if (item.kind !== "command") return + const selected = slashCommands().find((entry) => entry.id === item.id) + if (!selected || selected.type === "custom") return + return () => command.trigger(selected.id, "slash") + }, + attachments: { + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + isDialogActive: () => !!dialog.active, + warn: () => + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }), + duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, + store: platform.draftStore?.putBlob, + }, + view: { + placeholder: designPlaceholder, + get agent() { + return props.controls.agents.visible && props.controls.agents.options.length > 0 + ? { + options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), + current: () => props.controls.agents.current, + onSelect: (value: string) => props.controls.agents.select(value), + keybind: () => command.keybindParts("agent.cycle"), + } + : undefined + }, + variant: { + options: () => variants().map((value) => ({ id: value, label: value })), + current: () => props.controls.model.selection.variant.current() ?? "default", + onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value), + keybind: () => command.keybindParts("model.variant.cycle"), + }, + submit: { + stopping, + working, + onSubmit: () => void submission.handleSubmit(new Event("submit")), + onStop: () => void submission.abort(), + }, + }, + }) + Object.defineProperty(controller, "model", { get: () => props.controls.model }) + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: controller.state.mode !== "normal", + onSelect: () => controller.attach(), + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: "mod+shift+x", + disabled: controller.state.mode === "shell", + onSelect: () => controller.dispatch({ type: "mode.shell" }), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: "mod+shift+e", + disabled: controller.state.mode === "normal", + onSelect: () => controller.dispatch({ type: "mode.normal" }), + }, + ]) + + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + prompt.context.items().forEach((item) => prompt.context.remove(item.key)) + edit.context.forEach((item) => + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }), + ) + controller.dispatch({ type: "mode.normal" }) + controller.resetHistory() + prompt.set(edit.prompt, promptLength(edit.prompt)) + controller.restoreFocus() + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + + return controller as PromptInputV2ComposerController +} + +function PromptInputV2ModelControl(props: { + loading: boolean + paid: boolean + title: string + keybind: string[] + model: PromptInputV2ComposerController["model"]["selection"] + providerID?: string + modelName: string + onClose: () => void + onUnpaidClick: () => void +}) { + const shouldAnimate = createMemo((previous) => previous ?? props.loading) + const content = () => ( + <> + + {(providerID) => ( + + )} + + {props.modelName} + + + + + ) + return ( + + + {props.title} + + + } + > + + {content()} + + } + > + ( + + {content()} + + )} + onClose={props.onClose} + /> + + + + ) +} + +function openComment( + item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }, + props: PromptInputV2ControllerProps, + sync: ReturnType, + layout: ReturnType, + files: ReturnType, + comments: ReturnType, +) { + if (!item.commentID) return + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + const queueFocus = (attempts = 6) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (attempts <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1) + }) + }) + } + const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined + const review = + item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path)) + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + if (review) { + layout.fileTree.setTab("changes") + props.controls.session.tabs.setActive("review") + queueFocus() + return + } + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + void props.controls.session.tabs.open(tab) + props.controls.session.tabs.setActive(tab) + void Promise.resolve(files.load(item.path)).finally(() => queueFocus()) +} diff --git a/packages/app/src/components/prompt-input.stories.tsx b/packages/app/src/components/prompt-input.stories.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0b9c26ce411bdffc4a4a8bfa64e964d231bb556f --- /dev/null +++ b/packages/app/src/components/prompt-input.stories.tsx @@ -0,0 +1,223 @@ +// @ts-nocheck +import { createStore } from "solid-js/store" +import type { Todo } from "@opencode-ai/sdk/v2" +import { createPromptState } from "@/context/prompt" +import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer" +import { createPromptInputHistory, PromptInput } from "./prompt-input" + +function createPromptInputStoryRuntime() { + const state = createPromptState() + return { + state, + history: createPromptInputHistory(), + submission: { + abort() {}, + handleSubmit(event: Event) { + event.preventDefault() + state.reset() + }, + }, + } +} + +function PromptInputExample() { + const input = createPromptInputStoryRuntime() + const [controls, setControls] = createStore({ + agent: "build", + variant: undefined as string | undefined, + comments: 0, + tabs: [] as string[], + activeTab: undefined as string | undefined, + reviewOpen: false, + }) + const storyModel = { + id: "claude-3-7-sonnet", + name: "Claude 3.7 Sonnet", + provider: { id: "anthropic", name: "Anthropic" }, + } + const model = { + current: () => storyModel, + list: () => [storyModel], + visible: () => true, + set: () => {}, + variant: { + list: () => ["fast", "thinking"], + current: () => controls.variant, + set: (variant?: string) => setControls("variant", variant), + }, + } + const inputControls = { + agents: { + available: [{ name: "review", hidden: false, mode: "subagent" }], + options: ["build", "review", "plan"], + get current() { + return controls.agent + }, + loading: false, + visible: true, + select: (agent?: string) => setControls("agent", agent ?? "build"), + }, + model: { + selection: model, + paid: true, + loading: false, + }, + session: { + id: "story-session", + tabs: { + active: () => controls.activeTab, + all: () => controls.tabs, + open: (tab: string) => setControls("tabs", (tabs) => (tabs.includes(tab) ? tabs : [...tabs, tab])), + setActive: (tab: string) => setControls("activeTab", tab), + }, + reviewPanel: { + opened: () => controls.reviewOpen, + open: () => setControls("reviewOpen", true), + }, + }, + } + const addReviewComment = () => { + const comment = controls.comments + 1 + setControls("comments", comment) + input.state.context.add({ + type: "file", + path: "src/components/prompt-input.tsx", + selection: { + startLine: 84 + comment, + startChar: 0, + endLine: 84 + comment, + endChar: 0, + }, + comment: `Review comment ${comment}`, + commentID: `review-comment-${comment}`, + commentOrigin: "review", + preview: "export const PromptInput = ...", + }) + } + + return ( +
+ +
+ +
+
+ ) +} + +const todos: Todo[] = [ + { id: "todo-1", content: "Inspect the session composer animation", status: "completed" }, + { id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" }, + { id: "todo-3", content: "Verify session navigation behavior", status: "pending" }, +] + +function PromptInputWithOpenDock() { + const input = createPromptInputStoryRuntime() + const [controls, setControls] = createStore({ + agent: "build", + activeTab: undefined as string | undefined, + todoCollapsed: false, + }) + const inputControls = { + agents: { + available: [], + options: ["build"], + get current() { + return controls.agent + }, + loading: false, + visible: true, + select: (agent?: string) => setControls("agent", agent ?? "build"), + }, + model: { + selection: { + current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }), + variant: { list: () => [], current: () => undefined, set: () => {} }, + }, + paid: true, + loading: false, + }, + session: { + id: "story-session", + tabs: { + active: () => controls.activeTab, + all: () => [], + open: () => {}, + setActive: (tab: string) => setControls("activeTab", tab), + }, + reviewPanel: { opened: () => false, open: () => {} }, + }, + } + const state = { + blocked: () => false, + questionRequest: () => undefined, + permissionRequest: () => undefined, + permissionResponding: () => false, + decide: () => {}, + todos: () => todos, + dock: () => true, + closing: () => false, + opening: () => false, + } + return ( + "story-session", + sessionID: () => "story-session", + prompt: input.state, + ready: () => true, + centered: () => false, + todo: { + collapsed: () => controls.todoCollapsed, + onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed), + }, + followup: () => undefined, + revert: () => undefined, + onResponseSubmit: () => {}, + openParent: () => {}, + setPromptRef: () => {}, + setDockRef: () => {}, + })} + promptInput={ + {}} + newSessionWorktree="" + onNewSessionWorktreeReset={() => {}} + /> + } + /> + ) +} + +export default { + title: "App/PromptInput", + id: "app-prompt-input", + component: PromptInput, +} + +export const Basic = { + render: () => ( +
+

Prompt Input

+ +
+ ), +} + +export const DockAlreadyOpen = { + render: () => ( +
+

Prompt Input with open Todo dock

+ +
+ ), +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..41cf7d30ca34432e8d5b472442a38a70ade6bf0f --- /dev/null +++ b/packages/app/src/components/prompt-input.tsx @@ -0,0 +1,1793 @@ +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { + createEffect, + on, + Component, + Show, + onCleanup, + createMemo, + createSignal, + createResource, + Switch, + Match, + type JSX, +} from "solid-js" +import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" +import { + ContentPart, + DEFAULT_PROMPT, + isCommentItem, + isPromptEqual, + Prompt, + usePrompt, + ImageAttachmentPart, + AgentPart, + FileAttachmentPart, +} from "@/context/prompt" +import { useLayout } from "@/context/layout" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useComments } from "@/context/comments" +import { Button } from "@opencode-ai/ui/button" +import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" +import { Icon } from "@opencode-ai/ui/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Select } from "@opencode-ai/ui/select" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" +import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2" +import { useCommand } from "@/context/command" +import { usePermission } from "@/context/permission" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { createSessionTabs } from "@/pages/session/helpers" +import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" +import { createPromptAttachments } from "./prompt-input/attachments" +import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" +import { + canNavigateHistoryAtCursor, + navigatePromptHistory, + type PromptHistoryComment, + type PromptHistoryEntry, + promptLength, +} from "./prompt-input/history" +import { + createPersistedPromptInputHistory, + createPromptInputHistory, + type PromptInputHistory, +} from "./prompt-input/history-store" +import { + type PromptInputControls, + type PromptInputProps, + type PromptInputState, + type PromptInputSubmission, +} from "./prompt-input/contracts" +import { createPromptSubmit } from "./prompt-input/submit" +import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +import { PromptContextItems } from "./prompt-input/context-items" +import { PromptImageAttachments } from "./prompt-input/image-attachments" +import { PromptDragOverlay } from "./prompt-input/drag-overlay" +import { promptPlaceholder } from "./prompt-input/placeholder" +import { createPromptInputTransientState } from "./prompt-input/transient-state" +import { showToast } from "@/utils/toast" +import { ImagePreview } from "@opencode-ai/ui/image-preview" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" + +export { createPromptInputHistory } +export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission } + +const EXAMPLES = [ + "prompt.example.1", + "prompt.example.2", + "prompt.example.3", + "prompt.example.4", + "prompt.example.5", + "prompt.example.6", + "prompt.example.7", + "prompt.example.8", + "prompt.example.9", + "prompt.example.10", + "prompt.example.11", + "prompt.example.12", + "prompt.example.13", + "prompt.example.14", + "prompt.example.15", + "prompt.example.16", + "prompt.example.17", + "prompt.example.18", + "prompt.example.19", + "prompt.example.20", + "prompt.example.21", + "prompt.example.22", + "prompt.example.23", + "prompt.example.24", + "prompt.example.25", +] as const + +export const PromptInput: Component = (props) => { + const sdk = useSDK() + + const sync = useSync() + const files = useFile() + const prompt = props.state ?? usePrompt() + const layout = useLayout() + const comments = useComments() + const dialog = useDialog() + const command = useCommand() + const permission = usePermission() + const language = useLanguage() + const platform = usePlatform() + const tabs = () => props.controls.session.tabs + let editorRef!: HTMLDivElement + let fileInputRef: HTMLInputElement | undefined + let scrollRef!: HTMLDivElement + let slashPopoverRef!: HTMLDivElement + let restoreEndOnFocus = true + let savedCursor: number | null = null + + const mirror = { input: false } + const inset = 56 + const space = `${inset}px` + + const scrollCursorIntoView = () => { + const container = scrollRef + const selection = window.getSelection() + if (!container || !selection || selection.rangeCount === 0) return + + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return + + const cursor = getCursorPosition(editorRef) + const length = promptLength(prompt.current().filter((part) => part.type !== "image")) + if (cursor >= length) { + container.scrollTop = container.scrollHeight + return + } + + const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect() + if (!rect.height) return + + const containerRect = container.getBoundingClientRect() + const top = rect.top - containerRect.top + container.scrollTop + const bottom = rect.bottom - containerRect.top + container.scrollTop + const padding = 12 + + if (top < container.scrollTop + padding) { + container.scrollTop = Math.max(0, top - padding) + return + } + + if (bottom > container.scrollTop + container.clientHeight - inset) { + container.scrollTop = bottom - container.clientHeight + inset + } + } + + const queueScroll = (count = 2) => { + requestAnimationFrame(() => { + scrollCursorIntoView() + if (count > 1) queueScroll(count - 1) + }) + } + + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + + const commentInReview = (path: string) => { + const sessionID = props.controls.session.id + if (!sessionID) return false + + const diffs = sync().data.session_diff[sessionID] + if (!diffs) return false + return diffs.some((diff) => diff.file === path) + } + + const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => { + if (!item.commentID) return + + const focus = { file: item.path, id: item.commentID } + comments.setActive(focus) + + const queueCommentFocus = (attempts = 6) => { + const schedule = (left: number) => { + requestAnimationFrame(() => { + comments.setFocus({ ...focus }) + if (left <= 0) return + requestAnimationFrame(() => { + const current = comments.focus() + if (!current) return + if (current.file !== focus.file || current.id !== focus.id) return + schedule(left - 1) + }) + }) + } + + schedule(attempts) + } + + const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) + if (wantsReview) { + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + layout.fileTree.setTab("changes") + tabs().setActive("review") + queueCommentFocus() + return + } + + if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open() + layout.fileTree.setTab("all") + const tab = files.tab(item.path) + void tabs().open(tab) + tabs().setActive(tab) + void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) + } + + const recent = createMemo(() => { + const all = tabs().all() + const active = activeFileTab() + const order = active ? [active, ...all.filter((x) => x !== active)] : all + const seen = new Set() + const paths: string[] = [] + + for (const tab of order) { + const path = files.pathFromTab(tab) + if (!path) continue + if (seen.has(path)) continue + seen.add(path) + paths.push(path) + } + + return paths + }) + const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined)) + const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? "")) + const imageAttachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), + ) + + const [store, setStore] = createPromptInputTransientState( + () => prompt.capture(), + Math.floor(Math.random() * EXAMPLES.length), + ) + const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) + const motion = (value: number) => ({ + opacity: value, + transform: `scale(${0.98 + value * 0.02})`, + filter: `blur(${(1 - value) * 2}px)`, + "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const), + }) + const buttons = createMemo(() => motion(buttonsSpring())) + const shell = createMemo(() => motion(1 - buttonsSpring())) + const control = createMemo(() => ({ height: "28px", ...buttons() })) + + const commentCount = createMemo(() => { + if (store.mode === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + const blank = createMemo(() => { + const text = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 + }) + const stopping = createMemo(() => working() && blank()) + const tip = () => { + if (stopping()) { + return ( +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+ ) + } + + return ( +
+ {language.t("prompt.action.send")} + +
+ ) + } + + const contextItems = createMemo(() => { + const items = prompt.context.items() + if (store.mode !== "shell") return items + return items.filter((item) => !item.comment?.trim()) + }) + + const hasUserPrompt = createMemo(() => { + const sessionID = props.controls.session.id + if (!sessionID) return false + const messages = sync().data.message[sessionID] + if (!messages) return false + return messages.some((m) => m.role === "user") + }) + + const history = props.history ?? createPersistedPromptInputHistory() + + const suggest = createMemo(() => !hasUserPrompt()) + + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "", + suggest: suggest(), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + + const historyComments = () => { + const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) + return prompt.context.items().flatMap((item) => { + if (item.type !== "file") return [] + const comment = item.comment?.trim() + if (!comment) return [] + + const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined + const nextSelection = + selection ?? + (item.selection + ? ({ + start: item.selection.startLine, + end: item.selection.endLine, + } satisfies SelectedLineRange) + : undefined) + if (!nextSelection) return [] + + return [ + { + id: item.commentID ?? item.key, + path: item.path, + selection: { ...nextSelection }, + comment, + time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), + origin: item.commentOrigin, + preview: item.preview, + } satisfies PromptHistoryComment, + ] + }) + } + + const applyHistoryComments = (items: PromptHistoryComment[]) => { + comments.replace( + items.map((item) => ({ + id: item.id, + file: item.path, + selection: { ...item.selection }, + comment: item.comment, + time: item.time, + })), + ) + prompt.context.replaceComments( + items.map((item) => ({ + type: "file" as const, + path: item.path, + selection: selectionFromLines(item.selection), + comment: item.comment, + commentID: item.id, + commentOrigin: item.origin, + preview: item.preview, + })), + ) + } + + const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => { + const p = entry.prompt + const length = position === "start" ? 0 : promptLength(p) + setStore("applyingHistory", true) + applyHistoryComments(entry.comments) + prompt.set(p, length) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, length) + setStore("applyingHistory", false) + queueScroll() + }) + } + + const getCaretState = () => { + const selection = window.getSelection() + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } + return { + collapsed: selection.isCollapsed, + cursorPosition: getCursorPosition(editorRef), + textLength, + } + } + + const escBlur = () => platform.platform === "desktop" && platform.os === "macos" + + const pick = () => { + pickAttachmentFiles({ + picker: platform.openAttachmentPickerDialog, + directory: () => sdk().directory, + fallback: () => fileInputRef?.click(), + onFile: addAttachment, + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + }) + } + + const setMode = (mode: "normal" | "shell") => { + setStore("mode", mode) + setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) + requestAnimationFrame(() => editorRef?.focus()) + } + + const shellModeKey = "mod+shift+x" + const normalModeKey = "mod+shift+e" + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: store.mode !== "normal", + onSelect: pick, + }, + { + id: "prompt.mode.shell", + title: language.t("command.prompt.mode.shell"), + category: language.t("command.category.session"), + keybind: shellModeKey, + disabled: store.mode === "shell", + onSelect: () => setMode("shell"), + }, + { + id: "prompt.mode.normal", + title: language.t("command.prompt.mode.normal"), + category: language.t("command.category.session"), + keybind: normalModeKey, + disabled: store.mode === "normal", + onSelect: () => setMode("normal"), + }, + ]) + + const closePopover = () => setStore({ popover: null, slashMenu: false, slashMenuQuery: "" }) + + const resetHistoryNavigation = (force = false) => { + if (!force && (store.historyIndex < 0 || store.applyingHistory)) return + setStore("historyIndex", -1) + setStore("savedPrompt", null) + } + + const clearEditor = () => { + editorRef.innerHTML = "" + } + + const setEditorText = (text: string) => { + clearEditor() + editorRef.textContent = text + } + + const focusEditorEnd = () => { + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const selection = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + }) + } + + const currentCursor = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null + return getCursorPosition(editorRef) + } + + const restoreFocus = () => { + requestAnimationFrame(() => { + const cursor = savedCursor ?? prompt.cursor() ?? promptLength(prompt.current()) + editorRef.focus() + setCursorPosition(editorRef, cursor) + queueScroll() + }) + } + + const handleFocus = () => { + if (!restoreEndOnFocus) return + restoreEndOnFocus = false + requestAnimationFrame(() => { + if (document.activeElement !== editorRef) return + setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current())) + queueScroll() + }) + } + + const renderEditorWithCursor = (parts: Prompt) => { + const cursor = currentCursor() + renderEditor(parts) + if (cursor !== null) setCursorPosition(editorRef, cursor) + } + + createEffect(() => { + props.controls.session.id + if (props.controls.session.id) return + if (!suggest()) return + const interval = setInterval(() => { + setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) + }, 6500) + onCleanup(() => clearInterval(interval)) + }) + + const [composing, setComposing] = createSignal(false) + const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 + + const handleBlur = () => { + const cursor = currentCursor() + savedCursor = cursor + if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor) + closePopover() + setComposing(false) + } + + const handleCompositionStart = () => { + setComposing(true) + } + + const handleCompositionEnd = () => { + setComposing(false) + requestAnimationFrame(() => { + if (composing()) return + reconcile(prompt.current().filter((part) => part.type !== "image")) + }) + } + + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + + const referenceList = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map( + (reference): AtOption => ({ + type: "reference", + name: reference.name, + path: reference.path, + display: reference.name, + description: reference.description ?? referenceDescription(reference), + }), + ), + ) + + const agentList = createMemo(() => + props.controls.agents.available + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), + ) + + const mcpResourceList = createMemo(() => + Object.values(sync().data.mcp_resource).map( + (resource): AtOption => ({ + type: "resource", + name: resource.name, + uri: resource.uri, + client: resource.server, + display: resource.name, + description: resource.description, + mime: resource.mimeType, + }), + ), + ) + + const handleAtSelect = (option: AtOption | undefined) => { + if (!option) return + if (option.type === "agent") { + addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) + return + } + if (option.type === "reference") { + addPart({ + type: "file", + path: option.path, + content: "@" + option.name, + start: 0, + end: 0, + mime: "application/x-directory", + filename: option.name, + }) + return + } + if (option.type === "resource") { + addPart({ + type: "file", + path: option.uri, + content: "@" + option.name, + start: 0, + end: 0, + mime: option.mime ?? "text/plain", + filename: option.name, + url: option.uri, + source: { + type: "resource", + text: { value: "@" + option.name, start: 0, end: 0 }, + clientName: option.client, + uri: option.uri, + }, + }) + return + } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + } + + const atKey = (x: AtOption | undefined) => { + if (!x) return "" + if (x.type === "agent") return `agent:${x.name}` + if (x.type === "reference") return `reference:${x.name}` + if (x.type === "resource") return `resource:${x.client}:${x.uri}` + return `file:${x.path}` + } + + const { + flat: atFlat, + active: atActive, + setActive: setAtActive, + onInput: atOnInput, + onKeyDown: atOnKeyDown, + } = useFilteredList({ + items: async (query) => { + const references = referenceList() + const agents = agentList() + const mcpResources = mcpResourceList() + const open = recent() + const seen = new Set(open) + const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] + const paths = await files.searchFilesAndDirectories(query) + const fileOptions: AtOption[] = paths + .filter((path) => !seen.has(path)) + .map((path) => ({ type: "file", path, display: path })) + return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] + }, + key: atKey, + filterKeys: ["display"], + skipFilter: (item) => item.type === "file" && !item.recent, + groupBy: (item) => { + if (item.type === "reference") return "reference" + if (item.type === "agent") return "agent" + if (item.type === "resource") return "resource" + if (item.recent) return "recent" + return "file" + }, + sortGroupsBy: (a, b) => { + const rank = (category: string) => { + if (category === "reference") return 0 + if (category === "agent") return 1 + if (category === "resource") return 2 + if (category === "recent") return 3 + return 4 + } + return rank(a.category) - rank(b.category) + }, + onSelect: handleAtSelect, + }) + + const slashCommands = createMemo(() => { + const builtin = command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + type: "builtin" as const, + })) + + const custom = sync().data.command.map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + // source: cmd.source, + })) + + return [...custom, ...builtin] + }) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + const menu = store.slashMenu + closePopover() + const images = imageAttachments() + + if (cmd.type === "custom") { + const text = `/${cmd.trigger} ` + if (menu) { + editorRef.focus() + setCursorPosition(editorRef, 0) + addPart({ type: "text", content: text, start: 0, end: text.length }) + focusEditorEnd() + return + } + setEditorText(text) + prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) + focusEditorEnd() + return + } + + if (menu) { + command.trigger(cmd.id, "slash") + return + } + + clearEditor() + prompt.set([...DEFAULT_PROMPT, ...images], 0) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + setActive: setSlashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList({ + items: slashCommands, + key: (x) => x?.id, + filterKeys: ["trigger", "title"], + onSelect: handleSlashSelect, + }) + + const createPill = (part: FileAttachmentPart | AgentPart) => { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", part.type) + if (part.type === "file") { + pill.setAttribute("data-path", part.path) + if (part.mime) pill.setAttribute("data-mime", part.mime) + if (part.filename) pill.setAttribute("data-filename", part.filename) + if (part.url) pill.setAttribute("data-url", part.url) + if (part.source?.type === "resource") { + pill.setAttribute("data-source-type", part.source.type) + pill.setAttribute("data-source-client-name", part.source.clientName) + pill.setAttribute("data-source-uri", part.source.uri) + } + } + if (part.type === "agent") pill.setAttribute("data-name", part.name) + pill.setAttribute("contenteditable", "false") + pill.style.userSelect = "text" + pill.style.cursor = "default" + return pill + } + + const isNormalizedEditor = () => + Array.from(editorRef.childNodes).every((node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (!text.includes("\u200B")) return true + if (text !== "\u200B") return false + + const prev = node.previousSibling + const next = node.nextSibling + const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" + return !!prevIsBr && !next + } + if (node.nodeType !== Node.ELEMENT_NODE) return false + const el = node as HTMLElement + if (el.dataset.type === "file") return true + if (el.dataset.type === "agent") return true + return el.tagName === "BR" + }) + + const renderEditor = (parts: Prompt) => { + clearEditor() + for (const part of parts) { + if (part.type === "text") { + editorRef.appendChild(createTextFragment(part.content)) + continue + } + if (part.type === "file" || part.type === "agent") { + editorRef.appendChild(createPill(part)) + } + } + + const last = editorRef.lastChild + if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") { + editorRef.appendChild(document.createTextNode("\u200B")) + } + } + + const scrollSlashActiveIntoView = () => { + const activeId = slashActive() + if (!activeId || !slashPopoverRef) return + + requestAnimationFrame(() => { + const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + } + const selectPopoverActive = () => { + if (store.popover === "at") { + const items = atFlat() + if (items.length === 0) return + const active = atActive() + const item = items.find((entry) => atKey(entry) === active) ?? items[0] + handleAtSelect(item) + return + } + + if (store.popover === "slash") { + const items = slashFlat() + if (items.length === 0) return + const active = slashActive() + const item = items.find((entry) => entry.id === active) ?? items[0] + handleSlashSelect(item) + } + } + + const reconcile = (input: Prompt) => { + if (mirror.input) { + mirror.input = false + if (isNormalizedEditor()) return + + renderEditorWithCursor(input) + return + } + + const dom = parseFromDOM() + if (isNormalizedEditor() && isPromptEqual(input, dom)) return + + renderEditorWithCursor(input) + } + + createEffect( + on( + () => prompt.current(), + (parts) => { + if (composing()) return + reconcile(parts.filter((part) => part.type !== "image")) + }, + ), + ) + + const parseFromDOM = (): Prompt => { + const parts: Prompt = [] + let position = 0 + let buffer = "" + + const flushText = () => { + let content = buffer + if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n") + if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") + buffer = "" + if (!content) return + parts.push({ type: "text", content, start: position, end: position + content.length }) + position += content.length + } + + const pushFile = (file: HTMLElement) => { + const content = file.textContent ?? "" + const source = + file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri + ? { + type: "resource" as const, + text: { + value: content, + start: position, + end: position + content.length, + }, + clientName: file.dataset.sourceClientName, + uri: file.dataset.sourceUri, + } + : undefined + parts.push({ + type: "file", + path: file.dataset.path!, + content, + start: position, + end: position + content.length, + ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), + ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), + ...(file.dataset.url ? { url: file.dataset.url } : {}), + ...(source ? { source } : {}), + }) + position += content.length + } + + const pushAgent = (agent: HTMLElement) => { + const content = agent.textContent ?? "" + parts.push({ + type: "agent", + name: agent.dataset.name!, + content, + start: position, + end: position + content.length, + }) + position += content.length + } + + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + buffer += node.textContent ?? "" + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + + const el = node as HTMLElement + if (el.dataset.type === "file") { + flushText() + pushFile(el) + return + } + if (el.dataset.type === "agent") { + flushText() + pushAgent(el) + return + } + if (el.tagName === "BR") { + buffer += "\n" + return + } + + for (const child of Array.from(el.childNodes)) { + visit(child) + } + } + + const children = Array.from(editorRef.childNodes) + children.forEach((child, index) => { + const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) + visit(child) + if (isBlock && index < children.length - 1) { + buffer += "\n" + } + }) + + flushText() + + if (parts.length === 0) parts.push(...DEFAULT_PROMPT) + return parts + } + + const handleInput = () => { + const rawParts = parseFromDOM() + const images = imageAttachments() + const cursorPosition = getCursorPosition(editorRef) + const rawText = + rawParts.length === 1 && rawParts[0]?.type === "text" + ? rawParts[0].content + : rawParts.map((p) => ("content" in p ? p.content : "")).join("") + const hasNonText = rawParts.some((part) => part.type !== "text") + const textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "") + const shouldReset = + textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !hasNonText && images.length === 0 + + if (shouldReset) { + closePopover() + resetHistoryNavigation() + if (prompt.dirty()) { + mirror.input = true + prompt.set(DEFAULT_PROMPT, 0) + } + queueScroll() + return + } + + const shellMode = store.mode === "shell" + + if (!shellMode) { + const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + const slashMatch = rawText.match(/^\/(\S*)$/) + + if (atMatch) { + atOnInput(atMatch[1]) + setStore({ popover: "at", slashMenu: false, slashMenuQuery: "" }) + } else if (slashMatch) { + slashOnInput(slashMatch[1]) + setStore({ popover: "slash", slashMenu: false, slashMenuQuery: "" }) + } else { + closePopover() + } + } else { + closePopover() + } + + resetHistoryNavigation() + + mirror.input = true + prompt.set([...rawParts, ...images], cursorPosition) + queueScroll() + } + + const addPart = (part: ContentPart) => { + if (part.type === "image") return false + + const selection = window.getSelection() + if (!selection) return false + + if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) { + editorRef.focus() + const cursor = prompt.cursor() ?? promptLength(prompt.current()) + setCursorPosition(editorRef, cursor) + } + + if (selection.rangeCount === 0) return false + const range = selection.getRangeAt(0) + if (!editorRef.contains(range.startContainer)) return false + + if (part.type === "file" || part.type === "agent") { + const cursorPosition = getCursorPosition(editorRef) + const rawText = prompt + .current() + .map((p) => ("content" in p ? p.content : "")) + .join("") + const textBeforeCursor = rawText.substring(0, cursorPosition) + const atMatch = textBeforeCursor.match(/@(\S*)$/) + const pill = createPill(part) + const gap = document.createTextNode(" ") + + if (atMatch) { + const start = atMatch.index ?? cursorPosition - atMatch[0].length + setRangeEdge(editorRef, range, "start", start) + setRangeEdge(editorRef, range, "end", cursorPosition) + } + + range.deleteContents() + range.insertNode(gap) + range.insertNode(pill) + range.setStartAfter(gap) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + if (part.type === "text") { + const fragment = createTextFragment(part.content) + const last = fragment.lastChild + range.deleteContents() + range.insertNode(fragment) + if (last) { + if (last.nodeType === Node.TEXT_NODE) { + const text = last.textContent ?? "" + if (text === "\u200B") { + range.setStart(last, 0) + } + if (text !== "\u200B") { + range.setStart(last, text.length) + } + } + if (last.nodeType !== Node.TEXT_NODE) { + const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" + const next = last.nextSibling + const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "" + if (isBreak && (!next || emptyText)) { + const placeholder = next && emptyText ? next : document.createTextNode("\u200B") + if (!next) last.parentNode?.insertBefore(placeholder, null) + placeholder.textContent = "\u200B" + range.setStart(placeholder, 0) + } else { + range.setStartAfter(last) + } + } + } + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + handleInput() + closePopover() + return true + } + + const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { + history.add(prompt, mode, mode === "shell" ? [] : historyComments()) + } + + createEffect( + on( + () => props.edit?.id, + (id) => { + const edit = props.edit + if (!id || !edit) return + + for (const item of prompt.context.items()) { + prompt.context.remove(item.key) + } + + for (const item of edit.context) { + prompt.context.add({ + type: item.type, + path: item.path, + selection: item.selection, + comment: item.comment, + commentID: item.commentID, + commentOrigin: item.commentOrigin, + preview: item.preview, + }) + } + + setStore("mode", "normal") + closePopover() + setStore("historyIndex", -1) + setStore("savedPrompt", null) + prompt.set(edit.prompt, promptLength(edit.prompt)) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(edit.prompt)) + queueScroll() + }) + props.onEditLoaded?.() + }, + { defer: true }, + ), + ) + + const navigateHistory = (direction: "up" | "down") => { + const result = navigatePromptHistory({ + direction, + entries: history.entries(store.mode), + historyIndex: store.historyIndex, + currentPrompt: prompt.current(), + currentComments: historyComments(), + savedPrompt: store.savedPrompt, + }) + if (!result.handled) return false + setStore("historyIndex", result.historyIndex) + setStore("savedPrompt", result.savedPrompt) + applyHistoryPrompt(result.entry, result.cursor) + return true + } + + const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ + prompt, + editor: () => editorRef, + isDialogActive: () => !!dialog.active, + setDraggingType: (type) => setStore("draggingType", type), + focusEditor: () => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(prompt.current())) + }, + addPart, + readClipboardImage: platform.readClipboardImage, + getPathForFile: platform.getPathForFile, + }) + + const fileAttachmentInput = () => ( + (fileInputRef = el)} + type="file" + multiple + accept={ACCEPTED_FILE_TYPES.join(",")} + class="hidden" + onChange={(e) => { + const list = e.currentTarget.files + if (list) void addAttachments(Array.from(list)) + e.currentTarget.value = "" + }} + /> + ) + + const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()]) + // Check provider variants directly: `variants` also includes the UI-only default option. + const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0) + const accepting = createMemo(() => { + const id = props.controls.session.id + if (!id) return permission.isAutoAcceptingDirectory(sdk().directory) + return permission.isAutoAccepting(id, sdk().directory) + }) + + const { abort, handleSubmit } = + props.submission ?? + createPromptSubmit({ + prompt, + info, + imageAttachments, + commentCount, + autoAccept: () => accepting(), + mode: () => store.mode, + working, + editor: () => editorRef, + queueScroll, + promptLength, + addToHistory, + resetHistoryNavigation: () => { + resetHistoryNavigation(true) + }, + setMode: (mode) => setStore("mode", mode), + setPopover: (popover) => { + if (!popover) return closePopover() + setStore({ popover, slashMenu: false, slashMenuQuery: "" }) + }, + newSessionWorktree: () => props.newSessionWorktree, + onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, + shouldQueue: props.shouldQueue, + onQueue: props.onQueue, + onAbort: props.onAbort, + onSubmit: props.onSubmit, + model: props.controls.model.selection, + }) + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { + event.preventDefault() + if (store.mode !== "normal") return + pick() + return + } + + if (event.key === "Backspace") { + const selection = window.getSelection() + if (selection && selection.isCollapsed) { + const node = selection.anchorNode + const offset = selection.anchorOffset + if (node && node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? "" + if (/^\u200B+$/.test(text) && offset > 0) { + const range = document.createRange() + range.setStart(node, 0) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + } + } + } + + if (event.key === "!" && store.mode === "normal") { + const cursorPosition = getCursorPosition(editorRef) + if (cursorPosition === 0) { + setStore("mode", "shell") + closePopover() + event.preventDefault() + return + } + } + + if (event.key === "Escape") { + if (store.popover) { + closePopover() + event.preventDefault() + event.stopPropagation() + return + } + + if (store.mode === "shell") { + setStore("mode", "normal") + event.preventDefault() + event.stopPropagation() + return + } + + if (working()) { + void abort() + event.preventDefault() + event.stopPropagation() + return + } + + if (escBlur()) { + editorRef.blur() + event.preventDefault() + event.stopPropagation() + return + } + } + + if (store.mode === "shell") { + const { collapsed, cursorPosition, textLength } = getCaretState() + if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { + setStore("mode", "normal") + event.preventDefault() + return + } + } + + // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input + // and should always insert a newline regardless of composition state + if (event.key === "Enter" && event.shiftKey) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } + + if (event.key === "Enter" && isImeComposing(event)) { + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + + if (store.popover) { + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (nav || ctrlNav) { + if (store.popover === "at") { + atOnKeyDown(event) + event.preventDefault() + return + } + if (store.popover === "slash") { + slashOnKeyDown(event) + if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) { + scrollSlashActiveIntoView() + } + } + event.preventDefault() + return + } + } + + if (ctrl && event.code === "KeyG") { + if (store.popover) { + closePopover() + event.preventDefault() + return + } + if (working()) { + void abort() + event.preventDefault() + } + return + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + if (event.altKey || event.ctrlKey || event.metaKey) return + const { collapsed } = getCaretState() + if (!collapsed) return + + const cursorPosition = getCursorPosition(editorRef) + const textContent = prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + const direction = event.key === "ArrowUp" ? "up" : "down" + if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return + if (navigateHistory(direction)) { + event.preventDefault() + } + return + } + + // Note: Shift+Enter is handled earlier, before IME check + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + if (event.repeat) return + if ( + working() && + prompt + .current() + .map((part) => ("content" in part ? part.content : "")) + .join("") + .trim().length === 0 && + imageAttachments().length === 0 && + commentCount() === 0 + ) { + return + } + void handleSubmit(event) + } + } + + const handleSlashMenuKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + closePopover() + requestAnimationFrame(() => editorRef.focus()) + event.preventDefault() + return + } + + if (event.key === "Tab") { + selectPopoverActive() + event.preventDefault() + return + } + + const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" + const ctrlNav = ctrl && (event.key === "n" || event.key === "p") + if (!nav && !ctrlNav) return + slashOnKeyDown(event) + if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) scrollSlashActiveIntoView() + event.preventDefault() + } + + const agentsLoading = () => props.controls.agents.loading + const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) + const providersLoading = () => props.controls.model.loading + const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) + + const [promptReady] = createResource( + () => prompt.ready.promise, + (p) => p, + ) + + const bindEditorRef = (el: HTMLDivElement) => { + editorRef = el + restoreEndOnFocus = true + props.ref?.(el) + } + return ( +
+ {(promptReady(), null)} + (slashPopoverRef = el)} + atFlat={atFlat()} + atActive={atActive() ?? undefined} + atKey={atKey} + setAtActive={setAtActive} + onAtSelect={handleAtSelect} + slashFlat={slashFlat()} + slashActive={slashActive() ?? undefined} + setSlashActive={setSlashActive} + onSlashSelect={handleSlashSelect} + slashMenu={store.slashMenu} + slashMenuQuery={store.slashMenuQuery} + onSlashMenuInput={(value) => { + setStore("slashMenuQuery", value) + slashOnInput(value) + }} + onSlashMenuKeyDown={handleSlashMenuKeyDown} + commandKeybind={command.keybind} + commandKeybindParts={command.keybindParts} + newLayoutDesigns={false} + t={(key) => language.t(key as Parameters[0])} + /> + + + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + newLayoutDesigns={false} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeAttachment} + removeLabel={language.t("prompt.attachment.remove")} + fileLabel={language.t("ui.common.file")} + newLayoutDesigns={false} + /> +
{ + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { + return + } + editorRef?.focus() + }} + > +
(scrollRef = el)} + style={{ "scroll-padding-bottom": space }} + > +
+
+ {placeholder()} +
+
+ + + + + +
+
+
+ + {language.t("prompt.mode.shell")} +
+ +
+
+ +
+ + (x === "default" ? language.t("common.default") : x)} + onSelect={(value) => { + props.controls.model.selection.variant.set(value === "default" ? undefined : value) + restoreFocus() + }} + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-model-variant" }} + variant="ghost" + /> + +
+
+ + +
+
+
+ + +
+ ) +} diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b84afdb56bf08b941599899eb0868fdd78094139 --- /dev/null +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -0,0 +1,589 @@ +import { + createEffect, + createSignal, + For, + onCleanup, + Show, + splitProps, + type Accessor, + type ComponentProps, +} from "solid-js" +import { createStore } from "solid-js/store" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" +import { getProjectAvatarVariant } from "@/context/layout" +import { useLanguage } from "@/context/language" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" +import { pathKey } from "@/utils/path-key" +import { handleDocumentSearchKeydown } from "@/utils/search-keydown" +import { createMenuDismissController } from "@/utils/menu-dismiss-controller" + +export type PromptProject = { + name?: string + id?: string + worktree: string + sandboxes?: string[] + icon?: { color?: string; url?: string; override?: string } + server?: { key: string; name: string } +} + +export type PromptProjectControls = { + available: PromptProject[] + directory: string + server?: string + select: (worktree: string, server?: string) => void + add: (title: string, server?: string) => void +} + +const actionPrefix = "action:" +const projectPrefix = "project:" + +function projectKey(project: PromptProject) { + return `${projectPrefix}${encodeURIComponent(project.server?.key ?? "")}:${encodeURIComponent(project.worktree)}` +} + +function actionKey(server?: string) { + return `${actionPrefix}${encodeURIComponent(server ?? "")}` +} + +export function createPromptProjectController(input: { + controls: Accessor + onDone: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ open: false, search: "", active: "" }) + let searchRef: HTMLInputElement | undefined + + const current = () => { + const key = pathKey(input.controls().directory) + return input + .controls() + .available.find( + (project) => + (!project.server || project.server.key === input.controls().server) && + (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), + ) + } + const selected = () => current() ?? input.controls().available[0] + const projects = () => { + const search = store.search.trim().toLowerCase() + if (!search) return input.controls().available + return input.controls().available.filter((project) => displayName(project).toLowerCase().includes(search)) + } + const servers = () => + input + .controls() + .available.map((project) => project.server) + .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index) + const keys = () => { + if (servers().length <= 1) { + return [...projects().map(projectKey), actionKey(servers()[0]?.key)] + } + return [ + ...servers().flatMap((server) => + projects() + .filter((project) => project.server?.key === server!.key) + .map(projectKey), + ), + actionKey(), + ] + } + const initialActive = () => { + const selectedKey = selected() ? projectKey(selected()!) : undefined + const options = keys() + if (selectedKey && options.includes(selectedKey)) return selectedKey + return options[0] ?? "" + } + const close = () => { + setStore({ open: false, search: "", active: "" }) + input.onDone() + } + const select = (project: PromptProject) => { + if ( + pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") || + project.server?.key !== current()?.server?.key + ) { + input.controls().select(project.worktree, project.server?.key) + } + close() + } + const add = (server?: string) => { + setStore({ open: false, search: "", active: "" }) + input.controls().add(language.t("command.project.open"), server) + } + const setSearch = (value: string) => { + const search = value.trim().toLowerCase() + const first = input + .controls() + .available.find((project) => !search || displayName(project).toLowerCase().includes(search)) + setStore({ + search: value, + active: first ? projectKey(first) : actionKey(servers().length > 1 ? undefined : servers()[0]?.key), + }) + } + + return { + selected, + empty: () => input.controls().available.length === 0, + projects, + servers, + projectKey, + actionKey, + open: () => store.open, + search: () => store.search, + active: () => store.active, + labels: { + add: () => language.t("session.new.project.add"), + clear: () => language.t("common.clear"), + new: () => language.t("session.new.project.new"), + search: () => language.t("session.new.project.search"), + }, + add, + select, + setOpen(open: boolean) { + if (open) { + setStore({ open: true, active: initialActive() }) + setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) + return + } + setStore({ open: false, search: "", active: "" }) + }, + setSearch, + clearSearch() { + setStore({ search: "", active: initialActive() }) + setTimeout(() => searchRef?.focus()) + }, + setActive(key: string) { + setStore("active", key) + }, + moveActive(delta: number) { + const options = keys() + if (options.length === 0) return + const index = options.indexOf(store.active) + const start = index === -1 ? 0 : index + setStore("active", options[(start + delta + options.length) % options.length]) + }, + activeProject() { + return store.active.startsWith(projectPrefix) + ? projects().find((project) => projectKey(project) === store.active) + : undefined + }, + activeServer() { + return store.active.startsWith(actionPrefix) + ? decodeURIComponent(store.active.slice(actionPrefix.length)) || undefined + : undefined + }, + activeAction() { + return store.active.startsWith(actionPrefix) + }, + setSearchRef(el: HTMLInputElement) { + searchRef = el + }, + focusSearch() { + setTimeout(() => requestAnimationFrame(() => searchRef?.focus())) + }, + handleSearchKeydown(event: KeyboardEvent) { + return handleDocumentSearchKeydown(searchRef, event, store.search, setSearch) + }, + } +} + +export type PromptProjectController = ReturnType + +export function PromptProjectSelector(props: { + controller: PromptProjectController + placement?: "bottom" | "bottom-start" +}) { + const [triggerReady, setTriggerReady] = createSignal(false) + let contentRef: HTMLDivElement | undefined + const dismiss = createMenuDismissController(() => contentRef) + let triggerFrame: number | undefined + + // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. + const setTriggerRef = (element: HTMLButtonElement) => { + const ready = () => { + if (!element.isConnected) { + triggerFrame = requestAnimationFrame(ready) + return + } + triggerFrame = undefined + setTriggerReady(true) + } + ready() + } + + onCleanup(() => { + if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame) + }) + + const activeItem = () => + props.controller.active() + ? contentRef?.querySelector(`[data-option-key="${CSS.escape(props.controller.active())}"]`) + : undefined + const selectProject = (project: PromptProject) => { + dismiss.preventTriggerRestore() + props.controller.setOpen(false) + dismiss.afterClose(() => props.controller.select(project)) + } + const selectAction = (server?: string) => { + dismiss.preventTriggerRestore() + props.controller.setOpen(false) + dismiss.afterClose(() => props.controller.add(server)) + } + const selectActive = () => { + const project = props.controller.activeProject() + if (project) { + selectProject(project) + return + } + if (props.controller.activeAction() && props.controller.servers().length > 1) { + const item = activeItem() + item?.focus() + item?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })) + return + } + selectAction(props.controller.activeServer()) + } + const moveActive = (delta: number) => { + props.controller.moveActive(delta) + queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" })) + } + const focusPreviousControl = () => { + const target = Array.from( + document.querySelectorAll( + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ) + .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) + .findLast((element) => element.offsetParent !== null) + dismiss.preventTriggerRestore() + target?.focus() + queueMicrotask(() => { + if (props.controller.open()) props.controller.setOpen(false) + }) + } + const selectedValue = () => { + const project = props.controller.selected() + return project ? props.controller.projectKey(project) : undefined + } + + createEffect(() => { + if (!props.controller.open()) return + const handler = (event: KeyboardEvent) => props.controller.handleSearchKeydown(event) + document.addEventListener("keydown", handler, true) + onCleanup(() => document.removeEventListener("keydown", handler, true)) + }) + + return ( + { + if (open) dismiss.allowTriggerRestore() + props.controller.setOpen(open) + }} + > + + + event.preventDefault()} + onPointerDownOutside={dismiss.preventTriggerRestore} + onFocusOutside={dismiss.preventTriggerRestore} + onCloseAutoFocus={dismiss.onCloseAutoFocus} + > +
+
+ + props.controller.setSearchRef(el)} + value={props.controller.search()} + placeholder={props.controller.labels.search()} + aria-autocomplete="list" + aria-controls="prompt-project-menu" + aria-activedescendant={props.controller.active() || undefined} + class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => props.controller.setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Tab") { + event.preventDefault() + event.stopPropagation() + if (event.shiftKey) { + focusPreviousControl() + return + } + activeItem()?.focus() + return + } + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + props.controller.setOpen(false) + return + } + if (event.altKey || event.metaKey) return + if (event.key === "ArrowDown") { + event.preventDefault() + moveActive(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + moveActive(-1) + return + } + if (event.key === "Enter" && !event.isComposing) { + event.preventDefault() + selectActive() + } + }} + /> + + + +
+
+ 1} + fallback={ + + + {(project) => ( + + )} + + + } + > + + props.controller.projects().some((project) => project.server?.key === server!.key), + )} + > + {(server) => ( +
+
+ {server!.name} +
+ + project.server?.key === server!.key)} + > + {(project) => ( + + )} + + +
+ )} +
+
+
+
+
+
+ 1} + fallback={ + + } + > + + props.controller.setActive(props.controller.actionKey())} + > + + + {props.controller.labels.add()} + + + + + + + {(server) => } + + + + + +
+ + + + ) +} + +export function PromptProjectAddButton(props: { controller: PromptProjectController }) { + return ( + + ) +} + +function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptProjectController }) { + const [local, rest] = splitProps(props, ["controller", "class", "classList", "onClick", "onKeyDown"]) + const project = () => local.controller.selected() + return ( + + ) +} + +function ProjectItem(props: { + project: PromptProject + controller: PromptProjectController + onSelect: (project: PromptProject) => void +}) { + const key = () => props.controller.projectKey(props.project) + return ( + { + props.controller.setActive(key()) + props.controller.focusSearch() + }} + onSelect={() => props.onSelect(props.project)} + > + + {displayName(props.project)} + + + + + ) +} + +const projectActionClass = + "h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover" + +function ProjectAction(props: { + server?: string + controller: PromptProjectController + onSelect: (server?: string) => void +}) { + const key = () => props.controller.actionKey(props.server) + return ( + { + props.controller.setActive(key()) + props.controller.focusSearch() + }} + onSelect={() => props.onSelect(props.server)} + > + + + {props.controller.labels.add()} + + + ) +} + +function ServerAction(props: { server: { key: string; name: string }; onSelect: (server: string) => void }) { + return ( + props.onSelect(props.server.key)}> + {props.server.name} + + ) +} diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a7a3537434124dc9585689c4b8722fa9b112db67 --- /dev/null +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -0,0 +1,128 @@ +import { For, Show } from "solid-js" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { Icon } from "@opencode-ai/ui/icon" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { getFilename } from "@opencode-ai/core/util/path" +import { useLanguage } from "@/context/language" + +export function PromptWorkspaceSelector(props: { + value: string + projectRoot: string + workspaces: string[] + branch?: string + onChange: (value: string) => void + onDone: () => void +}) { + const language = useLanguage() + let pending: string | undefined + const selected = () => (props.value === props.projectRoot ? "main" : props.value) + const icon = () => { + if (selected() === "main") return "monitor" + if (selected() === "create") return "workspace-new" + return "workspace" + } + const select = (value: string) => { + pending = value + } + const onOpenChange = (open: boolean) => { + if (open) return + const value = pending + pending = undefined + if (value) props.onChange(value) + props.onDone() + } + const label = () => { + if (selected() === "main") return language.t("session.new.workspace.triggerLocal") + if (props.value === "create") return language.t("workspace.new") + return getFilename(props.value) + } + + return ( + <> + + + + + {label()} + + + + + + {language.t("session.new.workspace.runIn")} + select("main")}> + + {language.t("session.new.workspace.local")} + + + + + select("create")}> + + {language.t("workspace.new")} + + + + + + 0}> + + + + + {language.t("session.new.workspace.existing")} + + + + + {(workspace) => ( + select(workspace)}> + + {getFilename(workspace)} + + + + + )} + + + + + + + + + + + ) +} + +export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { + const language = useLanguage() + const label = () => { + if (props.noGit) return language.t("session.new.git.none") + return props.branch + } + + return ( + + {(value) => ( + <> + + +
+ + {value()} +
+
+ + )} +
+ ) +} diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c1e0ccde059722182d77ba65b0148cbe5bef3520 --- /dev/null +++ b/packages/app/src/components/session-context-usage.tsx @@ -0,0 +1,169 @@ +import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js" +import { ProgressCircle } from "@opencode-ai/ui/progress-circle" +import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" +import { Button } from "@opencode-ai/ui/button" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { createMediaQuery } from "@solid-primitives/media" + +import { useFile } from "@/context/file" +import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" +import { useLanguage } from "@/context/language" +import { useProviders } from "@/hooks/use-providers" +import { useSDK } from "@/context/sdk" +import { getSessionContext } from "@/components/session/session-context-metrics" +import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionTabs } from "@/pages/session/helpers" +import { useSettings } from "@/context/settings" + +interface SessionContextUsageProps { + variant?: "button" | "indicator" + buttonAppearance?: "default" | "v2" + placement?: ComponentProps["placement"] +} + +function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { + return ( +
+ {props.name} + {props.value} +
+ ) +} + +function openSessionContext(args: { + view: ReturnType["view"]> + layout: ReturnType + tabs: ReturnType["tabs"]> +}) { + args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") + if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") + void args.tabs.open("context") + args.tabs.setActive("context") +} + +export function SessionContextUsage(props: SessionContextUsageProps) { + const sync = useSync() + const file = useFile() + const layout = useLayout() + const language = useLanguage() + const sdk = useSDK() + const settings = useSettings() + const providers = useProviders(() => sdk().directory) + const { params, tabs, view } = useSessionLayout() + const isDesktop = createMediaQuery("(min-width: 768px)") + + const variant = createMemo(() => props.variant ?? "button") + const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default") + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id, + }) + const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) + const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) + + const usd = createMemo( + () => + new Intl.NumberFormat(language.intl(), { + style: "currency", + currency: "USD", + }), + ) + + const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) + const cost = createMemo(() => { + return usd().format(info()?.cost ?? 0) + }) + const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context") + const hasOtherTabs = createMemo(() => + tabs() + .all() + .some((tab) => tab !== "context" && tab !== "review"), + ) + + const openContext = () => { + if (!params.id) return + + const sessionView = view() + if (contextVisible()) { + tabs().close("context") + if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close() + return + } + + openSessionContext({ + view: sessionView, + layout, + tabs: tabs(), + }) + } + + const circle = () => ( +
+ +
+ ) + const circleV2 = () => ( +
+ +
+ ) + + const tooltipValue = () => ( +
+ + + +
+ ) + + return ( + + + + {circle()} + + + + + + + + + + ) +} diff --git a/packages/app/src/components/settings-dialog.tsx b/packages/app/src/components/settings-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..46dd89ade817cb3b77942d2c40972867581b85e9 --- /dev/null +++ b/packages/app/src/components/settings-dialog.tsx @@ -0,0 +1,43 @@ +import { useParams } from "@solidjs/router" +import { onCleanup } from "solid-js" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useDialog } from "@opencode-ai/ui/context/dialog" + +export function useSettingsDialog(defaultValue?: string) { + const dialog = useDialog() + const params = useParams<{ id?: string }>() + let run = 0 + let dead = false + + onCleanup(() => { + dead = true + }) + + return () => { + const current = ++run + const sessionID = params.id + void import("@/components/settings-v2").then((module) => { + if (dead || run !== current) return + void dialog.show(() => ) + }) + } +} + +export function useSettingsCommand() { + const command = useCommand() + const language = useLanguage() + const show = useSettingsDialog() + + command.register("settings", () => [ + { + id: "settings.open", + title: language.t("command.settings.open"), + category: language.t("command.category.settings"), + keybind: "mod+comma", + onSelect: show, + }, + ]) + + return show +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8adaa1f0fdf9201085c7869175bc124797e69c11 --- /dev/null +++ b/packages/app/src/components/settings-general.tsx @@ -0,0 +1,795 @@ +import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Select } from "@opencode-ai/ui/select" +import { Switch } from "@opencode-ai/ui/switch" +import { TextField } from "@opencode-ai/ui/text-field" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useParams } from "@solidjs/router" +import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" +import { usePlatform, type DisplayBackend } from "@/context/platform" +import { useServerSync } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" +import { useUpdaterAction } from "./updater-action" +import { + monoDefault, + monoFontFamily, + monoInput, + sansDefault, + sansFontFamily, + sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, + useSettings, +} from "@/context/settings" +import { decode64 } from "@/utils/base64" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { ExternalLink } from "./external-link" +import { SettingsList } from "./settings-list" + +let demoSoundState = { + cleanup: undefined as (() => void) | undefined, + timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +type ThemeOption = { + id: string + name: string +} + +type ShellOption = { + path: string + name: string + acceptable: boolean +} + +type ShellSelectOption = { + id: string + value: string + label: string +} + +// To prevent audio from overlapping/playing very quickly when navigating the settings menus, +// delay the playback by 100ms during quick selection changes and pause existing sounds. +const stopDemoSound = () => { + demoSoundState.run += 1 + if (demoSoundState.cleanup) { + demoSoundState.cleanup() + } + clearTimeout(demoSoundState.timeout) + demoSoundState.cleanup = undefined +} + +const playDemoSound = (id: string | undefined) => { + stopDemoSound() + if (!id) return + + const run = ++demoSoundState.run + demoSoundState.timeout = setTimeout(() => { + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) + }, 100) +} + +export const SettingsGeneral: Component = () => { + const theme = useTheme() + const language = useLanguage() + const permission = usePermission() + const platform = usePlatform() + const dialog = useDialog() + const params = useParams() + const settings = useSettings() + + const updater = useUpdaterAction() + + const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") + const dir = createMemo(() => decode64(params.dir)) + const accepting = createMemo(() => { + const value = dir() + if (!value) return false + if (!params.id) return permission.isAutoAcceptingDirectory(value) + return permission.isAutoAccepting(params.id, value) + }) + + const toggleAccept = (checked: boolean) => { + const value = dir() + if (!value) return + + if (!params.id) { + if (permission.isAutoAcceptingDirectory(value) === checked) return + permission.toggleAutoAcceptDirectory(value) + return + } + + if (checked) { + permission.enableAutoAccept(params.id, value) + return + } + + permission.disableAutoAccept(params.id, value) + } + const desktop = createMemo(() => platform.platform === "desktop") + + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + + const serverSync = useServerSync() + const serverSdk = useServerSDK() + + const [shells] = createResource( + async () => { + const sdk = serverSdk() + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.shells()).data ?? [] + } + // return (await sdk.api.pty.shells()).data + return [] as ShellOption[] + }, + { initialValue: [] as ShellOption[] }, + ) + + const [displayBackend, { refetch: refetchDisplayBackend }] = createResource( + () => (linux() && platform.getDisplayBackend ? true : false), + () => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null), + { initialValue: null as DisplayBackend | null }, + ) + + const [pinchZoom, { mutate: setPinchZoom }] = createResource( + () => (desktop() && platform.getPinchZoomEnabled ? true : false), + () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), + { initialValue: false }, + ) + + onMount(() => { + void theme.loadThemes() + }) + + const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } + const currentShell = createMemo(() => serverSync().data.config.shell ?? "") + + const shellOptions = createMemo(() => { + const list = shells.latest + const current = serverSync().data.config.shell + + const nameCounts = new Map() + for (const s of list) { + nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) + } + + const options = [ + autoOption, + ...list.map((s) => { + const ambiguousName = (nameCounts.get(s.name) || 0) > 1 + const text = ambiguousName ? s.path : s.name + const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` + return { + id: s.path, + // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. + value: ambiguousName ? s.path : s.name, + label, + } + }), + ] + + if (current && !options.some((o) => o.value === current)) { + options.push({ id: current, value: current, label: current }) + } + + return options + }) + + const onDisplayBackendChange = (checked: boolean) => { + const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto") + if (!update) return + void update.finally(() => { + void refetchDisplayBackend() + }) + } + + const onPinchZoomChange = (checked: boolean) => { + setPinchZoom(checked) + const update = platform.setPinchZoomEnabled?.(checked) + if (!update) return + void update.catch(() => setPinchZoom(!checked)) + } + + const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ + { value: "system", label: language.t("theme.scheme.system") }, + { value: "light", label: language.t("theme.scheme.light") }, + { value: "dark", label: language.t("theme.scheme.dark") }, + ]) + + const languageOptions = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), + ) + + const noneSound = { id: "none", label: "sound.option.none" } as const + const soundOptions = [noneSound, ...SOUND_OPTIONS] + const mono = () => monoInput(settings.appearance.font()) + const sans = () => sansInput(settings.appearance.uiFont()) + const terminal = () => terminalInput(settings.appearance.terminalFont()) + + const soundSelectProps = ( + enabled: () => boolean, + current: () => string, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + options: soundOptions, + current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, + value: (o: (typeof soundOptions)[number]) => o.id, + label: (o: (typeof soundOptions)[number]) => language.t(o.label), + onHighlight: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + playDemoSound(option.id === "none" ? undefined : option.id) + }, + onSelect: (option: (typeof soundOptions)[number] | undefined) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + stopDemoSound() + return + } + setEnabled(true) + set(option.id) + playDemoSound(option.id) + }, + variant: "secondary" as const, + size: "small" as const, + triggerVariant: "settings" as const, + }) + + const InterfaceSection = () => ( +
+ + + {language.t("settings.general.row.newInterface.title")} + {language.t("settings.general.row.newInterface.badge")} + + } + description={language.t("settings.general.row.newInterface.description")} + > +
+ { + settings.general.setNewLayoutDesigns(checked) + if (!checked) return + void import("@/components/settings-v2").then((module) => { + void dialog.show(() => ) + }) + }} + /> +
+
+
+
+ ) + + const InterfaceNoticeSection = () => ( +
+ + + + + +
+ ) + + const GeneralSection = () => ( +
+ + + o.value === currentShell()) ?? autoOption} + value={(o) => o.id} + label={(o) => o.label} + onSelect={(option) => { + if (!option) return + if (option.value === currentShell()) return + serverSync().updateConfig({ shell: option.value }) + }} + variant="secondary" + size="small" + triggerVariant="settings" + triggerStyle={{ "min-width": "180px" }} + /> + + + +
+ settings.general.setShowReasoningSummaries(checked)} + /> +
+
+ + +
+ settings.general.setShellToolPartsExpanded(checked)} + /> +
+
+ + +
+ settings.general.setEditToolPartsExpanded(checked)} + /> +
+
+
+
+ ) + + const AdvancedSection = () => ( +
+

{language.t("settings.general.section.advanced")}

+ + + +
+ settings.general.setShowFileTree(checked)} + /> +
+
+ + +
+ settings.general.setShowNavigation(checked)} + /> +
+
+ + +
+ settings.general.setShowSearch(checked)} + /> +
+
+ + +
+ settings.general.setShowStatus(checked)} + /> +
+
+ + +
+ settings.general.setShowCustomAgents(checked)} + /> +
+
+
+
+ ) + + const AppearanceSection = () => ( +
+

{language.t("settings.general.section.appearance")}

+ + + + o.id === theme.themeId())} + value={(o) => o.id} + label={(o) => o.name} + onSelect={(option) => { + if (!option) return + theme.setTheme(option.id) + }} + variant="secondary" + size="small" + triggerVariant="settings" + /> + + + +
+ settings.appearance.setUIFont(value)} + placeholder={sansDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + class="text-12-regular" + style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} + /> +
+
+ + +
+ settings.appearance.setFont(value)} + placeholder={monoDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + class="text-12-regular" + style={{ "font-family": monoFontFamily(settings.appearance.font()) }} + /> +
+
+ + +
+ settings.appearance.setTerminalFont(value)} + placeholder={terminalDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + class="text-12-regular" + style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} + /> +
+
+
+
+ ) + + const NotificationsSection = () => ( +
+

{language.t("settings.general.section.notifications")}

+ + + +
+ settings.notifications.setAgent(checked)} + /> +
+
+ + +
+ settings.notifications.setPermissions(checked)} + /> +
+
+ + +
+ settings.notifications.setErrors(checked)} + /> +
+
+
+
+ ) + + const SoundsSection = () => ( +
+

{language.t("settings.general.section.sounds")}

+ + + + settings.sounds.permissionsEnabled(), + () => settings.sounds.permissions(), + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + )} + /> + + + + setState("text", e.currentTarget.value)} + style={{ + width: "420px", + "max-width": "100%", + padding: "6px 8px", + "border-radius": "6px", + border: "1px solid var(--color-divider, #333)", + background: "var(--color-fill-element, #222)", + color: "var(--color-text, #eee)", + }} + /> +
+ +
+ visualDuration + setState("visualDuration", Number(e.currentTarget.value))} + /> + {visualDuration().toFixed(2)}s +
+ +
+ bounce + setState("bounce", Number(e.currentTarget.value))} + /> + {bounce().toFixed(2)} +
+ +
+ fade ease + +
+ +
+ fade + setState("fadeMs", Number(e.currentTarget.value))} + /> + {fadeMs()}ms +
+ +
+ blur + setState("blur", Number(e.currentTarget.value))} + /> + {blur()}px +
+
+
+ ) + }, +} diff --git a/packages/session-ui/src/components/tool-count-summary.tsx b/packages/session-ui/src/components/tool-count-summary.tsx new file mode 100644 index 0000000000000000000000000000000000000000..444ee8f7cb6be905f3b76cab6771e833011cd2db --- /dev/null +++ b/packages/session-ui/src/components/tool-count-summary.tsx @@ -0,0 +1,47 @@ +import { Index, createMemo } from "solid-js" +import type { UiI18nPluralKey } from "@opencode-ai/ui/context" +import { AnimatedCountLabel } from "./tool-count-label" + +export type CountItem = { + key: UiI18nPluralKey + count: number +} + +export function AnimatedCountList(props: { items: CountItem[]; fallback?: string; class?: string }) { + const visible = createMemo(() => props.items.filter((item) => item.count > 0)) + const fallback = createMemo(() => props.fallback ?? "") + const showEmpty = createMemo(() => visible().length === 0 && fallback().length > 0) + + return ( + + + {fallback()} + + + + {(item, index) => { + const active = createMemo(() => item().count > 0) + const hasPrev = createMemo(() => { + for (let i = index - 1; i >= 0; i--) { + if (props.items[i].count > 0) return true + } + return false + }) + + return ( + <> + + , + + + + + + + + ) + }} + + + ) +} diff --git a/packages/session-ui/src/components/tool-error-card.css b/packages/session-ui/src/components/tool-error-card.css new file mode 100644 index 0000000000000000000000000000000000000000..4ff1e3dde9b51b4481517c3d0212f8f4c6556dba --- /dev/null +++ b/packages/session-ui/src/components/tool-error-card.css @@ -0,0 +1,91 @@ +[data-component="card"][data-kind="tool-error-card"] { + --card-pad-y: 8px; + --card-line-pad: 12px; + + [data-slot="basic-tool-tool-title"] { + color: var(--v2-text-text-base); + } + + [data-slot="basic-tool-tool-subtitle"] { + color: var(--v2-text-text-muted); + } + + [data-slot="collapsible-arrow"], + [data-slot="collapsible-arrow-icon"] { + color: var(--v2-text-text-faint); + } + + > [data-component="collapsible"].tool-collapsible { + gap: 0px; + } + + > [data-component="collapsible"].tool-collapsible[data-open="true"] { + gap: 4px; + } + + [data-component="tool-error-card-icon"] [data-component="icon"] { + color: var(--card-accent); + } + + [data-slot="tool-error-card-content"] { + position: relative; + padding-left: 24px; + margin-bottom: 8px; + -webkit-user-select: text; + user-select: text; + } + + > [data-component="collapsible"].tool-collapsible[data-open="true"] [data-slot="tool-error-card-content"] { + padding-right: 40px; + } + + [data-slot="tool-error-card-copy"] { + position: absolute; + top: 0; + right: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + will-change: opacity; + } + + &:hover [data-slot="tool-error-card-copy"], + &:focus-within [data-slot="tool-error-card-copy"] { + opacity: 1; + pointer-events: auto; + } + + [data-slot="tool-error-card-content"] :where(*)::selection { + background: var(--v2-state-bg-danger); + color: var(--v2-text-text-base); + } + + [data-slot="tool-error-card-content"] :where(*)::-moz-selection { + background: var(--v2-state-bg-danger); + color: var(--v2-text-text-base); + } +} + +body:not([data-new-layout]) [data-component="card"][data-kind="tool-error-card"] { + [data-slot="basic-tool-tool-title"] { + color: var(--text-strong); + } + + [data-slot="basic-tool-tool-subtitle"] { + color: var(--text-base); + } + + [data-slot="collapsible-arrow"] { + color: var(--text-base); + } + + [data-slot="collapsible-arrow-icon"] { + color: var(--icon-weaker); + } + + [data-slot="tool-error-card-content"] :where(*)::selection, + [data-slot="tool-error-card-content"] :where(*)::-moz-selection { + background: var(--surface-critical-base); + color: var(--text-on-critical-base); + } +} diff --git a/packages/session-ui/src/components/tool-status-title.tsx b/packages/session-ui/src/components/tool-status-title.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ef9ef6da61f5598fae48ac81c78ee0864ec349bd --- /dev/null +++ b/packages/session-ui/src/components/tool-status-title.tsx @@ -0,0 +1,133 @@ +import { Show, createEffect, createMemo, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" + +function common(active: string, done: string) { + const a = Array.from(active) + const b = Array.from(done) + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + return { + prefix: a.slice(0, i).join(""), + active: a.slice(i).join(""), + done: b.slice(i).join(""), + } +} + +function contentWidth(el: HTMLSpanElement | undefined) { + if (!el) return + return `${Math.ceil(el.getBoundingClientRect().width)}px` +} + +export function ToolStatusTitle(props: { + active: boolean + activeText: string + doneText: string + class?: string + split?: boolean +}) { + const split = createMemo(() => common(props.activeText, props.doneText)) + const suffix = createMemo( + () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, + ) + const prefixLen = createMemo(() => Array.from(split().prefix).length) + const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) + const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) + + const [state, setState] = createStore({ + active: props.active, + animating: false, + width: undefined as string | undefined, + }) + const width = () => state.width + const active = () => state.active + const animating = () => state.animating + let activeRef: HTMLSpanElement | undefined + let doneRef: HTMLSpanElement | undefined + let widthRef: HTMLSpanElement | undefined + let frame: number | undefined + let finishTimer: ReturnType | undefined + + const finish = () => { + if (frame !== undefined) cancelAnimationFrame(frame) + if (finishTimer !== undefined) clearTimeout(finishTimer) + frame = undefined + finishTimer = undefined + setState("animating", false) + setState("width", undefined) + } + + const animate = () => { + const first = contentWidth(widthRef) + const next = props.active + finish() + setState("active", next) + if (!first) return + + setState("animating", true) + setState("width", first) + frame = requestAnimationFrame(() => { + frame = undefined + const last = contentWidth(next ? activeRef : doneRef) + if (!last) { + finish() + return + } + if (first !== last) setState("width", last) + finishTimer = setTimeout(finish, 600) + }) + } + + createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true })) + + onCleanup(() => { + finish() + }) + + return ( + + + + + + + + + + + + + + } + > + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6bede42bfda78ebcc3442168da6dd74946fdb291 --- /dev/null +++ b/packages/session-ui/src/context/data.tsx @@ -0,0 +1,72 @@ +import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" + +export type NormalizedProviderListResponse = { + all: Map + defaultModel?: { + providerID: string + modelID: string + } | null + default: { + [key: string]: string + } + connected: Array +} + +type Data = { + agent?: { + name: string + color?: string + }[] + provider?: NormalizedProviderListResponse + session: Session[] + session_status: { + [sessionID: string]: SessionStatus + } + session_diff: { + [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[] + } + session_diff_preload?: { + [sessionID: string]: PreloadMultiFileDiffResult[] + } + message: { + [sessionID: string]: Message[] + } + part: { + [messageID: string]: Part[] + } + part_text_accum_delta?: { + [partID: string]: string + } +} + +export type NavigateToSessionFn = (sessionID: string) => void + +export type SessionHrefFn = (sessionID: string) => string + +export const { use: useData, provider: DataProvider } = createSimpleContext({ + name: "Data", + init: (props: { + data: Data + directory: string + sessionID?: string + onNavigateToSession?: NavigateToSessionFn + onSessionHref?: SessionHrefFn + }) => { + return { + get store() { + return props.data + }, + get directory() { + return props.directory + }, + get sessionID() { + return props.sessionID + }, + navigateToSession: props.onNavigateToSession, + sessionHref: props.onSessionHref, + } + }, +}) diff --git a/packages/session-ui/src/context/index.ts b/packages/session-ui/src/context/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b07252ef846ad41eaae22505c424838f7f53118f --- /dev/null +++ b/packages/session-ui/src/context/index.ts @@ -0,0 +1 @@ +export * from "./data" diff --git a/packages/session-ui/src/pierre/comment-hover.ts b/packages/session-ui/src/pierre/comment-hover.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ff975b75d9e6b3003a29453ce876b02f2afce92 --- /dev/null +++ b/packages/session-ui/src/pierre/comment-hover.ts @@ -0,0 +1,83 @@ +export type HoverCommentLine = { + lineNumber: number + side?: "additions" | "deletions" +} + +export function createHoverCommentUtility(props: { + label: string + getHoveredLine: () => HoverCommentLine | undefined + onSelect: (line: HoverCommentLine) => void +}) { + if (typeof document === "undefined") return + + const button = document.createElement("button") + button.type = "button" + button.ariaLabel = props.label + button.textContent = "+" + button.style.width = "20px" + button.style.height = "20px" + button.style.display = "flex" + button.style.alignItems = "center" + button.style.justifyContent = "center" + button.style.border = "none" + button.style.borderRadius = "var(--radius-md)" + button.style.background = "var(--icon-interactive-base)" + button.style.color = "var(--white)" + button.style.boxShadow = "var(--shadow-xs)" + button.style.fontSize = "14px" + button.style.lineHeight = "1" + button.style.cursor = "pointer" + button.style.position = "relative" + button.style.left = "30px" + button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)" + + let line: HoverCommentLine | undefined + + const sync = () => { + const next = props.getHoveredLine() + if (!next) return + line = next + } + + // The hovered line changes when the pointer moves or when content scrolls under + // a stationary pointer, so track both with passive listeners instead of polling + // every animation frame. There is no teardown hook for this utility; like the + // rAF loop this replaced, the listeners self-detach on the first event after + // the button leaves the DOM. + const onHoverInvalidated = () => { + if (!button.isConnected) { + document.removeEventListener("pointermove", onHoverInvalidated) + document.removeEventListener("scroll", onHoverInvalidated, true) + return + } + sync() + } + + const open = () => { + const next = props.getHoveredLine() ?? line + if (!next) return + props.onSelect(next) + } + + document.addEventListener("pointermove", onHoverInvalidated, { passive: true }) + document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true }) + button.addEventListener("mouseenter", sync) + button.addEventListener("mousemove", sync) + button.addEventListener("pointerdown", (event) => { + event.preventDefault() + event.stopPropagation() + sync() + }) + button.addEventListener("mousedown", (event) => { + event.preventDefault() + event.stopPropagation() + sync() + }) + button.addEventListener("click", (event) => { + event.preventDefault() + event.stopPropagation() + open() + }) + + return button +} diff --git a/packages/session-ui/src/pierre/commented-lines.ts b/packages/session-ui/src/pierre/commented-lines.ts new file mode 100644 index 0000000000000000000000000000000000000000..e970b7841b7ca47cd765eb2106d77fdb5c417fa0 --- /dev/null +++ b/packages/session-ui/src/pierre/commented-lines.ts @@ -0,0 +1,91 @@ +import { type SelectedLineRange } from "@pierre/diffs" +import { diffLineIndex, diffRowIndex } from "./diff-selection" + +export type CommentSide = "additions" | "deletions" + +function annotationIndex(node: HTMLElement) { + const value = node.dataset.lineAnnotation?.split(",")[1] + if (!value) return + const line = parseInt(value, 10) + if (Number.isNaN(line)) return + return line +} + +function clear(root: ShadowRoot) { + const marked = Array.from(root.querySelectorAll("[data-comment-selected]")) + for (const node of marked) { + if (!(node instanceof HTMLElement)) continue + node.removeAttribute("data-comment-selected") + } +} + +export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) { + clear(root) + + const diffs = root.querySelector("[data-diff]") + if (!(diffs instanceof HTMLElement)) return + + const split = diffs.dataset.diffType === "split" + const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (rows.length === 0) return + + const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + + for (const range of ranges) { + const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined) + if (start === undefined) continue + + const end = (() => { + const same = range.end === range.start && (range.endSide == null || range.endSide === range.side) + if (same) return start + return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined) + })() + if (end === undefined) continue + + const first = Math.min(start, end) + const last = Math.max(start, end) + + for (const row of rows) { + const idx = diffLineIndex(split, row) + if (idx === undefined || idx < first || idx > last) continue + row.setAttribute("data-comment-selected", "") + } + + for (const annotation of annotations) { + const idx = annotationIndex(annotation) + if (idx === undefined || idx < first || idx > last) continue + annotation.setAttribute("data-comment-selected", "") + } + } +} + +export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) { + clear(root) + + const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + + for (const range of ranges) { + const start = Math.max(1, Math.min(range.start, range.end)) + const end = Math.max(range.start, range.end) + + for (let line = start; line <= end; line++) { + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`)) + for (const node of nodes) { + if (!(node instanceof HTMLElement)) continue + node.setAttribute("data-comment-selected", "") + } + } + + for (const annotation of annotations) { + const line = annotationIndex(annotation) + if (line === undefined || line < start || line > end) continue + annotation.setAttribute("data-comment-selected", "") + } + } +} diff --git a/packages/session-ui/src/pierre/diff-selection.ts b/packages/session-ui/src/pierre/diff-selection.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc008b1b2e8ef753a8bb13362556bcd79724101b --- /dev/null +++ b/packages/session-ui/src/pierre/diff-selection.ts @@ -0,0 +1,71 @@ +import { type SelectedLineRange } from "@pierre/diffs" + +export type DiffSelectionSide = "additions" | "deletions" + +export function findDiffSide(node: HTMLElement): DiffSelectionSide { + const line = node.closest("[data-line], [data-alt-line]") + if (line instanceof HTMLElement) { + const type = line.dataset.lineType + if (type === "change-deletion") return "deletions" + if (type === "change-addition" || type === "change-additions") return "additions" + } + + const code = node.closest("[data-code]") + if (!(code instanceof HTMLElement)) return "additions" + return code.hasAttribute("data-deletions") ? "deletions" : "additions" +} + +export function diffLineIndex(split: boolean, node: HTMLElement) { + const raw = node.dataset.lineIndex + if (!raw) return + + const values = raw + .split(",") + .map((x) => parseInt(x, 10)) + .filter((x) => !Number.isNaN(x)) + if (values.length === 0) return + if (!split) return values[0] + if (values.length === 2) return values[1] + return values[0] +} + +export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) { + const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (rows.length === 0) return + + const target = side ?? "additions" + for (const row of rows) { + if (findDiffSide(row) === target) return diffLineIndex(split, row) + if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row) + } +} + +export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) { + if (!range) return range + if (!root) return + + const diffs = root.querySelector("[data-diff]") + if (!(diffs instanceof HTMLElement)) return + + const split = diffs.dataset.diffType === "split" + const start = diffRowIndex(root, split, range.start, range.side) + const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side) + + if (start === undefined || end === undefined) { + if (root.querySelector("[data-line], [data-alt-line]") == null) return + return null + } + if (start <= end) return range + + const side = range.endSide ?? range.side + const swapped: SelectedLineRange = { + start: range.end, + end: range.start, + } + + if (side) swapped.side = side + if (range.endSide && range.side) swapped.endSide = range.side + return swapped +} diff --git a/packages/session-ui/src/pierre/file-find.ts b/packages/session-ui/src/pierre/file-find.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1cf6dd3055d5df0554fa418efdfcb7648a07a08 --- /dev/null +++ b/packages/session-ui/src/pierre/file-find.ts @@ -0,0 +1,485 @@ +import { createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createStore } from "solid-js/store" + +export type FindHost = { + element: () => HTMLElement | undefined + open: () => void + close: () => void + next: (dir: 1 | -1) => void + isOpen: () => boolean +} + +const hosts = new Set() +let target: FindHost | undefined +let current: FindHost | undefined +let installed = false + +function isEditable(node: unknown): boolean { + if (!(node instanceof HTMLElement)) return false + if (node.closest("[data-prevent-autofocus]")) return true + if (node.isContentEditable) return true + return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName) +} + +function hostForNode(node: unknown) { + if (!(node instanceof Node)) return + for (const host of hosts) { + const el = host.element() + if (el && el.isConnected && el.contains(node)) return host + } +} + +function installShortcuts() { + if (installed) return + if (typeof window === "undefined") return + installed = true + + window.addEventListener( + "keydown", + (event) => { + if (event.defaultPrevented) return + if (isEditable(event.target)) return + + const mod = event.metaKey || event.ctrlKey + if (!mod) return + + const key = event.key.toLowerCase() + if (key === "g") { + const host = current + if (!host || !host.isOpen()) return + event.preventDefault() + event.stopPropagation() + host.next(event.shiftKey ? -1 : 1) + return + } + + if (key !== "f") return + + const active = current + if (active && active.isOpen()) { + event.preventDefault() + event.stopPropagation() + active.open() + return + } + + const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0] + if (!host) return + + event.preventDefault() + event.stopPropagation() + host.open() + }, + { capture: true }, + ) +} + +function clearHighlightFind() { + const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights + if (!api) return + api.delete("opencode-find") + api.delete("opencode-find-current") +} + +function supportsHighlights() { + const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown } + return typeof g.Highlight === "function" && g.CSS?.highlights != null +} + +function scrollParent(el: HTMLElement): HTMLElement | undefined { + let parent = el.parentElement + while (parent) { + const style = getComputedStyle(parent) + if (style.overflowY === "auto" || style.overflowY === "scroll") return parent + parent = parent.parentElement + } +} + +type CreateFileFindOptions = { + wrapper: () => HTMLElement | undefined + overlay: () => HTMLDivElement | undefined + getRoot: () => ShadowRoot | undefined +} + +export function createFileFind(opts: CreateFileFindOptions) { + let input: HTMLInputElement | undefined + let overlayFrame: number | undefined + let mode: "highlights" | "overlay" = "overlay" + let hits: Range[] = [] + const [overlayScroll, setOverlayScroll] = createSignal([]) + + const [state, setState] = createStore({ + open: false, + query: "", + index: 0, + count: 0, + pos: { top: 8, right: 8 }, + }) + const open = () => state.open + const query = () => state.query + const index = () => state.index + const count = () => state.count + const pos = () => state.pos + + const clearOverlayScroll = () => { + setOverlayScroll([]) + } + + const clearOverlay = () => { + const el = opts.overlay() + if (!el) return + if (overlayFrame !== undefined) { + cancelAnimationFrame(overlayFrame) + overlayFrame = undefined + } + el.innerHTML = "" + } + + const renderOverlay = () => { + if (mode !== "overlay") { + clearOverlay() + return + } + + const wrapper = opts.wrapper() + const overlay = opts.overlay() + if (!wrapper || !overlay) return + + clearOverlay() + if (hits.length === 0) return + + const base = wrapper.getBoundingClientRect() + const currentIndex = index() + const frag = document.createDocumentFragment() + + for (let i = 0; i < hits.length; i++) { + const range = hits[i] + const active = i === currentIndex + for (const rect of Array.from(range.getClientRects())) { + if (!rect.width || !rect.height) continue + + const mark = document.createElement("div") + mark.style.position = "absolute" + mark.style.left = `${Math.round(rect.left - base.left)}px` + mark.style.top = `${Math.round(rect.top - base.top)}px` + mark.style.width = `${Math.round(rect.width)}px` + mark.style.height = `${Math.round(rect.height)}px` + mark.style.borderRadius = "2px" + mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)" + mark.style.opacity = active ? "0.55" : "0.35" + if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)" + frag.appendChild(mark) + } + } + + overlay.appendChild(frag) + } + + function scheduleOverlay() { + if (mode !== "overlay") return + if (!open()) return + if (overlayFrame !== undefined) return + + overlayFrame = requestAnimationFrame(() => { + overlayFrame = undefined + renderOverlay() + }) + } + + const syncOverlayScroll = () => { + if (mode !== "overlay") return + const root = opts.getRoot() + + const next = root + ? Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + : [] + const current = overlayScroll() + if (next.length === current.length && next.every((el, i) => el === current[i])) return + + clearOverlayScroll() + setOverlayScroll(next) + } + + const clearFind = () => { + clearHighlightFind() + clearOverlay() + clearOverlayScroll() + hits = [] + setState("count", 0) + setState("index", 0) + } + + const positionBar = () => { + if (typeof window === "undefined") return + const wrapper = opts.wrapper() + if (!wrapper) return + + const root = scrollParent(wrapper) ?? wrapper + const rect = root.getBoundingClientRect() + const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) + const header = Number.isNaN(title) ? 0 : title + + setState("pos", { + top: Math.round(rect.top) + header - 4, + right: Math.round(window.innerWidth - rect.right) + 8, + }) + } + + const scan = (root: ShadowRoot, value: string) => { + const needle = value.toLowerCase() + const ranges: Range[] = [] + const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + + for (const col of cols) { + const text = col.textContent + if (!text) continue + + const hay = text.toLowerCase() + let at = hay.indexOf(needle) + if (at === -1) continue + + const nodes: Text[] = [] + const ends: number[] = [] + const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + let pos = 0 + while (node) { + if (node instanceof Text) { + pos += node.data.length + nodes.push(node) + ends.push(pos) + } + node = walker.nextNode() + } + if (nodes.length === 0) continue + + const locate = (offset: number) => { + let lo = 0 + let hi = ends.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ends[mid] >= offset) hi = mid + else lo = mid + 1 + } + const prev = lo === 0 ? 0 : ends[lo - 1] + return { node: nodes[lo], offset: offset - prev } + } + + while (at !== -1) { + const start = locate(at) + const end = locate(at + value.length) + const range = document.createRange() + range.setStart(start.node, start.offset) + range.setEnd(end.node, end.offset) + ranges.push(range) + at = hay.indexOf(needle, at + value.length) + } + } + + return ranges + } + + const scrollToRange = (range: Range) => { + const start = range.startContainer + const el = start instanceof Element ? start : start.parentElement + el?.scrollIntoView({ block: "center", inline: "center" }) + } + + const setHighlights = (ranges: Range[], currentIndex: number) => { + const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights + const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight + if (!api || typeof Highlight !== "function") return false + + api.delete("opencode-find") + api.delete("opencode-find-current") + + const active = ranges[currentIndex] + if (active) api.set("opencode-find-current", new Highlight(active)) + + const rest = ranges.filter((_, i) => i !== currentIndex) + if (rest.length > 0) api.set("opencode-find", new Highlight(...rest)) + return true + } + + const apply = (args?: { reset?: boolean; scroll?: boolean }) => { + if (!open()) return + + const value = query().trim() + if (!value) { + clearFind() + return + } + + const root = opts.getRoot() + if (!root) return + + mode = supportsHighlights() ? "highlights" : "overlay" + + const ranges = scan(root, value) + const total = ranges.length + const desired = args?.reset ? 0 : index() + const currentIndex = total ? Math.min(desired, total - 1) : 0 + + hits = ranges + setState("count", total) + setState("index", currentIndex) + + const active = ranges[currentIndex] + if (mode === "highlights") { + clearOverlay() + clearOverlayScroll() + if (!setHighlights(ranges, currentIndex)) { + mode = "overlay" + clearHighlightFind() + syncOverlayScroll() + scheduleOverlay() + } + if (args?.scroll && active) scrollToRange(active) + return + } + + clearHighlightFind() + syncOverlayScroll() + if (args?.scroll && active) scrollToRange(active) + scheduleOverlay() + } + + const close = () => { + setState("open", false) + setState("query", "") + clearFind() + if (current === host) current = undefined + } + + const focus = () => { + if (current && current !== host) current.close() + current = host + target = host + if (!open()) setState("open", true) + requestAnimationFrame(() => { + apply({ scroll: true }) + input?.focus() + input?.select() + }) + } + + const next = (dir: 1 | -1) => { + if (!open()) return + const total = count() + if (total <= 0) return + + const currentIndex = (index() + dir + total) % total + setState("index", currentIndex) + + const active = hits[currentIndex] + if (!active) return + + if (mode === "highlights") { + if (!setHighlights(hits, currentIndex)) { + mode = "overlay" + apply({ reset: true, scroll: true }) + return + } + scrollToRange(active) + return + } + + clearHighlightFind() + syncOverlayScroll() + scrollToRange(active) + scheduleOverlay() + } + + const host: FindHost = { + element: opts.wrapper, + isOpen: () => open(), + next, + open: focus, + close, + } + + createEffect(() => { + for (const el of overlayScroll()) makeEventListener(el, "scroll", scheduleOverlay, { passive: true }) + }) + + onMount(() => { + mode = supportsHighlights() ? "highlights" : "overlay" + installShortcuts() + hosts.add(host) + if (!target) target = host + + onCleanup(() => { + hosts.delete(host) + if (current === host) { + current = undefined + clearHighlightFind() + } + if (target === host) target = undefined + }) + }) + + createEffect(() => { + if (!open()) return + + const update = () => positionBar() + requestAnimationFrame(update) + makeEventListener(window, "resize", update, { passive: true }) + + const wrapper = opts.wrapper() + if (!wrapper) return + const root = scrollParent(wrapper) ?? wrapper + createResizeObserver(root, update) + }) + + onCleanup(() => { + clearOverlayScroll() + clearOverlay() + if (current === host) { + current = undefined + clearHighlightFind() + } + }) + + return { + open, + query, + count, + index, + pos, + setInput: (el: HTMLInputElement) => { + input = el + }, + setQuery: (value: string) => { + setState("query", value) + setState("index", 0) + apply({ reset: true, scroll: true }) + }, + focus, + close, + next, + refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args), + onPointerDown: () => { + target = host + opts.wrapper()?.focus({ preventScroll: true }) + }, + onFocus: () => { + target = host + }, + onInputKeyDown: (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + close() + return + } + if (event.key !== "Enter") return + event.preventDefault() + next(event.shiftKey ? -1 : 1) + }, + } +} diff --git a/packages/session-ui/src/pierre/file-runtime.ts b/packages/session-ui/src/pierre/file-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2072100356ffc17417f30c3d98a56f587995dca --- /dev/null +++ b/packages/session-ui/src/pierre/file-runtime.ts @@ -0,0 +1,114 @@ +type ReadyWatcher = { + observer?: MutationObserver + token: number +} + +export function createReadyWatcher(): ReadyWatcher { + return { token: 0 } +} + +export function clearReadyWatcher(state: ReadyWatcher) { + state.observer?.disconnect() + state.observer = undefined +} + +export function getViewerHost(container: HTMLElement | undefined) { + if (!container) return + const host = container.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + return host +} + +export function getViewerRoot(container: HTMLElement | undefined) { + return getViewerHost(container)?.shadowRoot ?? undefined +} + +export function applyViewerScheme(host: HTMLElement | undefined) { + if (!host) return + if (typeof document === "undefined") return + + const scheme = document.documentElement.dataset.colorScheme + if (scheme === "dark" || scheme === "light") { + host.dataset.colorScheme = scheme + return + } + + host.removeAttribute("data-color-scheme") +} + +export function observeViewerScheme(getHost: () => HTMLElement | undefined) { + if (typeof document === "undefined") return () => {} + + applyViewerScheme(getHost()) + if (typeof MutationObserver === "undefined") return () => {} + + const root = document.documentElement + const monitor = new MutationObserver(() => applyViewerScheme(getHost())) + monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] }) + return () => monitor.disconnect() +} + +export function notifyShadowReady(opts: { + state: ReadyWatcher + container: HTMLElement + getRoot: () => ShadowRoot | undefined + isReady: (root: ShadowRoot) => boolean + onReady: () => void + settleFrames?: number +}) { + clearReadyWatcher(opts.state) + opts.state.token += 1 + + const token = opts.state.token + const settle = Math.max(0, opts.settleFrames ?? 0) + + const runReady = () => { + const step = (left: number) => { + if (token !== opts.state.token) return + if (left <= 0) { + opts.onReady() + return + } + requestAnimationFrame(() => step(left - 1)) + } + + requestAnimationFrame(() => step(settle)) + } + + const observeRoot = (root: ShadowRoot) => { + if (opts.isReady(root)) { + runReady() + return + } + + if (typeof MutationObserver === "undefined") return + + clearReadyWatcher(opts.state) + opts.state.observer = new MutationObserver(() => { + if (token !== opts.state.token) return + if (!opts.isReady(root)) return + + clearReadyWatcher(opts.state) + runReady() + }) + opts.state.observer.observe(root, { childList: true, subtree: true }) + } + + const root = opts.getRoot() + if (!root) { + if (typeof MutationObserver === "undefined") return + + opts.state.observer = new MutationObserver(() => { + if (token !== opts.state.token) return + + const next = opts.getRoot() + if (!next) return + + observeRoot(next) + }) + opts.state.observer.observe(opts.container, { childList: true, subtree: true }) + return + } + + observeRoot(root) +} diff --git a/packages/session-ui/src/pierre/file-selection.ts b/packages/session-ui/src/pierre/file-selection.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdc34729e760045a2a3f0ddb928fd5314a2345e0 --- /dev/null +++ b/packages/session-ui/src/pierre/file-selection.ts @@ -0,0 +1,85 @@ +import { type SelectedLineRange } from "@pierre/diffs" +import { toRange } from "./selection-bridge" + +export function findElement(node: Node | null): HTMLElement | undefined { + if (!node) return + if (node instanceof HTMLElement) return node + return node.parentElement ?? undefined +} + +export function findFileLineNumber(node: Node | null): number | undefined { + const el = findElement(node) + if (!el) return + + const line = el.closest("[data-line]") + if (!(line instanceof HTMLElement)) return + + const value = parseInt(line.dataset.line ?? "", 10) + if (Number.isNaN(value)) return + return value +} + +export function findDiffLineNumber(node: Node | null): number | undefined { + const el = findElement(node) + if (!el) return + + const line = el.closest("[data-line], [data-alt-line]") + if (!(line instanceof HTMLElement)) return + + const primary = parseInt(line.dataset.line ?? "", 10) + if (!Number.isNaN(primary)) return primary + + const alt = parseInt(line.dataset.altLine ?? "", 10) + if (!Number.isNaN(alt)) return alt +} + +export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] { + const el = findElement(node) + if (!el) return + + const code = el.closest("[data-code]") + if (!(code instanceof HTMLElement)) return + if (code.hasAttribute("data-deletions")) return "deletions" + return "additions" +} + +export function readShadowLineSelection(opts: { + root: ShadowRoot + lineForNode: (node: Node | null) => number | undefined + sideForNode?: (node: Node | null) => SelectedLineRange["side"] + preserveTextSelection?: boolean +}) { + const selection = + (opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() + if (!selection || selection.isCollapsed) return + + const domRange = + ( + selection as unknown as { + getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[] + } + ).getComposedRanges?.({ shadowRoots: [opts.root] })?.[0] ?? + (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) + + const startNode = domRange?.startContainer ?? selection.anchorNode + const endNode = domRange?.endContainer ?? selection.focusNode + if (!startNode || !endNode) return + if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return + + const start = opts.lineForNode(startNode) + const end = opts.lineForNode(endNode) + if (start === undefined || end === undefined) return + + const startSide = opts.sideForNode?.(startNode) + const endSide = opts.sideForNode?.(endNode) + const side = startSide ?? endSide + + const range: SelectedLineRange = { start, end } + if (side) range.side = side + if (endSide && side && endSide !== side) range.endSide = endSide + + return { + range, + text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined, + } +} diff --git a/packages/session-ui/src/pierre/index.ts b/packages/session-ui/src/pierre/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..714110d92a2fc69f5352485d5948e3b63d2ce884 --- /dev/null +++ b/packages/session-ui/src/pierre/index.ts @@ -0,0 +1,218 @@ +import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs" +import { ComponentProps } from "solid-js" +import { lineCommentStyles } from "../components/line-comment-styles" + +export type DiffProps = FileDiffOptions & { + before: FileContents + after: FileContents + annotations?: DiffLineAnnotation[] + selectedLines?: SelectedLineRange | null + commentedLines?: SelectedLineRange[] + onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void + onRendered?: () => void + class?: string + classList?: ComponentProps<"div">["classList"] +} + +const unsafeCSS = ` +:host { + --diffs-bg: var(--opencode-diffs-bg, var(--color-background-stronger)); +} + +[data-diff], +[data-file] { + /* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */ + --diffs-bg-deletion-override: light-dark( + color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)), + color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base)) + ); + --diffs-bg-deletion-number-override: var(--diffs-bg-deletion-override); + --diffs-bg-addition-override: light-dark( + color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)), + color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base)) + ); + --diffs-bg-addition-number-override: var(--diffs-bg-addition-override); + --diffs-selection-base: var(--v2-background-bg-accent); + --diffs-selection-number-fg: var(--v2-text-text-accent); + --diffs-comment-bg: rgb(from var(--v2-background-bg-accent) r g b / 0.06); + /* Use explicit alpha instead of color-mix(..., transparent) to avoid Safari's non-premultiplied interpolation bugs. */ + --diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--diffs-selection-base) r g b / 0.2)); + --diffs-bg-selection-number: var(--diffs-bg-selection-number-override, var(--diffs-bg-selection)); + --diffs-bg-selection-text: rgb(from var(--diffs-selection-base) r g b / 0.2); +} + +[data-diff] ::selection, +[data-file] ::selection { + background-color: var(--diffs-bg-selection-text); +} + +[data-indicators='bars'] [data-column-number][data-line-type='change-addition']::before, +[data-indicators='bars'] [data-column-number][data-line-type='change-deletion']::before { + width: 2px; +} + +[data-indicators='bars'] [data-column-number][data-line-type='change-deletion']::before { + background-image: none; + background-color: var(--diffs-deletion-base); +} + +[data-background] [data-column-number] { + --mix-light: 88%; + --mix-dark: 80%; +} + +[data-diff-type='split'] [data-additions], +[data-diff-type='split'] [data-additions] [data-gutter], +[data-diff-type='split'] [data-deletions], +[data-diff-type='split'] [data-deletions] [data-content] { + border-left: 0; + border-right: 0; +} + +[data-content-buffer] { + background-image: none; + background-color: var(--diffs-bg-context-gutter); +} + +::highlight(opencode-find) { + background-color: rgb(from var(--surface-warning-base) r g b / 0.35); +} + +::highlight(opencode-find-current) { + background-color: rgb(from var(--surface-warning-strong) r g b / 0.55); +} + +[data-diff] [data-line][data-comment-selected]:not([data-selected-line]) { + box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); +} + +[data-file] [data-line][data-comment-selected]:not([data-selected-line]) { + box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); +} + +[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) { + box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number); + color: var(--diffs-selection-number-fg); +} + +[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) { + box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number); + color: var(--diffs-selection-number-fg); +} + +[data-diff] [data-line-annotation], +[data-diff] [data-gutter-buffer='annotation'], +[data-file] [data-line-annotation], +[data-file] [data-gutter-buffer='annotation'] { + --diffs-annotation-bg: var(--diffs-comment-bg); + --diffs-computed-decoration-bg: var(--diffs-comment-bg); + --diffs-computed-diff-line-bg: var(--diffs-comment-bg); + --diffs-computed-selected-line-bg: var(--diffs-comment-bg); + --diffs-line-bg: var(--diffs-comment-bg); + background-color: var(--diffs-comment-bg); +} + +[data-diff] [data-line][data-selected-line] { + background-color: var(--diffs-bg-selection); +} + +[data-file] [data-line][data-selected-line] { + background-color: var(--diffs-bg-selection); +} + +[data-diff] [data-column-number][data-selected-line] { + background-color: var(--diffs-bg-selection-number); + color: var(--diffs-selection-number-fg); +} + +[data-file] [data-column-number][data-selected-line] { + background-color: var(--diffs-bg-selection-number); + color: var(--diffs-selection-number-fg); +} + +[data-diff] [data-column-number][data-line-type='context'][data-selected-line], +[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line], +[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line], +[data-diff] [data-column-number][data-line-type='change-deletion'][data-selected-line] { + color: var(--diffs-selection-number-fg); +} + +@media (pointer: fine) { + [data-gutter-utility-slot] { + opacity: 0; + pointer-events: none; + } + + [data-column-number][data-hovered] [data-gutter-utility-slot], + [data-gutter-utility-slot]:focus-within { + opacity: 1; + pointer-events: auto; + } +} + +/* The deletion word-diff emphasis is stronger than additions; soften it while selected so the selection highlight reads consistently. */ +[data-diff] [data-line][data-line-type='change-deletion'][data-selected-line] { + --diffs-bg-deletion-emphasis: light-dark( + rgb(from var(--diffs-deletion-base) r g b / 0.07), + rgb(from var(--diffs-deletion-base) r g b / 0.1) + ); +} + +[data-diff-header], +[data-diff], +[data-file] { + [data-separator] { + height: 24px; + } + [data-column-number] { + cursor: default !important; + } + + &[data-interactive-line-numbers] [data-column-number] { + cursor: default !important; + } + + &[data-interactive-lines] [data-line] { + cursor: auto !important; + } + [data-code] { + overflow-x: auto !important; + overflow-y: clip !important; + } +} + +${lineCommentStyles} + +` + +export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) { + return { + theme: "OpenCode", + themeType: "system", + disableLineNumbers: false, + overflow: "wrap", + diffStyle: style ?? "unified", + diffIndicators: "bars", + lineHoverHighlight: "both", + disableBackground: false, + expansionLineCount: 20, + hunkSeparators: "line-info-basic", + lineDiffType: style === "split" ? "word-alt" : "none", + maxLineDiffLength: 1000, + maxLineLengthForHighlighting: 1000, + disableFileHeader: true, + unsafeCSS, + } as const +} + +export const styleVariables = { + "--diffs-font-family": "var(--font-family-mono)", + "--diffs-font-size": "var(--font-size-small)", + "--diffs-line-height": "24px", + "--diffs-tab-size": 2, + "--diffs-font-features": "var(--font-family-mono--font-feature-settings)", + "--diffs-header-font-family": "var(--font-family-sans)", + "--diffs-gap-block": 0, + "--diffs-gap-style": 0, + "--diffs-min-number-column-width": "3ch", +} diff --git a/packages/session-ui/src/pierre/media.ts b/packages/session-ui/src/pierre/media.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ee63c25ba934b7ad4cf5ea4577e8b531b55a5fc --- /dev/null +++ b/packages/session-ui/src/pierre/media.ts @@ -0,0 +1,110 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +export type MediaKind = "image" | "audio" | "svg" + +const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"]) +const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"]) + +type MediaValue = unknown + +function mediaRecord(value: unknown) { + if (!value || typeof value !== "object") return + return value as Partial & { + content?: unknown + encoding?: unknown + mimeType?: unknown + type?: unknown + } +} + +export function normalizeMimeType(type: string | undefined) { + if (!type) return + const mime = type.split(";", 1)[0]?.trim().toLowerCase() + if (!mime) return + if (mime === "audio/x-aac") return "audio/aac" + if (mime === "audio/x-m4a") return "audio/mp4" + return mime +} + +export function fileExtension(path: string | undefined) { + if (!path) return "" + const idx = path.lastIndexOf(".") + if (idx === -1) return "" + return path.slice(idx + 1).toLowerCase() +} + +export function mediaKindFromPath(path: string | undefined): MediaKind | undefined { + const ext = fileExtension(path) + if (ext === "svg") return "svg" + if (imageExtensions.has(ext)) return "image" + if (audioExtensions.has(ext)) return "audio" +} + +export function isBinaryContent(value: MediaValue) { + return mediaRecord(value)?.type === "binary" +} + +function validDataUrl(value: string, kind: MediaKind) { + if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined + if (kind === "image") return value.startsWith("data:image/") ? value : undefined + if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;") + if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;") + if (value.startsWith("data:audio/")) return value +} + +export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) { + if (!value) return + + if (typeof value === "string") { + return validDataUrl(value, kind) + } + + const record = mediaRecord(value) + if (!record) return + + if (typeof record.content !== "string") return + + const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined) + if (!mime) return + + if (kind === "svg") { + if (mime !== "image/svg+xml") return + if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}` + } + + if (kind === "image" && !mime.startsWith("image/")) return + if (kind === "audio" && !mime.startsWith("audio/")) return + if (record.encoding !== "base64") return + + return `data:${mime};base64,${record.content}` +} + +function decodeBase64Utf8(value: string) { + if (typeof atob !== "function") return + + try { + const raw = atob(value) + const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0)) + if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes) + return raw + } catch {} +} + +export function svgTextFromValue(value: MediaValue) { + const record = mediaRecord(value) + if (!record) return + if (typeof record.content !== "string") return + + const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined) + if (mime !== "image/svg+xml") return + if (record.encoding === "base64") return decodeBase64Utf8(record.content) + return record.content +} + +export function hasMediaValue(value: MediaValue) { + if (typeof value === "string") return value.length > 0 + const record = mediaRecord(value) + if (!record) return false + return typeof record.content === "string" && record.content.length > 0 +} diff --git a/packages/session-ui/src/pierre/selection-bridge.ts b/packages/session-ui/src/pierre/selection-bridge.ts new file mode 100644 index 0000000000000000000000000000000000000000..4055ec0874a38889c33c125a08c095a5c4238389 --- /dev/null +++ b/packages/session-ui/src/pierre/selection-bridge.ts @@ -0,0 +1,132 @@ +import { type SelectedLineRange } from "@pierre/diffs" + +type SelectionKey = "ui.sessionReview.selection.line" | "ui.sessionReview.selection.lines" +type SelectionVars = Record + +type PointerMode = "none" | "text" | "numbers" +type Side = SelectedLineRange["side"] +type LineSpan = Pick + +export function formatSelectedLineLabel(range: LineSpan, t: (key: SelectionKey, params: SelectionVars) => string) { + const start = Math.min(range.start, range.end) + const end = Math.max(range.start, range.end) + if (start === end) return t("ui.sessionReview.selection.line", { line: start }) + return t("ui.sessionReview.selection.lines", { start, end }) +} + +export function previewSelectedLines(source: string, range: LineSpan) { + const start = Math.max(1, Math.min(range.start, range.end)) + const end = Math.max(range.start, range.end) + const lines = source.split("\n").slice(start - 1, end) + if (lines.length === 0) return + return lines.slice(0, 2).join("\n") +} + +export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange { + const next: SelectedLineRange = { + start: range.start, + end: range.end, + } + + if (range.side) next.side = range.side + if (range.endSide) next.endSide = range.endSide + return next +} + +export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) { + if (!range) return false + + const start = Math.min(range.start, range.end) + const end = Math.max(range.start, range.end) + if (line < start || line > end) return false + if (!side) return true + + const first = range.side + const last = range.endSide ?? first + if (!first && !last) return true + if (!first || !last) return (first ?? last) === side + if (first === last) return first === side + if (line === start) return first === side + if (line === end) return last === side + return true +} + +export function isSingleLineSelection(range: SelectedLineRange | null) { + if (!range) return false + return range.start === range.end && (range.endSide == null || range.endSide === range.side) +} + +export function toRange(source: Range | StaticRange): Range { + if (source instanceof Range) return source + const range = new Range() + range.setStart(source.startContainer, source.startOffset) + range.setEnd(source.endContainer, source.endOffset) + return range +} + +export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) { + if (!root || !range) return + + requestAnimationFrame(() => { + const selection = + (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() + if (!selection) return + + try { + selection.removeAllRanges() + selection.addRange(range) + } catch {} + }) +} + +export function createLineNumberSelectionBridge() { + let mode: PointerMode = "none" + let line: number | undefined + let moved = false + let pending = false + + const clear = () => { + mode = "none" + line = undefined + moved = false + } + + return { + begin(numberColumn: boolean, next: number | undefined) { + if (!numberColumn) { + mode = "text" + return + } + + mode = "numbers" + line = next + moved = false + }, + track(buttons: number, next: number | undefined) { + if (mode !== "numbers") return false + + if ((buttons & 1) === 0) { + clear() + return true + } + + if (next !== undefined && line !== undefined && next !== line) moved = true + return true + }, + finish() { + const current = mode + pending = current === "numbers" && moved + clear() + return current + }, + consume(range: SelectedLineRange | null) { + const result = pending && !isSingleLineSelection(range) + pending = false + return result + }, + reset() { + pending = false + clear() + }, + } +} diff --git a/packages/session-ui/src/pierre/virtualizer.ts b/packages/session-ui/src/pierre/virtualizer.ts new file mode 100644 index 0000000000000000000000000000000000000000..235a3fd67734cfb0984b74bfb7c8beda8afedbcf --- /dev/null +++ b/packages/session-ui/src/pierre/virtualizer.ts @@ -0,0 +1,100 @@ +import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs" + +type Target = { + key: Document | HTMLElement + root: Document | HTMLElement + content: HTMLElement | undefined +} + +type Entry = { + virtualizer: Virtualizer + refs: number +} + +const cache = new WeakMap() + +export const virtualMetrics: Partial = { + lineHeight: 24, + hunkSeparatorHeight: 24, + spacing: 0, +} + +function scrollable(value: string) { + return value === "auto" || value === "scroll" || value === "overlay" +} + +function scrollRoot(container: HTMLElement) { + let node = container.parentElement + while (node) { + const style = getComputedStyle(node) + if (scrollable(style.overflowY)) return node + node = node.parentElement + } +} + +function target(container: HTMLElement): Target | undefined { + if (typeof document === "undefined") return + + const review = container.closest("[data-component='session-review']") + if (review instanceof HTMLElement) { + const root = scrollRoot(container) ?? review + const content = review.querySelector("[data-slot='session-review-container']") + return { + key: review, + root, + content: content instanceof HTMLElement ? content : undefined, + } + } + + const root = scrollRoot(container) + if (root) { + const content = root.querySelector("[role='log']") + return { + key: root, + root, + content: content instanceof HTMLElement ? content : undefined, + } + } + + return { + key: document, + root: document, + content: undefined, + } +} + +export function acquireVirtualizer(container: HTMLElement) { + const resolved = target(container) + if (!resolved) return + + let entry = cache.get(resolved.key) + if (!entry) { + const virtualizer = new Virtualizer() + virtualizer.setup(resolved.root, resolved.content) + entry = { + virtualizer, + refs: 0, + } + cache.set(resolved.key, entry) + } + + entry.refs += 1 + let done = false + + return { + virtualizer: entry.virtualizer, + release() { + if (done) return + done = true + + const current = cache.get(resolved.key) + if (!current) return + + current.refs -= 1 + if (current.refs > 0) return + + current.virtualizer.cleanUp() + cache.delete(resolved.key) + }, + } +} diff --git a/packages/session-ui/src/pierre/worker.ts b/packages/session-ui/src/pierre/worker.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c1bebb40b55ac719999881f5bce0a88ef9c7edd --- /dev/null +++ b/packages/session-ui/src/pierre/worker.ts @@ -0,0 +1,55 @@ +import { WorkerPoolManager } from "@pierre/diffs/worker" +import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url" +import { registerOpenCodeTheme } from "@opencode-ai/ui/context/marked-theme-register" + +registerOpenCodeTheme() + +export type WorkerPoolStyle = "unified" | "split" + +export function workerFactory(): Worker { + return new Worker(ShikiWorkerUrl, { type: "module" }) +} + +function createPool(lineDiffType: "none" | "word-alt") { + const pool = new WorkerPoolManager( + { + workerFactory, + // poolSize defaults to 8. More workers = more parallelism but + // also more memory. Too many can actually slow things down. + // NOTE: 2 is probably better for OpenCode, as I think 8 might be + // a bit overkill, especially because Safari has a significantly slower + // boot up time for workers + poolSize: 2, + }, + { + theme: "OpenCode", + lineDiffType, + preferredHighlighter: "shiki-wasm", + }, + ) + + void pool.initialize() + return pool +} + +let unified: WorkerPoolManager | undefined +let split: WorkerPoolManager | undefined + +export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined { + if (typeof window === "undefined") return + + if (style === "split") { + if (!split) split = createPool("word-alt") + return split + } + + if (!unified) unified = createPool("none") + return unified +} + +export function getWorkerPools() { + return { + unified: getWorkerPool("unified"), + split: getWorkerPool("split"), + } +} diff --git a/packages/session-ui/src/styles/index.css b/packages/session-ui/src/styles/index.css new file mode 100644 index 0000000000000000000000000000000000000000..24f78feb73f75ef8e5c2caed2f8bdf98fb9f602d --- /dev/null +++ b/packages/session-ui/src/styles/index.css @@ -0,0 +1,14 @@ +@layer theme, base, components, utilities; + +@import "../components/basic-tool.css" layer(components); +@import "../components/file.css" layer(components); +@import "../components/markdown.css" layer(components); +@import "../components/message-part.css" layer(components); +@import "../components/message-nav.css" layer(components); +@import "../components/session-review.css" layer(components); +@import "../components/session-turn.css" layer(components); +@import "../components/shell-submessage.css" layer(components); +@import "../components/tool-count-label.css" layer(components); +@import "../components/tool-count-summary.css" layer(components); +@import "../components/tool-error-card.css" layer(components); +@import "../components/tool-status-title.css" layer(components); diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6852311f9e845639027804d8d18c7b3c23475b6e --- /dev/null +++ b/packages/tui/src/app.tsx @@ -0,0 +1,1136 @@ +import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { registerOpencodeSpinner } from "./component/register-spinner" +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { Deferred, Effect } from "effect" +import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ClipboardProvider, useClipboard } from "./context/clipboard" +import { ExitProvider, useExit } from "./context/exit" +import { EpilogueProvider } from "./context/epilogue" +import * as Selection from "./util/selection" +import { createCliRenderer, MouseButton } from "@opentui/core" +import { RouteProvider, useRoute } from "./context/route" +import { + Switch, + Match, + createEffect, + createMemo, + ErrorBoundary, + createSignal, + onMount, + onCleanup, + batch, + Show, + on, +} from "solid-js" +import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" +import { DialogProvider, useDialog } from "./ui/dialog" +import { DialogProvider as DialogProviderList } from "./component/dialog-provider" +import { ErrorComponent } from "./component/error-component" +import { PluginRouteMissing } from "./component/plugin-route-missing" +import { ProjectProvider, useProject } from "./context/project" +import { EditorContextProvider } from "./context/editor" +import { useEvent } from "./context/event" +import { SDKProvider, useSDK } from "./context/sdk" +import { StartupLoading } from "./component/startup-loading" +import { SyncProvider, useSync } from "./context/sync" +import { DataProvider } from "./context/data" +import { LocationProvider } from "./context/location" +import { LocalProvider, useLocal } from "./context/local" +import { PermissionProvider } from "./context/permission" +import { DialogModel } from "./component/dialog-model" +import { useConnected } from "./component/use-connected" +import { DialogMcp } from "./component/dialog-mcp" +import { DialogStatus } from "./component/dialog-status" +import { DialogDebug } from "./component/dialog-debug" +import { DialogThemeList } from "./component/dialog-theme-list" +import { DialogHelp } from "./ui/dialog-help" +import { DialogAgent } from "./component/dialog-agent" +import { DialogSessionList } from "./component/dialog-session-list" +import { DialogWorkspaceList } from "./component/dialog-workspace-list" +import { DialogConsoleOrg } from "./component/dialog-console-org" +import { ThemeProvider, useTheme } from "./context/theme" +import { Home } from "./routes/home" +import { Session } from "./routes/session" +import { PromptHistoryProvider } from "./component/prompt/history" +import { FrecencyProvider } from "./component/prompt/frecency" +import { PromptStashProvider } from "./component/prompt/stash" +import { DialogAlert } from "./ui/dialog-alert" +import { DialogConfirm } from "./ui/dialog-confirm" +import { ToastProvider, useToast } from "./ui/toast" +import { isDefaultTitle } from "./util/session" +import { KVProvider, useKV } from "./context/kv" +import * as Model from "./util/model" +import { ArgsProvider, useArgs, type Args } from "./context/args" +import open from "open" +import { PromptRefProvider, usePromptRef } from "./context/prompt" +import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config" +import { createTuiApiAdapters } from "./plugin/adapters" +import { createTuiApi } from "./plugin/api" +import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime" +import { CommandPaletteDialog } from "./component/command-palette" +import { + COMMAND_PALETTE_COMMAND, + OPENCODE_BASE_MODE, + OpencodeKeymapProvider, + registerOpencodeKeymap, + useBindings, + useOpencodeKeymap, +} from "./keymap" + +import type { EventSource } from "./context/sdk" +import { DialogVariant } from "./component/dialog-variant" +import { createTuiAttention } from "./attention" +import * as TuiAudio from "./audio" +import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32" +import { destroyRenderer } from "./util/renderer" +import { cliErrorMessage, errorFormat } from "./util/error" + +registerOpencodeSpinner() + +const appGlobalBindingCommands = [ + "session.list", + "session.new", + "session.quick_switch.1", + "session.quick_switch.2", + "session.quick_switch.3", + "session.quick_switch.4", + "session.quick_switch.5", + "session.quick_switch.6", + "session.quick_switch.7", + "session.quick_switch.8", + "session.quick_switch.9", +] as const + +const appBindingCommands = [ + "command.palette.show", + "model.list", + "model.cycle_recent", + "model.cycle_recent_reverse", + "model.cycle_favorite", + "model.cycle_favorite_reverse", + "agent.list", + "mcp.list", + "agent.cycle", + "agent.cycle.reverse", + "variant.cycle", + "variant.list", + "provider.connect", + "console.org.switch", + "opencode.status", + "opencode.debug", + "theme.switch", + "theme.switch_mode", + "theme.mode.lock", + "help.show", + "docs.open", + "diff.open", + "workspace.list", + "app.debug", + "app.console", + "app.heap_snapshot", + "terminal.suspend", + "terminal.title.toggle", + "app.toggle.animations", + "app.toggle.file_context", + "app.toggle.diffwrap", + "app.toggle.paste_summary", + "app.toggle.session_directory_filter", +] as const + +export type TuiInput = { + url: string + args: Args + config: TuiConfig.Resolved + onSnapshot?: () => Promise + directory?: string + fetch?: typeof fetch + headers?: RequestInit["headers"] + events?: EventSource + pluginHost: TuiPluginHost +} + +function errorMessage(error: unknown) { + if ( + typeof error === "object" && + error !== null && + "data" in error && + typeof error.data === "object" && + error.data !== null && + "message" in error.data && + typeof error.data.message === "string" + ) { + return error.data.message + } + return error instanceof Error ? error.message : String(error) +} + +function isVersionGreater(left: string, right: string) { + const parse = (value: string) => { + const [core, prerelease] = value.replace(/^v/, "").split("-", 2) + return { core: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease } + } + const a = parse(left) + const b = parse(right) + for (let index = 0; index < Math.max(a.core.length, b.core.length); index++) { + const difference = (a.core[index] ?? 0) - (b.core[index] ?? 0) + if (difference) return difference > 0 + } + if (a.prerelease === b.prerelease) return false + if (!a.prerelease) return true + if (!b.prerelease) return false + return a.prerelease.localeCompare(b.prerelease, undefined, { numeric: true }) > 0 +} + +export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + const global = yield* Global.Service + const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown } + const result = yield* Effect.scoped( + Effect.gen(function* () { + const renderer = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + createCliRenderer({ + externalOutputMode: "passthrough", + targetFps: 60, + gatherStats: false, + exitOnCtrlC: false, + useKittyKeyboard: {}, + autoFocus: false, + openConsoleOnError: false, + useMouse: !Flag.OPENCODE_DISABLE_MOUSE && input.config.mouse, + consoleOptions: { + keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], + }, + }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }), + (renderer) => + Effect.sync(() => { + destroyRenderer(renderer) + }), + ) + win32DisableProcessedInput() + const keymap = createDefaultOpenTuiKeymap(renderer) + yield* Effect.acquireRelease( + Effect.sync(() => registerOpencodeKeymap(keymap, renderer, input.config)), + (unregister) => Effect.sync(unregister), + ) + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + try { + await input.pluginHost.dispose() + } catch (error) { + console.error("Failed to dispose TUI plugins", error) + } + }), + ) + yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose)) + const shutdown = yield* Deferred.make() + const onSighup = () => destroyRenderer(renderer) + yield* Effect.acquireRelease( + Effect.sync(() => process.on("SIGHUP", onSighup)), + () => Effect.sync(() => process.off("SIGHUP", onSighup)), + ) + renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void)) + const pluginRuntime = createPluginRuntime() + + yield* Effect.tryPromise(async () => { + // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. + void renderer.getPalette({ size: 16 }).catch(() => undefined) + const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" + if (renderer.isDestroyed) return + + await render(() => { + return ( + { + if (renderer.isDestroyed) return + exit.reason = reason + destroyRenderer(renderer) + }} + > + (exit.epilogue = value)}> + }> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, renderer) + }) + yield* Deferred.await(shutdown) + return { epilogue: exit.epilogue, reason: exit.reason } + }), + ) + yield* Effect.sync(() => { + win32FlushInputBuffer() + if (result.reason !== undefined) { + process.stderr.write((cliErrorMessage(result.reason) ?? errorFormat(result.reason)) + "\n") + process.exitCode = 1 + } + if (result.epilogue) process.stdout.write(result.epilogue + "\n") + }) +}) + +function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPluginHost }) { + const startup = useTuiStartup() + const tuiConfig = useTuiConfig() + const route = useRoute() + const dimensions = useTerminalDimensions() + const renderer = useRenderer() + const dialog = useDialog() + const local = useLocal() + const kv = useKV() + const keymap = useOpencodeKeymap() + const event = useEvent() + const sdk = useSDK() + const toast = useToast() + const themeState = useTheme() + const { theme, mode, setMode, locked, lock, unlock } = themeState + const sync = useSync() + const project = useProject() + const exit = useExit() + const promptRef = usePromptRef() + const pluginRuntime = usePluginRuntime() + const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) + const clipboard = useClipboard() + + const api = createTuiApi( + createTuiApiAdapters({ + version: InstallationVersion, + tuiConfig, + dialog, + keymap, + kv, + route, + routes: pluginRuntime.routes, + event, + sdk, + sync, + theme: themeState, + toast, + renderer, + attention, + Slot: pluginRuntime.Slot, + }), + ) + const [ready, setReady] = createSignal(false) + props.pluginHost + .start({ + api, + config: tuiConfig, + runtime: pluginRuntime, + dispose: () => attention.dispose(), + }) + .catch((error) => { + console.error("Failed to load TUI plugins", error) + }) + .finally(() => { + setReady(true) + }) + + // Let selection copy/dismiss win ahead of normal bindings when explicit copy is required. + const offSelectionKeys = keymap.intercept( + "key", + ({ event }) => { + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + Selection.handleSelectionKey(renderer, toast, event, clipboard) + }, + { priority: 1 }, + ) + onCleanup(() => { + offSelectionKeys() + attention.dispose() + }) + + // Wire up console copy-to-clipboard via opentui's onCopySelection callback + renderer.console.onCopySelection = async (text: string) => { + if (!text || text.length === 0) return + + await clipboard + .write?.(text) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + + renderer.clearSelection() + } + const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) + const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal( + kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary), + ) + + // Update terminal window title based on current route and session + createEffect(() => { + if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return + + if (route.data.type === "home") { + renderer.setTerminalTitle("OpenCode") + return + } + + if (route.data.type === "session") { + const session = sync.session.get(route.data.sessionID) + if (!session || isDefaultTitle(session.title)) { + renderer.setTerminalTitle("OpenCode") + return + } + + const title = session.title.length > 40 ? session.title.slice(0, 37) + "…" : session.title + renderer.setTerminalTitle(`OC | ${title}`) + return + } + + if (route.data.type === "plugin") { + renderer.setTerminalTitle(`OC | ${route.data.id}`) + } + }) + + const args = useArgs() + onMount(() => { + batch(() => { + if (args.agent) local.agent.set(args.agent) + if (args.model) { + const { providerID, modelID } = Model.parse(args.model) + if (!providerID || !modelID) + return toast.show({ + variant: "warning", + message: `Invalid model format: ${args.model}`, + duration: 3000, + }) + local.model.set({ providerID, modelID }, { recent: true }) + } + if (args.sessionID && !args.fork) { + route.navigate({ + type: "session", + sessionID: args.sessionID, + }) + } + }) + }) + + let continued = false + createEffect(() => { + // When using -c, session list is loaded in blocking phase, so we can navigate at "partial" + if (continued || sync.status === "loading" || !args.continue) return + const match = sync.data.session + .toSorted((a, b) => b.time.updated - a.time.updated) + .find((x) => x.parentID === undefined)?.id + if (match) { + continued = true + if (args.fork) { + void sdk.client.session.fork({ sessionID: match }).then((result) => { + if (result.data?.id) { + route.navigate({ type: "session", sessionID: result.data.id }) + } else { + toast.show({ message: "Failed to fork session", variant: "error" }) + } + }) + } else { + route.navigate({ type: "session", sessionID: match }) + } + } + }) + + // Handle --session with --fork: wait for sync to be fully complete before forking + // (session list loads in non-blocking phase for --session, so we must wait for "complete" + // to avoid a race where reconcile overwrites the newly forked session) + let forked = false + createEffect(() => { + if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return + forked = true + void sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => { + if (result.data?.id) { + route.navigate({ type: "session", sessionID: result.data.id }) + } else { + toast.show({ message: "Failed to fork session", variant: "error" }) + } + }) + }) + + createEffect( + on( + () => sync.status === "complete" && sync.data.provider.length === 0, + (isEmpty, wasEmpty) => { + // only trigger when we transition into an empty-provider state + if (!isEmpty || wasEmpty) return + dialog.replace(() => ) + }, + ), + ) + + const connected = useConnected() + const currentWorktreeWorkspace = createMemo(() => { + const workspaceID = project.workspace.current() + if (!workspaceID) return + const workspace = project.workspace.get(workspaceID) + if (workspace?.type !== "worktree" || !workspace.directory) return + return workspace + }) + const appCommands = createMemo(() => + [ + { + name: COMMAND_PALETTE_COMMAND, + title: "Show command palette", + category: "System", + hidden: true, + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "session.list", + title: "Switch session", + category: "Session", + suggested: sync.data.session.length > 0, + slashName: "sessions", + slashAliases: ["resume", "continue"], + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "session.new", + title: "New session", + suggested: route.data.type === "session", + category: "Session", + slashName: "new", + slashAliases: ["clear"], + run: () => { + route.navigate({ + type: "home", + }) + dialog.clear() + }, + }, + { + name: "workspace.copy_path", + title: "Copy worktree path", + category: "Workspace", + enabled: () => currentWorktreeWorkspace() !== undefined, + run: async () => { + const workspace = currentWorktreeWorkspace() + if (!workspace?.directory) return + await clipboard + .write?.(workspace.directory) + .then(() => toast.show({ message: "Copied worktree path", variant: "info" })) + .catch(toast.error) + dialog.clear() + }, + }, + { + name: "workspace.list", + title: "Manage workspaces", + category: "Workspace", + hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slashName: "workspaces", + run: () => { + dialog.replace(() => ) + }, + }, + ...Array.from({ length: 9 }, (_, i) => ({ + name: `session.quick_switch.${i + 1}`, + title: `Switch to session in quick slot ${i + 1}`, + category: "Session", + hidden: true, + run: () => { + local.session.quickSwitch(i + 1) + }, + })), + { + name: "model.list", + title: "Switch model", + suggested: true, + category: "Agent", + slashName: "models", + // Bias /mo toward /models over /move without changing global fuzzy scoring. + slashAliases: ["mo"], + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "model.cycle_recent", + title: "Model cycle", + category: "Agent", + hidden: true, + run: () => { + local.model.cycle(1) + }, + }, + { + name: "model.cycle_recent_reverse", + title: "Model cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.model.cycle(-1) + }, + }, + { + name: "model.cycle_favorite", + title: "Favorite cycle", + category: "Agent", + hidden: true, + run: () => { + local.model.cycleFavorite(1) + }, + }, + { + name: "model.cycle_favorite_reverse", + title: "Favorite cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.model.cycleFavorite(-1) + }, + }, + { + name: "agent.list", + title: "Switch agent", + category: "Agent", + slashName: "agents", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "mcp.list", + title: "Toggle MCPs", + category: "Agent", + slashName: "mcps", + run: () => { + dialog.replace(() => ) + }, + }, + { + name: "agent.cycle", + title: "Agent cycle", + category: "Agent", + hidden: true, + run: () => { + local.agent.move(1) + }, + }, + { + name: "variant.cycle", + title: "Variant cycle", + category: "Agent", + run: () => { + local.model.variant.cycle() + }, + }, + { + name: "variant.list", + title: "Switch model variant", + category: "Agent", + hidden: local.model.variant.list().length === 0, + slashName: "variants", + run: () => { + if (local.model.variant.list().length === 0) { + return toast.show({ + title: "No variants available", + message: "The current model does not support any variants.", + variant: "info", + }) + } + dialog.replace(() => ) + }, + }, + { + name: "agent.cycle.reverse", + title: "Agent cycle reverse", + category: "Agent", + hidden: true, + run: () => { + local.agent.move(-1) + }, + }, + { + name: "provider.connect", + title: "Connect provider", + suggested: !connected(), + slashName: "connect", + run: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, + ...(sync.data.console_state.switchableOrgCount > 1 + ? [ + { + name: "console.org.switch", + title: "Switch org", + suggested: Boolean(sync.data.console_state.activeOrgName), + slashName: "org", + slashAliases: ["orgs", "switch-org"], + run: () => { + dialog.replace(() => ) + }, + category: "Provider", + }, + ] + : []), + { + name: "opencode.status", + title: "View status", + slashName: "status", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "opencode.debug", + title: "View debug info", + slashName: "debug", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "theme.switch", + title: "Switch theme", + slashName: "themes", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "theme.switch_mode", + title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", + run: () => { + setMode(mode() === "dark" ? "light" : "dark") + dialog.clear() + }, + category: "System", + }, + { + name: "theme.mode.lock", + title: locked() ? "Unlock theme mode" : "Lock theme mode", + run: () => { + if (locked()) unlock() + else lock() + dialog.clear() + }, + category: "System", + }, + { + name: "help.show", + title: "Help", + slashName: "help", + run: () => { + dialog.replace(() => ) + }, + category: "System", + }, + { + name: "docs.open", + title: "Open docs", + run: () => { + open("https://opencode.ai/docs").catch(() => {}) + dialog.clear() + }, + category: "System", + }, + { + name: "app.exit", + title: "Exit the app", + slashName: "exit", + slashAliases: ["quit", "q"], + run: () => exit(), + category: "System", + }, + { + name: "app.debug", + title: "Toggle debug panel", + category: "System", + run: () => { + renderer.toggleDebugOverlay() + dialog.clear() + }, + }, + { + name: "app.console", + title: "Toggle console", + category: "System", + run: () => { + renderer.console.toggle() + dialog.clear() + }, + }, + { + name: "app.heap_snapshot", + title: "Write heap snapshot", + category: "System", + run: async () => { + const files = await props.onSnapshot?.() + toast.show({ + variant: "info", + message: `Heap snapshot written to ${files?.join(", ")}`, + duration: 5000, + }) + dialog.clear() + }, + }, + { + name: "terminal.suspend", + title: "Suspend terminal", + category: "System", + hidden: true, + enabled: process.platform !== "win32", + run: () => { + renderer.suspend() + process.once("SIGCONT", () => renderer.resume()) + process.kill(0, "SIGTSTP") + }, + }, + { + name: "terminal.title.toggle", + title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", + category: "System", + run: () => { + setTerminalTitleEnabled((prev) => { + const next = !prev + kv.set("terminal_title_enabled", next) + if (!next) renderer.setTerminalTitle("") + return next + }) + dialog.clear() + }, + }, + { + name: "app.toggle.animations", + title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", + category: "System", + run: () => { + kv.set("animations_enabled", !kv.get("animations_enabled", true)) + dialog.clear() + }, + }, + { + name: "app.toggle.file_context", + title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context", + category: "System", + run: () => { + kv.set("file_context_enabled", !kv.get("file_context_enabled", true)) + dialog.clear() + }, + }, + { + name: "app.toggle.diffwrap", + title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", + category: "System", + run: () => { + const current = kv.get("diff_wrap_mode", "word") + kv.set("diff_wrap_mode", current === "word" ? "none" : "word") + dialog.clear() + }, + }, + { + name: "app.toggle.paste_summary", + title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", + category: "System", + run: () => { + setPasteSummaryEnabled((prev) => { + const next = !prev + kv.set("paste_summary_enabled", next) + return next + }) + dialog.clear() + }, + }, + { + name: "app.toggle.session_directory_filter", + title: kv.get("session_directory_filter_enabled", true) + ? "Disable session directory filtering" + : "Enable session directory filtering", + category: "System", + run: async () => { + kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) + await sync.session.refresh() + dialog.clear() + }, + }, + { + name: "permission.mode", + title: + local.permission.mode === "auto" ? "Disable auto-approve permissions" : "Enable auto-approve permissions", + category: "System", + run: () => { + local.permission.toggle() + dialog.clear() + }, + }, + ].map((command) => ({ + namespace: "palette", + ...command, + })), + ) + + useBindings(() => ({ + commands: appCommands(), + })) + + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + bindings: tuiConfig.keybinds.gather("app", appBindingCommands), + })) + + useBindings(() => ({ + bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands), + })) + + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: () => { + const current = promptRef.current + if (!current?.focused) return true + return current.current.input === "" + }, + bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), + })) + + event.on("tui.command.execute", (evt, { workspace }) => { + if (workspace !== project.workspace.current()) return + keymap.dispatchCommand(evt.properties.command) + }) + + event.on("tui.toast.show", (evt, { workspace }) => { + if (workspace !== project.workspace.current()) return + toast.show({ + title: evt.properties.title, + message: evt.properties.message, + variant: evt.properties.variant, + duration: evt.properties.duration, + }) + }) + + event.on("tui.session.select", (evt, { workspace }) => { + if (workspace !== project.workspace.current()) return + route.navigate({ + type: "session", + sessionID: evt.properties.sessionID, + }) + }) + + event.on("session.deleted", (evt) => { + if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { + route.navigate({ type: "home" }) + toast.show({ + variant: "info", + message: "The current session was deleted", + }) + } + }) + + event.on("session.error", (evt, { workspace }) => { + if (workspace !== project.workspace.current()) return + const error = evt.properties.error + if (error && typeof error === "object" && error.name === "MessageAbortedError") return + const message = errorMessage(error) + + toast.show({ + variant: "error", + message, + duration: 5000, + }) + }) + + event.on("installation.update-available", async (evt) => { + console.log("installation.update-available", evt) + const version = evt.properties.version + + const skipped = kv.get("skipped_version") + if (skipped && !isVersionGreater(version, skipped)) return + + const choice = await DialogConfirm.show( + dialog, + `Update Available`, + `A new release v${version} is available. Would you like to update now?`, + "skip", + ) + + if (choice === false) { + kv.set("skipped_version", version) + return + } + + if (choice !== true) return + + toast.show({ + variant: "info", + message: `Updating to v${version}…`, + duration: 30000, + }) + + const result = await sdk.client.global.upgrade({ target: version }) + + if (result.error || !result.data?.success) { + toast.show({ + variant: "error", + title: "Update Failed", + message: "Update failed", + duration: 10000, + }) + return + } + + await DialogAlert.show( + dialog, + "Update Complete", + `Successfully updated to OpenCode v${result.data.version}. Please restart the application.`, + ) + + void exit() + }) + + const plugin = createMemo(() => { + if (!ready()) return + if (route.data.type !== "plugin") return + const render = pluginRuntime.routes.get(route.data.id) + if (!render) return route.navigate({ type: "home" })} /> + return render({ params: route.data.data }) + }) + + return ( + { + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + if (evt.button !== MouseButton.RIGHT) return + + if (!Selection.copy(renderer, toast, clipboard)) return + evt.preventDefault() + evt.stopPropagation() + }} + onMouseUp={ + !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT + ? () => Selection.copy(renderer, toast, clipboard) + : undefined + } + > + + + + + + + + + + + + {(_) => } + + + + {plugin()} + + + + + + + + + + + ) +} diff --git a/packages/tui/src/attention.ts b/packages/tui/src/attention.ts new file mode 100644 index 0000000000000000000000000000000000000000..b309edcd9dd95c9d267cc78ba13960e95a178b46 --- /dev/null +++ b/packages/tui/src/attention.ts @@ -0,0 +1,260 @@ +/// +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) { + 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 + kv?: TuiKV + audio?: Pick +}): TuiAttentionHost { + let focus: FocusState = "unknown" + let disposed = false + let activePackID: string | undefined + const packs = new Map([[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(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) + }, + } +} diff --git a/packages/tui/src/audio.d.ts b/packages/tui/src/audio.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..092703e747a7a5376566abcad5803985ee751af6 --- /dev/null +++ b/packages/tui/src/audio.d.ts @@ -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 +} diff --git a/packages/tui/src/audio.ts b/packages/tui/src/audio.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f8a0b66525321fa9b736012b90c748464619680 --- /dev/null +++ b/packages/tui/src/audio.ts @@ -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>() + +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() +} diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ae29da88894b408a3ea3c9873377f1feba358c5 --- /dev/null +++ b/packages/tui/src/clipboard.ts @@ -0,0 +1,125 @@ +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((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` + const passthrough = `\x1bPtmux;\x1b${sequence}\x1b\\` + process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : 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> | 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) +} diff --git a/packages/tui/src/component/bg-pulse-render.ts b/packages/tui/src/component/bg-pulse-render.ts new file mode 100644 index 0000000000000000000000000000000000000000..122017e677d2c0e9eacfce1b15d8b02c320e512c --- /dev/null +++ b/packages/tui/src/component/bg-pulse-render.ts @@ -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, + ) + } + } +} diff --git a/packages/tui/src/component/bg-pulse.tsx b/packages/tui/src/component/bg-pulse.tsx new file mode 100644 index 0000000000000000000000000000000000000000..58596ccb936fbd93b73e1d1a10dcc5a9a0dcbf95 --- /dev/null +++ b/packages/tui/src/component/bg-pulse.tsx @@ -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 & { + 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 ( + + ) +} diff --git a/packages/tui/src/component/command-palette.tsx b/packages/tui/src/component/command-palette.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3dd6829c54c2688cc59f72b9e1b645154ecb4fe3 --- /dev/null +++ b/packages/tui/src/component/command-palette.tsx @@ -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[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 + const list = () => { + if (ref?.filter) return options() + return [ + ...options() + .filter((option) => option.suggested) + .map((option) => ({ + ...option, + value: `suggested:${option.value}`, + category: "Suggested", + })), + ...options(), + ] + } + + return (ref = value)} title="Commands" options={list()} /> +} diff --git a/packages/tui/src/component/dialog-agent.tsx b/packages/tui/src/component/dialog-agent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3d2928910bec819371eff1fb529bac5f4f17c30a --- /dev/null +++ b/packages/tui/src/component/dialog-agent.tsx @@ -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 ( + { + local.agent.set(option.value) + dialog.clear() + }} + /> + ) +} diff --git a/packages/tui/src/component/dialog-console-org.tsx b/packages/tui/src/component/dialog-console-org.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1f6ef5c746fbe399bf45bb8d173d85aa5b6132b3 --- /dev/null +++ b/packages/tui/src/component/dialog-console-org.tsx @@ -0,0 +1,135 @@ +import { createResource, createMemo, createSignal } from "solid-js" +import { TextAttributes } from "@opentui/core" +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 { errorMessage } from "../util/error" +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) => + `${item.accountEmail} ${accountHost(item.accountUrl)}` + +export function DialogConsoleOrg() { + const sdk = useSDK() + const dialog = useDialog() + const toast = useToast() + const { theme } = useTheme() + + const [loadError, setLoadError] = createSignal() + + const [orgs] = createResource(() => + sdk.client.experimental.console + .listOrgs({}, { throwOnError: true }) + .then((result) => result.data?.orgs ?? []) + // Catch so the rejected resource never reaches the memos below: reading + // orgs() in an errored state re-throws and tears down the dialog. + .catch((error) => { + setLoadError(error) + return undefined + }), + ) + + const showError = createMemo(() => Boolean(loadError())) + + const current = createMemo(() => orgs()?.find((item) => item.active)) + + const options = createMemo(() => { + if (showError()) return [] + 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: ( + + {item.accountEmail} + {accountHost(item.accountUrl)} + + ), + 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 ( + + title="Switch org" + options={options()} + current={current()} + renderFilter={!showError()} + locked={showError()} + emptyView={ + showError() ? ( + + + Could not load orgs + + {errorMessage(loadError())} + + ) : undefined + } + /> + ) +} diff --git a/packages/tui/src/component/dialog-debug.tsx b/packages/tui/src/component/dialog-debug.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21554aac957f789ad3fc1fb1bd97a55bdb3817da --- /dev/null +++ b/packages/tui/src/component/dialog-debug.tsx @@ -0,0 +1,90 @@ +import { TextAttributes } from "@opentui/core" +import { createMemo, createSignal, For } from "solid-js" +import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" +import { useRoute } from "../context/route" +import { useLocal } from "../context/local" +import { useClipboard } from "../context/clipboard" +import { useToast } from "../ui/toast" +import { useBindings } from "../keymap" +import { describeOS, describeTerminal } from "../util/system" + +export function DialogDebug() { + const { theme } = useTheme() + const dialog = useDialog() + const route = useRoute() + const local = useLocal() + const clipboard = useClipboard() + const toast = useToast() + const [copied, setCopied] = createSignal(false) + + dialog.setSize("large") + + const entries = createMemo(() => { + const model = local.model.current() + return [ + { label: "Version", value: `${InstallationVersion} (${InstallationChannel})` }, + { label: "Date", value: new Date().toISOString() }, + { label: "OS", value: describeOS() }, + { label: "Terminal", value: describeTerminal() }, + { label: "Session ID", value: route.data.type === "session" ? route.data.sessionID : "n/a" }, + { label: "Model", value: model ? `${model.providerID}/${model.modelID}` : "n/a" }, + ] + }) + + const copy = () => { + const text = entries() + .map((entry) => `${entry.label}: ${entry.value}`) + .join("\n") + void clipboard + .write?.(text) + .then(() => { + setCopied(true) + toast.show({ message: "Debug info copied to clipboard", variant: "info" }) + }) + .catch(toast.error) + } + + useBindings(() => ({ + bindings: [{ key: "return", desc: "Copy debug info", group: "Dialog", cmd: copy }], + })) + + return ( + + + + Debug + + dialog.clear()}> + esc + + + {/* No click-to-copy here: releasing a mouse selection must trigger the + global copy-on-select so users can copy a single value, e.g. the session id. */} + + + {(entry) => ( + + + {entry.label.padEnd(10)} + + + {entry.value} + + + )} + + + + Share this when reporting an issue. + + + {copied() ? "✓ copied" : "copy"}{" "} + + enter + + + + ) +} diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c48c0f8ee188605c4986ef0e1d2e20ded0d8ba61 --- /dev/null +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -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 ⋯ Loading + } + if (props.enabled) { + return ✓ Enabled + } + return ○ Disabled +} + +export function DialogMcp() { + const local = useLocal() + const sync = useSync() + const sdk = useSDK() + const [, setRef] = createSignal>() + const [loading, setLoading] = createSignal(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: , + category: undefined, + })), + ) + }) + + const actions = createMemo(() => [ + { + command: "dialog.mcp.toggle", + title: "toggle", + onTrigger: async (option: DialogSelectOption) => { + // 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 ( + { + // Don't close on select, only on escape + }} + /> + ) +} diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx new file mode 100644 index 0000000000000000000000000000000000000000..38db569aaa24947987415dc96f7cf3ec65075382 --- /dev/null +++ b/packages/tui/src/component/dialog-model.tsx @@ -0,0 +1,197 @@ +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 [ + ...sortModelOptions( + fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj), + false, + ), + ...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(() => ) + return + } + dialog.clear() + } + + return ( + [number]["value"]> + options={options()} + actions={[ + { + command: "model.dialog.provider", + title: connected() ? "Connect provider" : "View all providers", + onTrigger() { + dialog.replace(() => ) + }, + }, + { + 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( + 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.releaseDate, "desc"], + (option) => option.title, + ) +} diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21912b273316cbc2e16655a669de8ee79a5c78a5 --- /dev/null +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -0,0 +1,353 @@ +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" +import type { ProjectDirectories } from "@opencode-ai/sdk/v2" +import { useRoute } from "../context/route" + +export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +type ProjectDirectory = ProjectDirectories[number] + +type DialogMoveSessionProps = { + projectID: string + current?: MoveSessionSelection + onSelect: (selection: MoveSessionSelection) => void + onCurrentChange?: (selection: MoveSessionSelection) => void + initialDirectories?: ProjectDirectory[] + initialRemoving?: string +} + +export function DialogMoveSession(props: DialogMoveSessionProps) { + const dialog = useDialog() + const sdk = useSDK() + const dimensions = useTerminalDimensions() + const { theme } = useTheme() + const sync = useSync() + const projectContext = useProject() + const route = useRoute() + const toast = useToast() + const paths = useTuiPaths() + const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) + const [toDelete, setToDelete] = createSignal() + const [removing, setRemoving] = createSignal(props.initialRemoving) + const [replacementCurrent, setReplacementCurrent] = createSignal() + const [loadError, setLoadError] = createSignal() + const deleteHint = useCommandShortcut("dialog.move_session.delete") + onMount(() => dialog.setSize("xlarge")) + + function reopen(initialRemoving?: string) { + dialog.replace(() => ( + + )) + } + + // A failed current-checkout lookup only affects which row is highlighted, so + // swallow it and let the directory list render without a current marker. + const [loadedProject] = createResource( + () => (projectContext.project() === props.projectID ? undefined : props.projectID), + (projectID) => + sdk.client.project + .current({}, { throwOnError: true }) + .then((result) => (result.data?.id === projectID ? result.data.worktree : undefined)) + .catch(() => undefined), + ) + const currentCheckout = createMemo(() => { + if (projectContext.project() === props.projectID) return projectContext.instance.path().worktree + return loadedProject() + }) + + const [directories, { refetch }] = createResource( + () => (props.initialRemoving ? undefined : props.projectID), + async (projectID, info): Promise => { + try { + await sdk.client.v2.projectCopy.refresh( + { projectID, location: { directory: sdk.directory } }, + { throwOnError: true }, + ) + const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) + setLoadError(undefined) + return directories.data ?? [] + } catch (error) { + setLoadError(error) + // An initial load with no data surfaces the inline error view below. A + // failed refresh intentionally stays quiet and keeps the already-shown + // list interactive; reopening the dialog retries the load. + return info.value + } + }, + ) + const directoryData = createMemo(() => directories() ?? props.initialDirectories) + // Show the locked error view only when we have nothing to display. A refresh + // that fails after the list rendered keeps the list and its actions. + const showError = createMemo(() => Boolean(loadError()) && !directoryData()) + + const currentDirectory = createMemo( + () => replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()), + ) + const currentRoot = createMemo(() => { + if (showError()) return + const directory = currentDirectory() + if (!directory) return + return ( + directoryData() + ?.filter((root) => contains(root.directory, directory)) + .toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory } + ) + }) + + const options = createMemo[]>(() => { + if (showError()) return [] + const data = directoryData() + const current = currentRoot()?.directory + if (directories.loading && !data && !current) return [{ title: "Loading project directories…", value: undefined }] + const roots = [...(data ?? [])] + if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current }) + roots.sort((a, b) => { + if (a.directory === current) return -1 + if (b.directory === current) return 1 + if (Boolean(a.strategy) !== Boolean(b.strategy)) return a.strategy ? 1 : -1 + if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length + return 0 + }) + 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.some((root) => root.directory === directory)) + .filter((directory, index, directories) => directories.indexOf(directory) === index) + .map((location) => ({ + location, + root: roots + .filter((root) => { + const relative = path.relative(root.directory, location) + return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative) + }) + .toSorted((a, b) => b.directory.length - a.directory.length)[0], + })) + .filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined) + + const list = [...roots.map((root) => ({ location: root.directory, root })), ...subdirectories].toSorted((a, b) => { + const root = roots.indexOf(a.root) - roots.indexOf(b.root) + if (root !== 0) return root + if (a.location === a.root.directory) return -1 + if (b.location === b.root.directory) 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.directory ? undefined : path.sep + path.relative(item.root.directory, 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, + titleView: isRemoving ? ( + Deleting {item.location} + ) : deleting ? ( + Press {deleteHint()} again to confirm + ) : suffix ? ( + <> + {visible.slice(0, split)} + {visible.slice(split)} + + ) : undefined, + bg: deleting ? theme.error : undefined, + value: { + type: "directory", + directory: item.location, + subdirectory: item.location !== item.root.directory, + } as const, + category: item.root.directory === current ? "Current" : "Other", + titleWidth, + truncateTitle: "left" as const, + } + }) + }) + + const current = createMemo(() => { + if (directories.loading || loadedProject.loading) return + const replacement = replacementCurrent() + if (replacement) return { type: "directory", directory: replacement, subdirectory: false } as const + return props.current + }) + + async function removedCurrent(current: boolean) { + if (!current) return false + const fallback = projectContext.data.project.mainDir + if (fallback) setReplacementCurrent(fallback) + if (route.data.type === "session") { + route.navigate({ type: "home" }) + dialog.clear() + return true + } + if (fallback) { + props.onCurrentChange?.({ type: "directory", directory: fallback, subdirectory: false }) + return true + } + dialog.clear() + return true + } + + async function remove(option: DialogSelectOption) { + if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return + const data = directoryData() + const selected = option.value + const root = data?.find((item) => item.directory === selected.directory) + if (!root?.strategy) return + const deletingCurrent = selected.directory === currentRoot()?.directory + if (toDelete() !== selected.directory) { + setToDelete(selected.directory) + return + } + setToDelete(undefined) + setRemoving(selected.directory) + setWorking(true) + const result = await sdk.client.v2.projectCopy + .remove({ + projectID: props.projectID, + location: { directory: sdk.directory }, + directory: selected.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: selected.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(selected.directory) + const forced = await sdk.client.v2.projectCopy + .remove({ + projectID: props.projectID, + location: { directory: sdk.directory }, + directory: selected.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 + } + setRemoving(undefined) + setWorking(false) + if (await removedCurrent(deletingCurrent)) return + reopen() + return + } + toast.show({ + variant: "error", + title: "Failed to delete project copy", + message: errorMessage(result.error), + }) + return + } + await refetch() + setRemoving(undefined) + setWorking(false) + if (await removedCurrent(deletingCurrent)) return + } + + const fullHeight = createMemo(() => + Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)), + ) + + return ( + + + + Move session + + + + + + } + renderFilter={!showError()} + options={options()} + emptyView={ + showError() ? ( + + + Could not load project directories + + {errorMessage(loadError())} + + ) : undefined + } + locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())} + current={current()} + onSelect={(option) => { + if (option.value) props.onSelect(option.value) + }} + onMove={() => setToDelete(undefined)} + actions={ + showError() + ? [] + : [ + { + command: "dialog.move_session.new", + title: "new", + onTrigger: () => props.onSelect({ type: "new" }), + }, + { + command: "dialog.move_session.delete", + title: "delete", + disabled: (option) => { + const value = option?.value + if (!value || value.type !== "directory" || value.subdirectory) return true + return !directoryData()?.find((item) => item.directory === value.directory)?.strategy + }, + onTrigger: remove, + }, + { + command: "dialog.move_session.refresh", + title: "refresh", + onTrigger: () => void refetch(), + }, + ] + } + /> + + ) +} + +function contains(root: string, directory: string) { + if (root === directory) return true + const relative = path.relative(root, directory) + return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative) +} diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b86a32e3482a819de0e8985bf49d7e0f7d8537c --- /dev/null +++ b/packages/tui/src/component/dialog-provider.tsx @@ -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 = { + 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 { + const value = await DialogPrompt.show(dialog, "Other", { + placeholder: "Provider id", + description: () => ( + + This only stores a credential. Configure the provider in opencode.json to use it. + + ), + }) + 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(() => ) + }, + } + } + + 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() ? () => ✓ : 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((resolve) => { + dialog.replace( + () => ( + ({ + 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 | 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(() => ( + + )) + } + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) + } + } + if (method.type === "api") { + let metadata: Record | undefined + if (method.prompts?.length) { + const value = await PromptsMethod({ dialog, prompts: method.prompts }) + if (!value) return + metadata = value + } + return dialog.replace(() => ( + + )) + } + }, + } + }), + ) + }) + return options +} + +export function DialogProvider() { + const options = createDialogProviderOptions() + return +} + +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(() => ) + }) + + return ( + + + + {props.title} + + dialog.clear()}> + esc + + + + + {props.authorization.instructions} + + Waiting for authorization… + + c copy + + + ) +} + +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 ( + { + 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(() => ) + return + } + setError(true) + }} + description={() => ( + + {props.authorization.instructions} + + + Invalid code + + + )} + /> + ) +} + +interface ApiMethodProps { + providerID: string + title: string + metadata?: Record + custom?: boolean +} +function ApiMethod(props: ApiMethodProps) { + const dialog = useDialog() + const sdk = useSDK() + const sync = useSync() + const toast = useToast() + const { theme } = useTheme() + + return ( + + ({ + opencode: ( + + + OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API + key. + + + Go to https://opencode.ai/zen to get a key + + + ), + "opencode-go": ( + + + OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models + with generous usage limits. + + + Go to https://opencode.ai/go and enable OpenCode Go + + + ), + })[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(() => ) + }} + /> + ) +} + +interface PromptsMethodProps { + dialog: ReturnType + prompts: NonNullable[number][] +} +async function PromptsMethod(props: PromptsMethodProps) { + const inputs: Record = {} + 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((resolve) => { + props.dialog.replace( + () => ( + ({ + 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((resolve) => { + props.dialog.replace( + () => ( + resolve(value)} /> + ), + () => resolve(null), + ) + }) + if (value === null) return null + inputs[prompt.key] = value + } + return inputs +} diff --git a/packages/tui/src/component/dialog-retry-action.tsx b/packages/tui/src/component/dialog-retry-action.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b52a6e9b9f698279f59fac49623203da0ac5608f --- /dev/null +++ b/packages/tui/src/component/dialog-retry-action.tsx @@ -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) { + if (props.link) open(props.link).catch(() => {}) + props.onClose?.() + dialog.clear() +} + +function dismiss(props: DialogRetryActionProps, dialog: ReturnType) { + 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 ( + + {showGoTreatment() ? ( + + + + ) : null} + + + + {props.title} + + dialog.clear()}> + esc + + + + + {props.message} + + + {props.link ? ( + showGoTreatment() ? ( + + + + ) : ( + + + + ) + ) : ( + + )} + + setSelected("dismiss")} + onMouseUp={() => dismiss(props, dialog)} + > + + don't show again + + + setSelected("action")} + onMouseUp={() => runAction(props, dialog)} + > + + {props.label} + + + + + + ) +} + +DialogRetryAction.show = ( + dialog: DialogContext, + props: Pick, +) => { + return new Promise((resolve) => { + dialog.replace( + () => resolve(dontShow ?? false)} />, + () => resolve(false), + ) + }) +} diff --git a/packages/tui/src/component/dialog-session-delete-failed.tsx b/packages/tui/src/component/dialog-session-delete-failed.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f3617a53479efe52fd37c0272b4dc7a2c5fcffca --- /dev/null +++ b/packages/tui/src/component/dialog-session-delete-failed.tsx @@ -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 + onRestore?: () => boolean | void | Promise + 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 ( + + + + Failed to Delete Session + + dialog.clear()}> + esc + + + + {`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`} + + + Choose how you want to recover this broken workspace session. + + + + {(item) => ( + { + setStore("active", item.id) + void confirm() + }} + > + + {item.title} + + + {item.description} + + + )} + + + + ) +} diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c508fc6b7b01647282e90ba66847266b74adbf7f --- /dev/null +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -0,0 +1,364 @@ +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, onCleanup, 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" +import { useEvent } from "../context/event" + +type SessionListFilter = { scope?: "project"; path?: string } + +export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) { + const search = input.search?.trim() + return { + roots: true, + limit: search ? 30 : 100, + ...(search ? { search } : {}), + ...input.filter, + } +} + +export function loadDialogSessionList(input: { + search?: string + filter: SessionListFilter + list: (query: ReturnType) => Promise<{ data?: T[] }> +}) { + return input.list(createDialogSessionListQuery(input)).then( + (result) => result.data, + () => undefined, + ) +} + +export function DialogSessionList() { + const dialog = useDialog() + const route = useRoute() + const sync = useSync() + const project = useProject() + const { theme } = useTheme() + const sdk = useSDK() + const event = useEvent() + const local = useLocal() + const toast = useToast() + const [toDelete, setToDelete] = createSignal() + const [deleted, setDeleted] = createSignal(new Set()) + 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 [browseResults, { refetch: refetchBrowse }] = createResource( + () => sync.session.query(), + (filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }), + ) + const [searchResults, { refetch }] = createResource( + () => ({ query: search(), filter: sync.session.query() }), + (input) => { + if (!input.query) return undefined + return loadDialogSessionList({ + search: input.query, + filter: input.filter, + list: (query) => sdk.client.session.list(query), + }) + }, + ) + + const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const sessions = createMemo(() => { + const result = searchResults() ?? browseResults() ?? sync.data.session + const synced = new Map(sync.data.session.map((session) => [session.id, session])) + const ids = new Set(result.map((session) => session.id)) + const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => { + if (!id || ids.has(id)) return [] + const session = synced.get(id) + if (session) ids.add(id) + return session ? [session] : [] + }) + const query = search().trim().toLowerCase() + return [...result.map((session) => synced.get(session.id) ?? session), ...extra] + .filter((session) => !deleted().has(session.id)) + .filter((session) => !query || session.title.toLowerCase().includes(query)) + }) + + onCleanup( + event.on("session.deleted", (event) => { + setDeleted((current) => new Set(current).add(event.properties.info.id)) + }), + ) + + function recover(session: NonNullable[number]>) { + const workspace = project.workspace.get(session.workspaceID!) + const list = () => dialog.replace(() => ) + 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(() => ( + { + 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() + await refetchBrowse() + 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>) { + return sessionsList + .filter((x) => x.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((x) => x.id) + } + + const browseOrder = createMemo(() => orderByRecency(browseResults() ?? 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 order = searchResult ? orderByRecency(sessions()) : browseOrder() + const current = currentSessionID() + const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order + + const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) + const pinnedSet = new Set(pinned) + const slotByID = new Map(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 + ? () => + : slot !== undefined + ? () => {slot} + : 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 ( + { + 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() + } + await refetchBrowse() + if (search()) await refetch() + setToDelete(undefined) + return + } + setToDelete(option.value) + }, + }, + { + command: "session.rename", + title: "rename", + onTrigger: async (option) => { + dialog.replace(() => ) + }, + }, + ]} + 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}` +} diff --git a/packages/tui/src/component/dialog-session-rename.tsx b/packages/tui/src/component/dialog-session-rename.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9fd8049e1e4a59044820565e3d53c331a1f72849 --- /dev/null +++ b/packages/tui/src/component/dialog-session-rename.tsx @@ -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 ( + { + void sdk.client.session.update({ + sessionID: props.session, + title: value, + }) + dialog.clear() + }} + onCancel={() => dialog.clear()} + /> + ) +} diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1143890baca0501fd76076797af298ce6565a5d0 --- /dev/null +++ b/packages/tui/src/component/dialog-skill.tsx @@ -0,0 +1,70 @@ +import { TextAttributes } from "@opentui/core" +import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" +import { createResource, createMemo, createSignal } from "solid-js" +import { useDialog } from "../ui/dialog" +import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { errorMessage } from "../util/error" + +export type DialogSkillProps = { + onSelect: (skill: string) => void +} + +export function DialogSkill(props: DialogSkillProps) { + const dialog = useDialog() + const sdk = useSDK() + const { theme } = useTheme() + dialog.setSize("large") + + const [loadError, setLoadError] = createSignal() + + const [skills] = createResource(() => + sdk.client.app + .skills({}, { throwOnError: true }) + .then((result) => result.data ?? []) + // Catch so the rejected resource never reaches the memo below: reading + // skills() in an errored state re-throws and tears down the dialog. + .catch((error) => { + setLoadError(error) + return undefined + }), + ) + + const showError = createMemo(() => Boolean(loadError())) + + const options = createMemo[]>(() => { + if (showError()) return [] + 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 ( + + + Could not load skills + + {errorMessage(loadError())} + + ) : undefined + } + /> + ) +} diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b306087c91d77824f3cf39537ae3413bb55473b0 --- /dev/null +++ b/packages/tui/src/component/dialog-stash.tsx @@ -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() + 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 ( + { + 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) + }, + }, + ]} + /> + ) +} diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c8fabdbb3a3f4900c1996a2b7b150325f01ecbf --- /dev/null +++ b/packages/tui/src/component/dialog-status.tsx @@ -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 ( + + + + Status + + dialog.clear()}> + esc + + + 0} fallback={No MCP Servers}> + + {Object.keys(sync.data.mcp).length} MCP Servers + + {([key, item]) => ( + + + )[item.status], + }} + > + • + + + {key}{" "} + + + Connected + {(val) => val().error} + Disabled in configuration + + Needs authentication (run: opencode mcp auth {key}) + + + {(val) => (val() as { error: string }).error} + + + + + + )} + + + + {sync.data.lsp.length > 0 && ( + + {sync.data.lsp.length} LSP Servers + + {(item) => ( + + + • + + + {item.id} {item.root} + + + )} + + + )} + 0} fallback={No Formatters}> + + {enabledFormatters().length} Formatters + + {(item) => ( + + + • + + + {item.name} + + + )} + + + + 0} fallback={No Plugins}> + + {plugins().length} Plugins + + {(item) => ( + + + • + + + {item.name} + {item.version && @{item.version}} + + + )} + + + + + ) +} diff --git a/packages/tui/src/component/dialog-tag.tsx b/packages/tui/src/component/dialog-tag.tsx new file mode 100644 index 0000000000000000000000000000000000000000..39560d5eacfeb44c5bc32413465d1711f9f709ed --- /dev/null +++ b/packages/tui/src/component/dialog-tag.tsx @@ -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 ( + { + props.onSelect?.(option.value) + dialog.clear() + }} + /> + ) +} diff --git a/packages/tui/src/component/dialog-theme-list.tsx b/packages/tui/src/component/dialog-theme-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6cf3539ad94716378e04cfe4d81932d5c52a93b5 --- /dev/null +++ b/packages/tui/src/component/dialog-theme-list.tsx @@ -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 + const initial = theme.selected + + onCleanup(() => { + if (!confirmed) theme.set(initial) + }) + + return ( + { + 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) + }} + /> + ) +} diff --git a/packages/tui/src/component/dialog-variant.tsx b/packages/tui/src/component/dialog-variant.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cbff7cd4fe63f0d1e0f054eb1aca9f1a4d51a5e2 --- /dev/null +++ b/packages/tui/src/component/dialog-variant.tsx @@ -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 ( + + options={options()} + title={"Select variant"} + current={local.model.variant.selected()} + flat={true} + /> + ) +} diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx new file mode 100644 index 0000000000000000000000000000000000000000..98c71bb000233a7f002164637cbb3586d026f613 --- /dev/null +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -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(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 `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.` +} + +async function loadWorkspaceAdapters(input: { + sdk: ReturnType + sync: ReturnType + toast: ReturnType +}) { + 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 + sdk: ReturnType + sync: ReturnType + project: ReturnType + toast: ReturnType + onSelect: (selection: WorkspaceSelection) => Promise | 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(() => ) +} + +export async function warpWorkspaceSession(input: { + dialog: ReturnType + sdk: ReturnType + sync: ReturnType + project: ReturnType + toast: ReturnType + sourceWorkspaceID?: string + workspaceID: string | null + sessionID: string + copyChanges: boolean + done?: () => void +}): Promise { + 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 + sdk: ReturnType + 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 +}) { + const dialog = useDialog() + const project = useProject() + const route = useRoute() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const [adapters, setAdapters] = createSignal(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[]>(() => { + 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 ( + + 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(() => ( + + )) + }} + /> + ) +} + +function DialogExistingWorkspaceSelect(props: { + omitWorkspaceID?: string + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { + const project = useProject() + + const options = createMemo[]>(() => + 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 ( + + 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, + }) + }} + /> + ) +} diff --git a/packages/tui/src/component/dialog-workspace-file-changes.tsx b/packages/tui/src/component/dialog-workspace-file-changes.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2babeecf8fcc7a1be8455805965510fefb705b1b --- /dev/null +++ b/packages/tui/src/component/dialog-workspace-file-changes.tsx @@ -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 ( + + + + {props.title ?? "File Changes Found"} + + dialog.clear()}> + esc + + + + + {props.message ?? "Do you want to move these changes with the session?"} + + + + + {(item) => ( + + + + {statusLabel(item.status)} + + + {Locale.truncateLeft(item.file, fileNameWidth())} + + + + + {" "} + {item.additions ? +{item.additions} : null} + {item.deletions ? -{item.deletions} : null} + + + + )} + + + + + {(item) => ( + { + setStore("active", item) + props.onSelect(item) + dialog.clear() + }} + > + {item} + + )} + + + + ) +} + +DialogWorkspaceFileChanges.show = ( + dialog: DialogContext, + files: VcsFileStatus[], + options?: { title?: string; message?: string }, +) => { + return new Promise((resolve) => { + dialog.replace( + () => , + () => resolve(undefined), + ) + }) +} diff --git a/packages/tui/src/component/dialog-workspace-list.tsx b/packages/tui/src/component/dialog-workspace-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..600338017fc07a60f6c3ba919074dcd5ee90b0f6 --- /dev/null +++ b/packages/tui/src/component/dialog-workspace-list.tsx @@ -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() + const [removing, setRemoving] = createSignal() + const [expanded, setExpanded] = createStore>({}) + + const current = createMemo(() => { + if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID + return project.workspace.current() + }) + + const options = createMemo[]>(() => + 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: () => ●, + } + }), + ) + + 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 ( + { + setDeleting(undefined) + }} + onSelect={(option) => showDetails(option.value.workspace)} + actions={[ + { + command: "session.delete", + title: "delete", + onTrigger: (option) => void remove(option.value.workspace), + }, + ]} + /> + ) +} diff --git a/packages/tui/src/component/dialog-workspace-unavailable.tsx b/packages/tui/src/component/dialog-workspace-unavailable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3181bd8590a6ec50c12c79f1de041d74cf1c9d6c --- /dev/null +++ b/packages/tui/src/component/dialog-workspace-unavailable.tsx @@ -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 }) { + 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 ( + + + + Workspace Unavailable + + dialog.clear()}> + esc + + + + This session is attached to a workspace that is no longer available. + + + Would you like to restore this session into a new workspace? + + + + {(item) => ( + { + setStore("active", item) + void confirm() + }} + > + {item} + + )} + + + + ) +} diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48f5f93309fb09568f3859e4a6ec154be0c28e8d --- /dev/null +++ b/packages/tui/src/component/error-component.tsx @@ -0,0 +1,240 @@ +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { createSignal, For, Show } 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" +import { describeOS, describeTerminal } from "../util/system" + +export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) { + const term = useTerminalDimensions() + const exit = useExit() + const clipboard = useClipboard() + const [copied, setCopied] = createSignal(false) + + // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the + // theme context may be the thing that crashed. + const isLight = props.mode === "light" + const colors = isLight + ? { + bg: "#ffffff", + element: "#f5f5f5", + borderSubtle: "#d4d4d4", + text: "#1a1a1a", + muted: "#8a8a8a", + primary: "#3b7dd8", + onPrimary: "#ffffff", + error: "#d1383d", + success: "#3d9a57", + } + : { + bg: "#0a0a0a", + element: "#1e1e1e", + borderSubtle: "#3c3c3c", + text: "#eeeeee", + muted: "#808080", + primary: "#fab283", + onPrimary: "#0a0a0a", + error: "#e06c75", + success: "#7fd88f", + } + + const message = props.error.message || "An unknown error occurred." + const stack = props.error.stack || "No stack trace available." + const issueURL = buildIssueURL(message, stack) + + const copyReport = () => { + void clipboard.write?.(issueURL.toString()).then(() => setCopied(true)) + } + + const actions = [ + { key: "c", label: () => (copied() ? "✓ Copied" : "Copy report"), copy: true, onUse: copyReport }, + { key: "r", label: () => "Restart", onUse: props.reset }, + { key: "q", label: () => "Quit", onUse: () => exit() }, + ] + const [selected, setSelected] = createSignal(0) + const move = (delta: number) => setSelected((prev) => (prev + delta + actions.length) % actions.length) + let scroll: ScrollBoxRenderable | undefined + + useKeyboard((evt) => { + if (evt.ctrl && evt.name === "c") return exit() + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + return actions[selected()].onUse() + } + if (evt.name === "left") { + evt.preventDefault() + evt.stopPropagation() + return move(-1) + } + if (evt.name === "right") { + evt.preventDefault() + evt.stopPropagation() + return move(1) + } + if (evt.name === "tab") { + evt.preventDefault() + evt.stopPropagation() + return move(evt.shift ? -1 : 1) + } + // Vertical keys scroll the stack trace; buttons navigate horizontally. + if (evt.name === "up") return scroll?.scrollBy(-1) + if (evt.name === "down") return scroll?.scrollBy(1) + if (evt.name === "pageup" && scroll) return scroll.scrollBy(-scroll.height) + if (evt.name === "pagedown" && scroll) return scroll.scrollBy(scroll.height) + if (evt.name === "home" && scroll) return scroll.scrollTo(0) + if (evt.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight) + if (evt.name === "q") return exit() + if (evt.name === "c") return copyReport() + if (evt.name === "r") return props.reset() + }) + + // Responsive thresholds. + const contentWidth = () => Math.min(84, Math.max(24, term().width - 4)) + const showSubtext = () => term().height >= 18 + const showFooter = () => term().height >= 20 + + return ( + + + {/* Headline */} + + + opencode crashed + + + An unexpected error stopped the session. + + + + {/* Error message panel */} + + {message} + + + {/* Actions */} + + + {(action, index) => { + const isSelected = () => selected() === index() + const isCopied = () => action.copy && copied() + return ( + + setSelected(index())} + onMouseUp={() => action.onUse()} + backgroundColor={isCopied() ? colors.success : isSelected() ? colors.primary : colors.element} + minWidth={15} + alignItems="center" + paddingLeft={2} + paddingRight={2} + > + + {action.label()} + + + {action.key} + + ) + }} + + + + {/* Stack trace */} + + (scroll = element)} + flexGrow={1} + scrollAcceleration={getScrollAcceleration()} + > + {stack} + + + + {/* Footer */} + + + + {copied() + ? "Report copied — paste it into a new GitHub issue." + : "Copy the report and open a GitHub issue to help us fix this."} + + opencode {InstallationVersion} + + + + + ) +} + +function buildIssueURL(message: string, stack: string) { + // Field keys match the ids in .github/ISSUE_TEMPLATE/bug-report.yml so the issue + // form opens pre-filled. Populating os/terminal/reproduce keeps the report past + // the contributing-guidelines compliance check, which pushes for system info. + const url = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml") + url.searchParams.set("title", `TUI crash: ${message}`) + url.searchParams.set("opencode-version", InstallationVersion) + url.searchParams.set("os", describeOS()) + url.searchParams.set("terminal", describeTerminal()) + url.searchParams.set( + "reproduce", + "Reported automatically from the opencode crash screen. If you can, describe what you were doing when it crashed.", + ) + + // Budget the stack against the fully URL-encoded length (not the raw length) so + // the final link stays under GitHub's practical limit; flag truncation so a + // clipped trace is obvious. searchParams.set handles encoding without throwing, + // so measuring url.toString() is both correct and safe on any input. + const MAX_URL_LENGTH = 6000 + const marker = "\n… (truncated)" + const head = `The opencode TUI crashed with an unexpected error.\n\n**Error:** ${message}\n\n**Stack trace:**\n` + const setBody = (body: string) => url.searchParams.set("description", head + "```\n" + body + "\n```") + + setBody(stack) + if (url.toString().length <= MAX_URL_LENGTH) return url + + // Largest raw stack prefix whose encoded URL (with the marker) still fits. + let lo = 0 + let hi = stack.length + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + setBody(stack.slice(0, mid) + marker) + if (url.toString().length <= MAX_URL_LENGTH) lo = mid + else hi = mid - 1 + } + setBody(stack.slice(0, lo) + marker) + return url +} diff --git a/packages/tui/src/component/logo.tsx b/packages/tui/src/component/logo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..db8e8dc71697dbf4fd5e8704dc1658276830b8da --- /dev/null +++ b/packages/tui/src/component/logo.tsx @@ -0,0 +1,61 @@ +import { RGBA, TextAttributes } from "@opentui/core" +import { For, type JSX } from "solid-js" +import { tint, useTheme } from "../context/theme" +import { logo } from "../logo" + +export function Logo() { + const { theme } = useTheme() + + const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => { + const shadow = tint(theme.background, fg, 0.25) + const attrs = bold ? TextAttributes.BOLD : undefined + return Array.from(line).map((char) => { + if (char === "_") { + return ( + + {" "} + + ) + } + if (char === "^") { + return ( + + ▀ + + ) + } + if (char === "~") { + return ( + + ▀ + + ) + } + if (char === ",") { + return ( + + ▄ + + ) + } + return ( + + {char} + + ) + }) + } + + return ( + + + {(line, index) => ( + + {renderLine(line, theme.textMuted, false)} + {renderLine(logo.right[index()], theme.text, true)} + + )} + + + ) +} diff --git a/packages/tui/src/component/plugin-route-missing.tsx b/packages/tui/src/component/plugin-route-missing.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77e2ea8dd33bfbe62594962e5cab2cdcb0c99d28 --- /dev/null +++ b/packages/tui/src/component/plugin-route-missing.tsx @@ -0,0 +1,14 @@ +import { useTheme } from "../context/theme" + +export function PluginRouteMissing(props: { id: string; onHome: () => void }) { + const { theme } = useTheme() + + return ( + + Unknown plugin route: {props.id} + + go home + + + ) +} diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx new file mode 100644 index 0000000000000000000000000000000000000000..099fa9d83eb71edda0af9907a783233216affdab --- /dev/null +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -0,0 +1,781 @@ +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 { useLocation } from "../../context/location" +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" +import type { FileSystemEntry } from "@opencode-ai/sdk/v2" + +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 location = useLocation() + 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: FileSystemEntry, + filePath: string, + lineRange?: { startLine: number; endLine?: number }, + ) { + const urlObj = pathToFileURL(filePath) + const filename = + lineRange && item.type !== "directory" + ? `${item.path}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + : item.path + + if (lineRange && item.type !== "directory") { + urlObj.searchParams.set("start", String(lineRange.startLine)) + if (lineRange.endLine !== undefined) { + urlObj.searchParams.set("end", String(lineRange.endLine)) + } + } + + return { + filename, + part: { + type: "file" as const, + mime: item.type === "directory" ? "application/x-directory" : "text/plain", + filename, + url: urlObj.href, + source: { + type: "file" as const, + text: { + start: 0, + end: 0, + value: "", + }, + path: item.path, + }, + }, + } + } + + 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 = location()?.directory || 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({ path: item, type: "file" }, input.filePath, lineRange) + const index = store.visible === "@" ? store.index : props.input().cursorOffset + + setStore("visible", false) + setStore("index", index) + insertPart(filename, part) + } + + const [files] = createResource( + () => ({ query: search(), location: location() }), + async (input) => { + if (!store.visible || store.visible === "/") return [] + if (referenceMatch()) return [] + const { lineRange, baseQuery } = extractLineRange(input.query ?? "") + + // Get files from SDK + const result = await sdk.client.v2.fs.find({ + query: baseQuery, + limit: "20", + location: { + directory: input.location?.directory, + workspace: input.location?.workspaceID ?? 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, part } = createFilePart( + item, + path.join(result.data.location.directory, 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)) { + options.push({ + display: Locale.truncateMiddle(res.name, width), + // Match the name only; matching the URI caused unrelated fuzzy hits. + value: res.name, + 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()), + // Match description for slash commands only; for "@" it surfaced unrelated items. + ...(store.visible === "/" ? ["description" as const] : []), + (obj) => obj.aliases?.join(" ") ?? "", + ], + threshold: store.visible === "@" ? 0.5 : 0, + 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/) || + // "/" 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 ( + + (scroll = r)} + backgroundColor={theme.backgroundMenu} + height={height()} + scrollbarOptions={{ visible: false }} + scrollAcceleration={scrollAcceleration()} + > + + No matching items + + } + > + {(option, index) => ( + { + setStore("input", "mouse") + }} + onMouseOver={() => { + if (store.input !== "mouse") return + moveTo(index) + }} + onMouseDown={() => { + setStore("input", "mouse") + moveTo(index) + }} + onMouseUp={() => select()} + > + + {option().display} + + + + {" " + option().description?.trimStart()} + + + + )} + + + + ) +} diff --git a/packages/tui/src/component/prompt/cwd.ts b/packages/tui/src/component/prompt/cwd.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/packages/tui/src/component/prompt/frecency.tsx b/packages/tui/src/component/prompt/frecency.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f669aa02cbfafd947d819bdd75e7cfcb9f115528 --- /dev/null +++ b/packages/tui/src/component/prompt/frecency.tsx @@ -0,0 +1 @@ +export * from "../../prompt/frecency" diff --git a/packages/tui/src/component/prompt/history.tsx b/packages/tui/src/component/prompt/history.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5a62b0518df798bfb6cff5cf5c4dfcf60ffdf9b9 --- /dev/null +++ b/packages/tui/src/component/prompt/history.tsx @@ -0,0 +1 @@ +export * from "../../prompt/history" diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c48c751739ce86c84da3f878d13f4cdfc2bb0c35 --- /dev/null +++ b/packages/tui/src/component/prompt/index.tsx @@ -0,0 +1,1716 @@ +import { + BoxRenderable, + RGBA, + TextareaRenderable, + MouseEvent, + PasteEvent, + decodePasteBytes, + type KeyEvent, + type Renderable, +} from "@opentui/core" +import type { CommandContext } from "@opentui/keymap" +import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" +import { registerOpencodeSpinner } from "../register-spinner" +import path from "path" +import { fileURLToPath } from "url" +import { useLocal } from "../../context/local" +import { Flag } from "@opencode-ai/core/flag/flag" +import { tint, useTheme } from "../../context/theme" +import { EmptyBorder, SplitBorder } from "../../ui/border" +import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" +import { useClipboard } from "../../context/clipboard" +import { Spinner } from "../spinner" +import { useSDK } from "../../context/sdk" +import { useRoute } from "../../context/route" +import { useProject } from "../../context/project" +import { useSync } from "../../context/sync" +import { useEvent } from "../../context/event" +import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" +import { normalizePromptContent, openEditor } from "../../editor" +import { useExit } from "../../context/exit" +import { promptOffsetWidth } from "../../prompt/display" +import { createStore, produce, unwrap } from "solid-js/store" +import { usePromptHistory, type PromptInfo } from "../../prompt/history" +import { computePromptTraits } from "../../prompt/traits" +import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part" +import { usePromptStash } from "../../prompt/stash" +import { DialogStash } from "../dialog-stash" +import { type AutocompleteRef, Autocomplete } from "./autocomplete" +import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" +import { Locale } from "../../util/locale" +import { errorMessage } from "../../util/error" +import { formatDuration } from "../../util/format" +import { createColors, createFrames } from "../../ui/spinner" +import { useDialog } from "../../ui/dialog" +import { DialogProvider as DialogProviderConnect } from "../dialog-provider" +import { DialogAlert } from "../../ui/dialog-alert" +import { useToast } from "../../ui/toast" +import { useKV } from "../../context/kv" +import { createFadeIn } from "../../util/signal" +import { DialogSkill } from "../dialog-skill" +import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" +import { useArgs } from "../../context/args" +import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" +import { useTuiConfig } from "../../config" +import { usePromptWorkspace } from "./workspace" +import { usePromptMove } from "./move" +import { readLocalAttachment } from "./local-attachment" +import { useLocation } from "../../context/location" + +registerOpencodeSpinner() + +export type PromptProps = { + sessionID?: string + visible?: boolean + disabled?: boolean + onSubmit?: () => void + ref?: (ref: PromptRef | undefined) => void + hint?: JSX.Element + right?: JSX.Element + showPlaceholder?: boolean + placeholders?: { + normal?: string[] + shell?: string[] + } +} + +function pastedFilepath(value: string, platform: string) { + const raw = value.replace(/^['"]+|['"]+$/g, "") + if (raw.startsWith("file://")) { + try { + return fileURLToPath(raw) + } catch {} + } + if (platform === "win32") return raw + return raw.replace(/\\(.)/g, "$1") +} + +export type PromptRef = { + focused: boolean + current: PromptInfo + set(prompt: PromptInfo): void + reset(): void + blur(): void + focus(): void + submit(): void +} + +const money = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", +}) + +const DRAFT_RETENTION_MIN_CHARS = 20 + +function randomIndex(count: number) { + if (count <= 0) return 0 + return Math.floor(Math.random() * count) +} + +function fadeColor(color: RGBA, alpha: number) { + return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha) +} + +function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) { + return ( + selection.selection.start.line !== selection.selection.end.line || + selection.selection.start.character !== selection.selection.end.character + ) +} + +function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) { + if (!hasEditorRangeSelection(selection)) return + if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}` + return `#${selection.selection.start.line}-${selection.selection.end.line}` +} + +function formatEditorContext(selection: EditorSelection) { + const selected = selection.ranges.filter(hasEditorRangeSelection) + if (selected.length === 0) + return `Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.\n` + + const ranges = selected.map((range, index) => { + const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : "" + return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n` + }) + + return `${ranges.join("\n")} This may or may not be relevant to the current task.\n` +} + +let stashed: { prompt: PromptInfo; cursor: number } | undefined + +export function Prompt(props: PromptProps) { + let input: TextareaRenderable + let anchor: BoxRenderable + const [inputTarget, setInputTarget] = createSignal() + + const leader = useLeaderActive() + const local = useLocal() + const args = useArgs() + const paths = useTuiPaths() + const location = useLocation() + const terminalEnvironment = useTuiTerminalEnvironment() + const clipboard = useClipboard() + const sdk = useSDK() + const editor = useEditorContext() + const route = useRoute() + const project = useProject() + const sync = useSync() + const tuiConfig = useTuiConfig() + const dialog = useDialog() + const toast = useToast() + const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) + const history = usePromptHistory() + const stash = usePromptStash() + const keymap = useOpencodeKeymap() + const agentShortcut = useCommandShortcut("agent.cycle") + const paletteShortcut = useCommandShortcut("command.palette.show") + const renderer = useRenderer() + const exit = useExit() + const dimensions = useTerminalDimensions() + const { theme, syntax } = useTheme() + const kv = useKV() + const animationsEnabled = createMemo(() => kv.get("animations_enabled", true)) + const list = createMemo(() => props.placeholders?.normal ?? []) + const shell = createMemo(() => props.placeholders?.shell ?? []) + const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true)) + const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal() + const editorContext = createMemo(() => { + const selection = fileContextEnabled() ? editor.selection() : undefined + if (!selection) return + return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection + }) + const editorPath = createMemo(() => editorContext()?.filePath) + const editorSelectionLabel = createMemo(() => { + const ranges = editorContext()?.ranges + if (!ranges) return + const first = ranges.find(hasEditorRangeSelection) ?? ranges[0] + if (!first) return + return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined] + .filter(Boolean) + .join(" ") + }) + const editorFileLabel = createMemo(() => { + const value = editorPath() + if (!value) return + const filename = path.basename(value) + const file = /^index\.[^./]+$/.test(filename) + ? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/") + : filename + return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}` + }) + const editorFileLabelDisplay = createMemo(() => { + const file = editorFileLabel() + if (!file) return + return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3)))) + }) + const editorContextLabelState = createMemo(() => editor.labelState()) + const [auto, setAuto] = createSignal() + const workspace = usePromptWorkspace(props.sessionID) + const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) + const [cursorVersion, setCursorVersion] = createSignal(0) + const currentProviderLabel = createMemo(() => local.model.parsed().provider) + const hasRightContent = createMemo(() => Boolean(props.right)) + + function promptModelWarning() { + toast.show({ + variant: "warning", + message: "Connect a provider to send prompts", + duration: 3000, + }) + if (sync.data.provider.length === 0) { + dialog.replace(() => ) + } + } + + function dismissEditorContext() { + setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) + editor.clearSelection() + } + const fileStyleId = syntax().getStyleId("extmark.file")! + const agentStyleId = syntax().getStyleId("extmark.agent")! + const pasteStyleId = syntax().getStyleId("extmark.paste")! + let promptPartTypeId = 0 + const event = useEvent() + + event.on("tui.prompt.append", (evt, { workspace }) => { + if (workspace !== project.workspace.current()) return + if (!input || input.isDestroyed) return + input.insertText(evt.properties.text) + setTimeout(() => { + // setTimeout is a workaround and needs to be addressed properly + if (!input || input.isDestroyed) return + input.getLayoutNode().markDirty() + input.gotoBufferEnd() + renderer.requestRender() + }, 0) + }) + + createEffect(() => { + if (!input || input.isDestroyed) return + if (props.disabled) input.cursorColor = theme.backgroundElement + if (!props.disabled) input.cursorColor = theme.text + if (tuiConfig.cursor) input.cursorStyle = tuiConfig.cursor + }) + + const lastUserMessage = createMemo(() => { + if (!props.sessionID) return undefined + const messages = sync.data.message[props.sessionID] + if (!messages) return undefined + return messages.findLast((m): m is UserMessage => m.role === "user") + }) + + const usage = createMemo(() => { + if (!props.sessionID) return + const session = sync.session.get(props.sessionID) + const msg = sync.data.message[props.sessionID] ?? [] + const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + if (!last) return + + const tokens = + last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + if (tokens <= 0) return + + const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined + const cost = session?.cost ?? 0 + return { + context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), + cost: cost > 0 ? money.format(cost) : undefined, + } + }) + + const [store, setStore] = createStore<{ + prompt: PromptInfo + mode: "normal" | "shell" + extmarkToPartIndex: Map + interrupt: number + placeholder: number + }>({ + placeholder: randomIndex(list().length), + prompt: { + input: "", + parts: [], + }, + mode: "normal", + extmarkToPartIndex: new Map(), + interrupt: 0, + }) + + createEffect( + on( + () => props.sessionID, + () => { + setStore("placeholder", randomIndex(list().length)) + }, + { defer: true }, + ), + ) + + // Initialize agent/model/variant from last user message when session changes + let syncedSessionID: string | undefined + createEffect(() => { + const sessionID = props.sessionID + const msg = lastUserMessage() + + if (sessionID !== syncedSessionID) { + if (!sessionID || !msg) return + + syncedSessionID = sessionID + + // Only set agent if it's a primary agent (not a subagent) + const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) + if (msg.agent && isPrimaryAgent) { + // Keep command line --agent if specified. + if (!args.agent) local.agent.set(msg.agent) + if (msg.model) { + local.model.set(msg.model) + local.model.variant.set(msg.model.variant) + } + } + } + }) + + const promptCommands = createMemo(() => + [ + { + title: "Clear prompt", + name: "prompt.clear", + category: "Prompt", + hidden: true, + run: () => { + clearPrompt() + dialog.clear() + }, + }, + { + title: "Submit prompt", + name: "prompt.submit", + category: "Prompt", + hidden: true, + run: async () => { + if (!input.focused) return + const handled = await submit() + if (!handled) return + + dialog.clear() + }, + }, + { + title: "Remove editor context", + name: "prompt.editor_context.clear", + category: "Prompt", + enabled: Boolean(editorContext()), + run: () => { + dismissEditorContext() + dialog.clear() + }, + }, + { + title: "Paste", + name: "prompt.paste", + category: "Prompt", + hidden: true, + run: async (ctx: CommandContext) => { + ctx.event.preventDefault() + ctx.event.stopPropagation() + const content = await clipboard.read?.() + if (content?.mime.startsWith("image/")) { + await pasteAttachment({ + filename: "clipboard", + mime: content.mime, + content: content.data, + }) + return + } + if (content?.mime === "text/plain") { + await pasteInputText(content.data) + } + }, + }, + { + title: "Interrupt session", + name: "session.interrupt", + category: "Session", + hidden: true, + enabled: status().type !== "idle", + run: () => { + if (auto()?.visible) return + if (!input.focused) return + // TODO: this should be its own command + if (store.mode === "shell") { + setStore("mode", "normal") + return + } + if (!props.sessionID) return + + setStore("interrupt", store.interrupt + 1) + + setTimeout(() => { + setStore("interrupt", 0) + }, 5000) + + if (store.interrupt >= 2) { + void sdk.client.session.abort({ + sessionID: props.sessionID, + }) + setStore("interrupt", 0) + } + dialog.clear() + }, + }, + { + title: "Open editor", + category: "Session", + name: "prompt.editor", + slashName: "editor", + run: async () => { + dialog.clear() + + // replace summarized text parts with the actual text + const text = store.prompt.parts + .filter((p) => p.type === "text") + .reduce((acc, p) => { + if (!p.source) return acc + return acc.replace(p.source.text.value, p.text) + }, store.prompt.input) + + const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") + + const value = text + const content = await openEditor({ + renderer, + value, + cwd: + (project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || + project.instance.directory() || + paths.cwd, + }) + if (!content) return + const normalized = normalizePromptContent(content) + + input.setText(normalized) + + // Update positions for nonTextParts based on their location in new content + // Filter out parts whose virtual text was deleted + // this handles a case where the user edits the text in the editor + // such that the virtual text moves around or is deleted + const updatedNonTextParts = nonTextParts + .map((part) => { + let virtualText = "" + if (part.type === "file" && part.source?.text) { + virtualText = part.source.text.value + } else if (part.type === "agent" && part.source) { + virtualText = part.source.value + } + + if (!virtualText) return part + + const newStart = normalized.indexOf(virtualText) + // if the virtual text is deleted, remove the part + if (newStart === -1) return null + + const newEnd = newStart + virtualText.length + + if (part.type === "file" && part.source?.text) { + return { + ...part, + source: { + ...part.source, + text: { + ...part.source.text, + start: newStart, + end: newEnd, + }, + }, + } + } + + if (part.type === "agent" && part.source) { + return { + ...part, + source: { + ...part.source, + start: newStart, + end: newEnd, + }, + } + } + + return part + }) + .filter((part) => part !== null) + + setStore("prompt", { + input: normalized, + // keep only the non-text parts because the text parts were + // already expanded inline + parts: updatedNonTextParts, + }) + restoreExtmarksFromParts(updatedNonTextParts) + input.cursorOffset = Bun.stringWidth(normalized) + }, + }, + { + title: "Skills", + name: "prompt.skills", + category: "Prompt", + slashName: "skills", + run: () => { + dialog.replace(() => ( + { + input.setText(`/${skill} `) + setStore("prompt", { + input: `/${skill} `, + parts: [], + }) + input.gotoBufferEnd() + }} + /> + )) + }, + }, + { + title: "Warp", + desc: "Change the workspace for the session", + name: "workspace.set", + category: "Session", + enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slashName: "warp", + run: () => { + workspace.open() + }, + }, + { + title: "Move session", + desc: "Move to another project dir", + name: "session.move", + category: "Session", + slashName: "move", + run: () => { + move.open() + }, + }, + ].map((entry) => ({ + namespace: "palette", + ...entry, + })), + ) + + useBindings(() => ({ + commands: promptCommands(), + })) + + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + bindings: tuiConfig.keybinds.gather("prompt.palette", [ + "prompt.submit", + "prompt.editor", + "prompt.editor_context.clear", + "prompt.stash", + "prompt.stash.pop", + "prompt.stash.list", + "prompt.skills", + "session.interrupt", + "workspace.set", + "session.move", + ]), + })) + + const ref: PromptRef = { + get focused() { + return input.focused + }, + get current() { + return store.prompt + }, + focus() { + input.focus() + }, + blur() { + input.blur() + }, + set(prompt) { + input.setText(prompt.input) + setStore("prompt", prompt) + restoreExtmarksFromParts(prompt.parts) + input.gotoBufferEnd() + }, + reset() { + input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + }, + submit() { + void submit() + }, + } + + onMount(() => { + const saved = stashed + stashed = undefined + if (store.prompt.input) return + if (saved && saved.prompt.input) { + input.setText(saved.prompt.input) + setStore("prompt", saved.prompt) + restoreExtmarksFromParts(saved.prompt.parts) + input.cursorOffset = saved.cursor + } + }) + + onCleanup(() => { + if (store.prompt.input) { + stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } + } + setInputTarget(undefined) + props.ref?.(undefined) + }) + + createEffect(() => { + if (!input || input.isDestroyed) return + if (props.visible === false || dialog.stack.length > 0) { + if (input.focused) input.blur() + return + } + + // Slot/plugin updates can remount the background prompt while a dialog is open. + // Keep focus with the dialog and let the prompt reclaim it after the dialog closes. + if (!input.focused) input.focus() + }) + + createEffect(() => { + if (!input || input.isDestroyed) return + input.traits = { + ...input.traits, + ...computePromptTraits({ + mode: store.mode, + autocompleteVisible: !!auto()?.visible, + }), + } + }) + + function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { + input.extmarks.clear() + setStore("extmarkToPartIndex", new Map()) + + parts.forEach((part, partIndex) => { + let start = 0 + let end = 0 + let virtualText = "" + let styleId: number | undefined + + if (part.type === "file" && part.source?.text) { + start = part.source.text.start + end = part.source.text.end + virtualText = part.source.text.value + styleId = fileStyleId + } else if (part.type === "agent" && part.source) { + start = part.source.start + end = part.source.end + virtualText = part.source.value + styleId = agentStyleId + } else if (part.type === "text" && part.source?.text) { + start = part.source.text.start + end = part.source.text.end + virtualText = part.source.text.value + styleId = pasteStyleId + } + + if (virtualText) { + const extmarkId = input.extmarks.create({ + start, + end, + virtual: true, + styleId, + typeId: promptPartTypeId, + }) + setStore("extmarkToPartIndex", (map: Map) => { + const newMap = new Map(map) + newMap.set(extmarkId, partIndex) + return newMap + }) + } + }) + } + + function syncExtmarksWithPromptParts() { + const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) + setStore( + produce((draft) => { + const newMap = new Map() + const newParts: typeof draft.prompt.parts = [] + + for (const extmark of allExtmarks) { + const partIndex = draft.extmarkToPartIndex.get(extmark.id) + if (partIndex !== undefined) { + const part = draft.prompt.parts[partIndex] + if (part) { + if (part.type === "agent" && part.source) { + part.source.start = extmark.start + part.source.end = extmark.end + } else if (part.type === "file" && part.source?.text) { + part.source.text.start = extmark.start + part.source.text.end = extmark.end + } else if (part.type === "text" && part.source?.text) { + part.source.text.start = extmark.start + part.source.text.end = extmark.end + } + newMap.set(extmark.id, newParts.length) + newParts.push(part) + } + } + } + + draft.extmarkToPartIndex = newMap + draft.prompt.parts = newParts + }), + ) + } + + const stashCommands = createMemo(() => + [ + { + title: "Stash prompt", + name: "prompt.stash", + category: "Prompt", + enabled: !!store.prompt.input, + run: () => { + if (!store.prompt.input) return + stash.push({ + input: store.prompt.input, + parts: store.prompt.parts, + }) + input.extmarks.clear() + input.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + dialog.clear() + }, + }, + { + title: "Stash pop", + name: "prompt.stash.pop", + category: "Prompt", + enabled: stash.list().length > 0, + run: () => { + const entry = stash.pop() + if (entry) { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + } + dialog.clear() + }, + }, + { + title: "Stash list", + name: "prompt.stash.list", + category: "Prompt", + enabled: stash.list().length > 0, + run: () => { + dialog.replace(() => ( + { + input.setText(entry.input) + setStore("prompt", { input: entry.input, parts: entry.parts }) + restoreExtmarksFromParts(entry.parts) + input.gotoBufferEnd() + }} + /> + )) + }, + }, + ].map((entry) => ({ + namespace: "palette", + ...entry, + })), + ) + + useBindings(() => ({ + commands: stashCommands(), + })) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled, + bindings: tuiConfig.keybinds.get("prompt.paste"), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", + bindings: tuiConfig.keybinds.get("prompt.clear"), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return ( + inputTarget() !== undefined && + !props.disabled && + store.mode === "normal" && + !auto()?.visible && + input?.visualCursor.offset === 0 + ) + })(), + bindings: [ + { + key: "!", + desc: "Shell mode", + group: "Prompt", + cmd: () => { + setStore("placeholder", randomIndex(shell().length)) + setStore("mode", "shell") + }, + }, + ], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: inputTarget() !== undefined && store.mode === "shell", + bindings: [{ key: "escape", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0 + })(), + bindings: [{ key: "backspace", desc: "Exit shell mode", group: "Prompt", cmd: () => setStore("mode", "normal") }], + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined + })(), + commands: [ + { + name: "prompt.history.previous", + title: "Previous prompt history", + category: "Prompt", + run() { + if (input.cursorOffset !== 0) { + if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0 + return false + } + + const item = history.move(-1, input.plainText) + if (!item) return false + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = 0 + }, + }, + ], + bindings: tuiConfig.keybinds.get("prompt.history.previous"), + } + }) + + useBindings(() => { + return { + target: inputTarget, + enabled: (() => { + cursorVersion() + return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined + })(), + commands: [ + { + name: "prompt.history.next", + title: "Next prompt history", + category: "Prompt", + run() { + if (input.cursorOffset !== input.plainText.length) { + if ( + input.scrollY + input.visualCursor.visualRow === + Math.max(0, input.editorView.getTotalVirtualLineCount() - 1) + ) + input.cursorOffset = input.plainText.length + return false + } + + const item = history.move(1, input.plainText) + if (!item) return false + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + input.cursorOffset = input.plainText.length + }, + }, + ], + bindings: tuiConfig.keybinds.get("prompt.history.next"), + } + }) + + let submitting = false + async function submit() { + // Prevent overlapping invocations (e.g. a double-pressed Enter, or the + // input's native onSubmit racing another dispatch). Without this guard, + // a second call slips past the empty-input check before the first call + // clears `store.prompt.input`, then awaits its own `session.create` and + // ultimately reads the now-empty store — sending a phantom empty prompt + // to a freshly created session. + if (submitting) return false + submitting = true + try { + return await submitInner() + } finally { + submitting = false + } + } + + async function submitInner() { + workspace.clearNotice() + + // IME: double-defer may fire before onContentChange flushes the last + // composed character (e.g. Korean hangul) to the store, so read + // plainText directly and sync before any downstream reads. + if (input && !input.isDestroyed && input.plainText !== store.prompt.input) { + setStore("prompt", "input", input.plainText) + syncExtmarksWithPromptParts() + } + if (props.disabled) return false + if (workspace.creating() || move.creating()) return false + if (auto()?.visible) return false + if (!store.prompt.input) return false + const agent = local.agent.current() + if (!agent) return false + const trimmed = store.prompt.input.trim() + if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { + void exit() + return true + } + const selectedModel = local.model.current() + if (!selectedModel) { + void promptModelWarning() + return false + } + + const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined + const workspaceID = workspaceSession?.workspaceID + const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined + if (props.sessionID && workspaceID && workspaceStatus !== "connected") { + dialog.replace(() => ( + { + workspace.open() + return false + }} + /> + )) + return false + } + + const variant = local.model.variant.current() + let sessionID = props.sessionID + let finishMoveProgress = false + if (sessionID == null) { + const selectedWorkspace = workspace.selection() + const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined + + const directory = await move.getDirectory(store.prompt.input) + if (move.pending() && !directory) return false + finishMoveProgress = Boolean(move.progress()) + + const res = await sdk.client.session.create({ + directory, + workspace: workspaceID, + agent: agent.name, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + }) + + if (res.error) { + if (finishMoveProgress) move.finishSubmit() + console.log("Creating a session failed:", res.error) + + toast.show({ + message: "Creating a session failed. Open console for more details.", + variant: "error", + }) + + return true + } + + sessionID = res.data.id + } + + const inputText = expandTrackedPastedText( + store.prompt.input, + input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => { + const partIndex = store.extmarkToPartIndex.get(extmark.id) + const part = partIndex === undefined ? undefined : store.prompt.parts[partIndex] + if (part?.type !== "text") return [] + return [{ start: extmark.start, end: extmark.end, text: part.text }] + }), + ) + + // Filter out text parts (pasted content) since they're now expanded inline + const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text") + + // Capture mode before it gets reset + const currentMode = store.mode + const editorSelection = editorContext() + const editorParts = + editorSelection && editor.labelState() === "pending" + ? [ + { + type: "text" as const, + text: formatEditorContext(editorSelection), + synthetic: true, + metadata: { + kind: "editor_context", + source: editorSelection.source ?? "editor", + filePath: editorSelection.filePath, + ranges: editorSelection.ranges, + }, + }, + ] + : [] + + if (store.mode === "shell") { + move.startSubmit() + void sdk.client.session.shell({ + sessionID, + agent: agent.name, + model: { + providerID: selectedModel.providerID, + modelID: selectedModel.modelID, + }, + command: inputText, + }) + setStore("mode", "normal") + } else if ( + inputText.startsWith("/") && + sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1)) + ) { + move.startSubmit() + // Parse command from first line, preserve multi-line content in arguments + const firstLineEnd = inputText.indexOf("\n") + const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd) + const [command, ...firstLineArgs] = firstLine.split(" ") + const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) + const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") + + void sdk.client.session.command({ + sessionID, + command: command.slice(1), + arguments: args, + agent: agent.name, + model: `${selectedModel.providerID}/${selectedModel.modelID}`, + variant, + parts: nonTextParts.filter((x) => x.type === "file"), + }) + } else { + move.startSubmit() + sdk.client.session + .prompt( + { + sessionID, + ...selectedModel, + agent: agent.name, + model: selectedModel, + variant, + parts: [ + ...editorParts, + { + type: "text", + text: inputText, + }, + ...nonTextParts, + ], + }, + { throwOnError: true }, + ) + .catch((error) => { + toast.show({ + title: "Failed to send prompt", + message: errorMessage(error), + variant: "error", + }) + }) + if (editorParts.length > 0) editor.markSelectionSent() + } + history.append({ + ...store.prompt, + mode: currentMode, + }) + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + props.onSubmit?.() + + // temporary hack to make sure the message is sent + if (!props.sessionID) { + if (editorParts.length > 0) editor.preserveSelectionFromNewSession() + setTimeout(() => { + route.navigate({ + type: "session", + sessionID, + }) + }, 50) + } + input.clear() + if (finishMoveProgress) move.finishSubmit() + return true + } + + function pasteText(text: string, virtualText: string) { + const currentOffset = input.cursorOffset + const extmarkStart = currentOffset + const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText) + + input.insertText(virtualText + " ") + + const extmarkId = input.extmarks.create({ + start: extmarkStart, + end: extmarkEnd, + virtual: true, + styleId: pasteStyleId, + typeId: promptPartTypeId, + }) + + setStore( + produce((draft) => { + const partIndex = draft.prompt.parts.length + draft.prompt.parts.push({ + type: "text" as const, + text, + source: { + text: { + start: extmarkStart, + end: extmarkEnd, + value: virtualText, + }, + }, + }) + draft.extmarkToPartIndex.set(extmarkId, partIndex) + }), + ) + } + + async function pasteInputText(text: string) { + const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const pastedContent = normalizedText.trim() + const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform) + const isUrl = /^(https?):\/\//.test(filepath) + if (!isUrl) { + const attachment = await readLocalAttachment(filepath) + const filename = path.basename(filepath) + if (attachment?.type === "text") { + pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`) + return + } + if (attachment?.type === "binary") { + await pasteAttachment({ + filename, + filepath, + mime: attachment.mime, + content: Buffer.from(attachment.content).toString("base64"), + }) + return + } + } + + const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 + if ( + (lineCount >= 3 || pastedContent.length > 150) && + kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary) + ) { + pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) + return + } + + input.insertText(normalizedText) + + setTimeout(() => { + if (!input || input.isDestroyed) return + input.getLayoutNode().markDirty() + renderer.requestRender() + }, 0) + } + + async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { + const currentOffset = input.cursorOffset + const extmarkStart = currentOffset + const pdf = file.mime === "application/pdf" + const count = store.prompt.parts.filter((x) => { + if (x.type !== "file") return false + if (pdf) return x.mime === "application/pdf" + return x.mime.startsWith("image/") + }).length + const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]` + const extmarkEnd = extmarkStart + virtualText.length + const textToInsert = virtualText + " " + + input.insertText(textToInsert) + + const extmarkId = input.extmarks.create({ + start: extmarkStart, + end: extmarkEnd, + virtual: true, + styleId: pasteStyleId, + typeId: promptPartTypeId, + }) + + const part: Omit = { + type: "file" as const, + mime: file.mime, + filename: file.filename, + url: `data:${file.mime};base64,${file.content}`, + source: { + type: "file", + path: file.filepath ?? file.filename ?? "", + text: { + start: extmarkStart, + end: extmarkEnd, + value: virtualText, + }, + }, + } + setStore( + produce((draft) => { + const partIndex = draft.prompt.parts.length + draft.prompt.parts.push(part) + draft.extmarkToPartIndex.set(extmarkId, partIndex) + }), + ) + return + } + + function clearPrompt() { + if (store.prompt.input.trim().length >= DRAFT_RETENTION_MIN_CHARS || store.prompt.parts.length > 0) { + history.append({ + ...store.prompt, + mode: store.mode, + }) + } + input.clear() + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], + }) + setStore("extmarkToPartIndex", new Map()) + } + + const highlight = createMemo(() => { + if (leader()) return theme.border + if (store.mode === "shell") return theme.primary + const agent = local.agent.current() + if (!agent) return theme.border + return local.agent.color(agent.name) + }) + + const showVariant = createMemo(() => { + const variants = local.model.variant.list() + if (variants.length === 0) return false + const current = local.model.variant.current() + return !!current + }) + + const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled) + const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled) + const variantMetaAlpha = createFadeIn( + () => !!local.agent.current() && store.mode === "normal" && showVariant(), + animationsEnabled, + ) + const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha())) + + const placeholderText = createMemo(() => { + if (props.showPlaceholder === false) return undefined + if (store.mode === "shell") { + if (!shell().length) return undefined + const example = shell()[store.placeholder % shell().length] + return `Run a command… "${example}"` + } + if (!list().length) return undefined + return `Ask anything… "${list()[store.placeholder % list().length]}"` + }) + + const spinnerDef = createMemo(() => { + const agent = + status().type !== "idle" + ? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current()) + : local.agent.current() + const color = agent ? local.agent.color(agent.name) : theme.border + return { + frames: createFrames({ + color, + style: "blocks", + inactiveFactor: 0.6, + // enableFading: false, + minAlpha: 0.3, + }), + color: createColors({ + color, + style: "blocks", + inactiveFactor: 0.6, + // enableFading: false, + minAlpha: 0.3, + }), + } + }) + const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3))) + const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48))) + + return ( + <> + (anchor = r)} visible={props.visible !== false} width="100%"> + + +