wip(desktop): progress

This commit is contained in:
Adam 2025-12-15 04:09:57 -06:00
parent 56dde2cc83
commit e9b95b2e91
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
4 changed files with 475 additions and 145 deletions

View file

@ -12,6 +12,7 @@ import { GlobalSDKProvider } from "@/context/global-sdk"
import { SessionProvider } from "@/context/session"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import Layout from "@/pages/layout"
import Home from "@/pages/home"
import DirectoryLayout from "@/pages/directory-layout"
@ -40,27 +41,29 @@ export function App() {
<GlobalSyncProvider>
<LayoutProvider>
<DialogProvider>
<NotificationProvider>
<MetaProvider>
<Font />
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</NotificationProvider>
<CommandProvider>
<NotificationProvider>
<MetaProvider>
<Font />
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</NotificationProvider>
</CommandProvider>
</DialogProvider>
</LayoutProvider>
</GlobalSyncProvider>

View file

@ -1,5 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { makePersisted } from "@solid-primitives/storage"
import { createFocusSignal } from "@solid-primitives/active-element"
@ -19,6 +19,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand, formatKeybind } from "@/context/command"
interface PromptInputProps {
class?: string
@ -53,6 +54,14 @@ const PLACEHOLDERS = [
"How do environment variables work here?",
]
interface SlashCommand {
id: string
trigger: string
title: string
description?: string
keybind?: string
}
export const PromptInput: Component<PromptInputProps> = (props) => {
const navigate = useNavigate()
const sdk = useSDK()
@ -61,18 +70,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const session = useSession()
const dialog = useDialog()
const providers = useProviders()
const command = useCommand()
let editorRef!: HTMLDivElement
const [store, setStore] = createStore<{
popoverIsOpen: boolean
popover: "file" | "slash" | null
historyIndex: number
savedPrompt: Prompt | null
placeholder: number
slashFilter: string
}>({
popoverIsOpen: false,
popover: null,
historyIndex: -1,
savedPrompt: null,
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
slashFilter: "",
})
const MAX_HISTORY = 100
@ -157,17 +169,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onMount(() => {
editorRef.addEventListener("paste", handlePaste)
editorRef?.addEventListener("paste", handlePaste)
})
onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste)
editorRef?.removeEventListener("paste", handlePaste)
})
createEffect(() => {
if (isFocused()) {
handleInput()
} else {
setStore("popoverIsOpen", false)
setStore("popover", null)
}
})
@ -182,6 +194,53 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect: handleFileSelect,
})
// Get slash commands from registered commands (only those with explicit slash trigger)
const slashCommands = createMemo<SlashCommand[]>(() =>
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,
})),
)
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return
// Since slash commands only trigger from start, just clear the input
editorRef.innerHTML = ""
session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("popover", null)
command.trigger(cmd.id, "slash")
}
const {
flat: slashFlat,
active: slashActive,
onInput: slashOnInput,
onKeyDown: slashOnKeyDown,
} = useFilteredList<SlashCommand>({
items: () => {
const filter = store.slashFilter.toLowerCase()
return slashCommands().filter(
(cmd) =>
cmd.trigger.toLowerCase().includes(filter) ||
cmd.title.toLowerCase().includes(filter) ||
cmd.description?.toLowerCase().includes(filter) ||
false,
)
},
key: (x) => x?.id,
onSelect: handleSlashSelect,
})
// Update slash filter when store changes
createEffect(() => {
slashOnInput(store.slashFilter)
})
createEffect(
on(
() => session.prompt.current(),
@ -256,11 +315,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawText = rawParts.map((p) => p.content).join("")
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
// Slash commands only trigger when / is at the start of input
const slashMatch = rawText.match(/^\/(\S*)$/)
if (atMatch) {
onInput(atMatch[1])
setStore("popoverIsOpen", true)
} else if (store.popoverIsOpen) {
setStore("popoverIsOpen", false)
setStore("popover", "file")
} else if (slashMatch) {
setStore("slashFilter", slashMatch[1])
setStore("popover", "slash")
} else {
setStore("popover", null)
}
if (store.historyIndex >= 0) {
@ -294,8 +359,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0)
if (atMatch) {
// let node: Node | null = range.startContainer
// let offset = range.startOffset
let runningLength = 0
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@ -335,7 +398,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
handleInput()
setStore("popoverIsOpen", false)
setStore("popover", null)
}
const abort = () =>
@ -403,8 +466,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleKeyDown = (event: KeyboardEvent) => {
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
onKeyDown(event)
// Handle popover navigation
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "file") {
onKeyDown(event)
} else {
slashOnKeyDown(event)
}
event.preventDefault()
return
}
@ -441,8 +509,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
handleSubmit(event)
}
if (event.key === "Escape") {
if (store.popoverIsOpen) {
setStore("popoverIsOpen", false)
if (store.popover) {
setStore("popover", null)
} else if (session.working()) {
abort()
}
@ -470,31 +538,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (!existing) return
// if (!session.id) {
// session.layout.setOpenedTabs(
// session.layout.copyTabs("", session.id)
// }
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const attachments = prompt.filter((part) => part.type === "file")
// const activeFile = local.context.active()
// if (activeFile) {
// registerAttachment(
// activeFile.path,
// activeFile.selection,
// activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
// )
// }
// for (const contextFile of local.context.all()) {
// registerAttachment(
// contextFile.path,
// contextFile.selection,
// formatAttachmentLabel(contextFile.path, contextFile.selection),
// )
// }
const attachmentParts = attachments.map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
const query = attachment.selection
@ -519,7 +565,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
session.layout.setActiveTab(undefined)
session.messages.setActive(undefined)
// Clear the editor DOM directly to ensure it's empty
editorRef.innerHTML = ""
session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
@ -542,38 +587,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popoverIsOpen}>
{/* Popover for file mentions and slash commands */}
<Show when={store.popover}>
<div
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10
overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
>
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
<For each={flat()}>
{(i) => (
<button
classList={{
"w-full flex items-center justify-between rounded-md": true,
"bg-surface-raised-base-hover": active() === i,
}}
onClick={() => handleFileSelect(i)}
>
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<Show when={!i.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
<Switch>
<Match when={store.popover === "file"}>
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
<For each={flat()}>
{(i) => (
<button
classList={{
"w-full flex items-center gap-x-2 rounded-md px-2 py-1": true,
"bg-surface-raised-base-hover": active() === i,
}}
onClick={() => handleFileSelect(i)}
>
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
<Show when={!i.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</Show>
</div>
</button>
)}
</For>
</Show>
</Match>
<Match when={store.popover === "slash"}>
<Show
when={slashFlat().length > 0}
fallback={<div class="text-text-weak px-2 py-1">No matching commands</div>}
>
<For each={slashFlat()}>
{(cmd) => (
<button
classList={{
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
"bg-surface-raised-base-hover": slashActive() === cmd.id,
}}
onClick={() => handleSlashSelect(cmd)}
>
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
<Show when={cmd.description}>
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
</Show>
</div>
<Show when={cmd.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(cmd.keybind!)}</span>
</Show>
</div>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</button>
)}
</For>
</Show>
</button>
)}
</For>
</Show>
</Match>
</Switch>
</div>
</Show>
<form

View file

@ -0,0 +1,255 @@
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
/**
* Keybind configuration type.
* Format: "mod+key" where mod can be ctrl, alt, shift, meta (or cmd on mac)
* Multiple keybinds can be separated by comma: "mod+p,ctrl+shift+p"
* Use "mod" for platform-appropriate modifier (cmd on mac, ctrl elsewhere)
*/
export type KeybindConfig = string
export interface Keybind {
key: string
ctrl: boolean
meta: boolean
shift: boolean
alt: boolean
}
export interface CommandOption {
/** Unique identifier for the command */
id: string
/** Display title in the command palette */
title: string
/** Optional description */
description?: string
/** Category for grouping in the palette */
category?: string
/** Keybind string (e.g., "mod+p", "ctrl+shift+t") */
keybind?: KeybindConfig
/** Slash command trigger (e.g., "models" for /models) */
slash?: string
/** Whether to show in the "Suggested" section */
suggested?: boolean
/** Whether the command is disabled */
disabled?: boolean
/** Handler when command is selected */
onSelect?: (source?: "palette" | "keybind" | "slash") => void
}
export function parseKeybind(config: string): Keybind[] {
if (!config || config === "none") return []
return config.split(",").map((combo) => {
const parts = combo.trim().toLowerCase().split("+")
const keybind: Keybind = {
key: "",
ctrl: false,
meta: false,
shift: false,
alt: false,
}
for (const part of parts) {
switch (part) {
case "ctrl":
case "control":
keybind.ctrl = true
break
case "meta":
case "cmd":
case "command":
keybind.meta = true
break
case "mod":
if (IS_MAC) keybind.meta = true
else keybind.ctrl = true
break
case "alt":
case "option":
keybind.alt = true
break
case "shift":
keybind.shift = true
break
default:
keybind.key = part
break
}
}
return keybind
})
}
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
const eventKey = event.key.toLowerCase()
for (const kb of keybinds) {
const keyMatch = kb.key === eventKey
const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
const metaMatch = kb.meta === (event.metaKey || false)
const shiftMatch = kb.shift === (event.shiftKey || false)
const altMatch = kb.alt === (event.altKey || false)
if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
return true
}
}
return false
}
export function formatKeybind(config: string): string {
if (!config || config === "none") return ""
const keybinds = parseKeybind(config)
if (keybinds.length === 0) return ""
const kb = keybinds[0]
const parts: string[] = []
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
parts.push(displayKey)
}
return IS_MAC ? parts.join("") : parts.join("+")
}
function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog()
return (
<Dialog title="Commands">
<List
class="px-2.5"
search={{ placeholder: "Search commands", autofocus: true }}
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
key={(x) => x.id}
groupBy={(x) => x.category ?? ""}
onSelect={(option) => {
if (option) {
dialog.clear()
option.onSelect?.("palette")
}
}}
>
{(option) => (
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
<Show when={option.description}>
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
</Show>
</div>
<Show when={option.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
</Show>
</div>
)}
</List>
</Dialog>
)
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const options = createMemo(() => {
const all = registrations().flatMap((x) => x())
const suggested = all.filter((x) => x.suggested && !x.disabled)
return [
...suggested.map((x) => ({
...x,
id: "suggested." + x.id,
category: "Suggested",
})),
...all,
]
})
const suspended = () => suspendCount() > 0
const showPalette = () => {
if (dialog.stack.length === 0) {
dialog.replace(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended()) return
// Check for command palette keybind (mod+shift+p)
const paletteKeybinds = parseKeybind("mod+shift+p")
if (matchKeybind(paletteKeybinds, event)) {
event.preventDefault()
showPalette()
return
}
// Check registered command keybinds
for (const option of options()) {
if (option.disabled) continue
if (!option.keybind) continue
const keybinds = parseKeybind(option.keybind)
if (matchKeybind(keybinds, event)) {
event.preventDefault()
option.onSelect?.("keybind")
return
}
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
})
return {
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results))
})
},
trigger(id: string, source?: "palette" | "keybind" | "slash") {
for (const option of options()) {
if (option.id === id || option.id === "suggested." + id) {
option.onSelect?.(source)
return
}
}
},
show: showPalette,
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
get options() {
return options()
},
}
},
})

View file

@ -34,6 +34,7 @@ import { Terminal } from "@/components/terminal"
import { checksum } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { useCommand } from "@/context/command"
export default function Page() {
const layout = useLayout()
@ -41,6 +42,7 @@ export default function Page() {
const sync = useSync()
const session = useSession()
const dialog = useDialog()
const command = useCommand()
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
activeDraggable: undefined as string | undefined,
@ -48,16 +50,6 @@ export default function Page() {
})
let inputRef!: HTMLDivElement
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
})
createEffect(() => {
if (layout.terminal.opened()) {
if (session.terminal.all().length === 0) {
@ -66,35 +58,54 @@ export default function Page() {
}
})
const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault()
return
}
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
event.preventDefault()
dialog.replace(() => <DialogSelectFile />)
return
}
if (event.ctrlKey && event.key.toLowerCase() === "t") {
event.preventDefault()
const currentTheme = localStorage.getItem("theme") ?? "oc-1"
const themes = ["oc-1", "oc-2-paper"]
const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
localStorage.setItem("theme", nextTheme)
document.documentElement.setAttribute("data-theme", nextTheme)
return
}
if (event.ctrlKey && event.key.toLowerCase() === "`") {
event.preventDefault()
if (event.shiftKey) {
session.terminal.new()
return
}
layout.terminal.toggle()
return
}
// Register commands for this page
command.register(() => [
{
id: "file.open",
title: "Open file",
description: "Search and open a file",
category: "File",
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.replace(() => <DialogSelectFile />),
},
{
id: "theme.toggle",
title: "Toggle theme",
description: "Switch between themes",
category: "View",
keybind: "ctrl+t",
slash: "theme",
onSelect: () => {
const currentTheme = localStorage.getItem("theme") ?? "oc-1"
const themes = ["oc-1", "oc-2-paper"]
const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
localStorage.setItem("theme", nextTheme)
document.documentElement.setAttribute("data-theme", nextTheme)
},
},
{
id: "terminal.toggle",
title: "Toggle terminal",
description: "Show or hide the terminal",
category: "View",
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => layout.terminal.toggle(),
},
{
id: "terminal.new",
title: "New terminal",
description: "Create a new terminal tab",
category: "Terminal",
keybind: "ctrl+shift+`",
onSelect: () => session.terminal.new(),
},
])
// Handle keyboard events that aren't commands
const handleKeyDown = (event: KeyboardEvent) => {
// Don't interfere with terminal
// @ts-expect-error
if (document.activeElement?.dataset?.component === "terminal") {
return
@ -108,32 +119,20 @@ export default function Page() {
return
}
// if (local.file.active()) {
// const active = local.file.active()!
// if (event.key === "Enter" && active.selection) {
// local.context.add({
// type: "file",
// path: active.path,
// selection: { ...active.selection },
// })
// return
// }
//
// if (event.getModifierState(MOD)) {
// if (event.key.toLowerCase() === "a") {
// return
// }
// if (event.key.toLowerCase() === "c") {
// return
// }
// }
// }
// Focus input when typing characters
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
})
const resetClickTimer = () => {
if (!store.clickTimer) return
clearTimeout(store.clickTimer)