mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 17:26:22 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2513f29744 |
@@ -15,7 +15,6 @@ import {
|
||||
MouseButton,
|
||||
type CliRenderer,
|
||||
type CliRendererConfig,
|
||||
type KeyEvent,
|
||||
type ThemeMode,
|
||||
} from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "./context/route"
|
||||
@@ -84,7 +83,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { PluginProvider, type PackageResolver } from "./plugin/context"
|
||||
import { usePlugin } from "./plugin/use-plugin"
|
||||
import { tuiPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, PluginSlot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
@@ -972,11 +972,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
name: "app.exit",
|
||||
title: "Exit the app",
|
||||
slash: { name: "exit", aliases: ["quit", "q"] },
|
||||
run: (_input: string | undefined, event?: KeyEvent) => {
|
||||
const current = promptRef.current
|
||||
if (event?.sequence && current?.focused && !current.empty) return false
|
||||
exit()
|
||||
},
|
||||
run: () => exit(),
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
@@ -1132,7 +1128,14 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
bindings: pinnedSessionBindingCommands,
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => {
|
||||
const current = promptRef.current
|
||||
if (!current?.focused) return true
|
||||
return current.current.text === ""
|
||||
},
|
||||
bindings: ["app.exit"],
|
||||
}))
|
||||
|
||||
event.on("tui.command.execute", (evt, { workspace }) => {
|
||||
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useRoute } from "../context/route"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { DevTools } from "../devtools"
|
||||
import { usePlugin } from "../plugin/context"
|
||||
import { usePlugin } from "../plugin/use-plugin"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
const graphWidth = 23
|
||||
|
||||
@@ -70,10 +70,15 @@ export function Autocomplete(props: {
|
||||
visible: false as AutocompleteRef["visible"],
|
||||
input: "keyboard" as "keyboard" | "mouse",
|
||||
})
|
||||
let popMode: (() => void) | undefined
|
||||
|
||||
const [positionTick, setPositionTick] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.visible) return
|
||||
const popMode = keymap.mode.push("autocomplete")
|
||||
onCleanup(popMode)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (store.visible) {
|
||||
let lastPos = { x: 0, y: 0, width: 0 }
|
||||
@@ -267,7 +272,7 @@ export function Autocomplete(props: {
|
||||
const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
|
||||
const index = store.visible === "@" ? store.index : props.input().cursorOffset
|
||||
|
||||
hide(false)
|
||||
setStore("visible", false)
|
||||
setStore("index", index)
|
||||
insertPart(filename, part)
|
||||
}
|
||||
@@ -496,7 +501,6 @@ export function Autocomplete(props: {
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
syncSearch()
|
||||
if (!options().length) return
|
||||
moveTo(moveSelection(store.selected, { count: options().length, delta: direction, policy: "wrap" }))
|
||||
}
|
||||
@@ -514,7 +518,6 @@ export function Autocomplete(props: {
|
||||
}
|
||||
|
||||
function select() {
|
||||
syncSearch()
|
||||
const selected = options()[store.selected]
|
||||
if (!selected) return
|
||||
hide()
|
||||
@@ -586,7 +589,6 @@ export function Autocomplete(props: {
|
||||
title: "Complete autocomplete item",
|
||||
group: "Autocomplete",
|
||||
run() {
|
||||
syncSearch()
|
||||
const selected = options()[store.selected]
|
||||
if (selected?.isDirectory) {
|
||||
expandDirectory()
|
||||
@@ -600,16 +602,15 @@ export function Autocomplete(props: {
|
||||
}))
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
popMode ??= keymap.mode.push("autocomplete")
|
||||
setStore({
|
||||
visible: mode,
|
||||
index: props.input().cursorOffset,
|
||||
})
|
||||
}
|
||||
|
||||
function hide(clear = true) {
|
||||
function hide() {
|
||||
const text = props.input().plainText
|
||||
if (clear && store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
|
||||
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
|
||||
@@ -618,8 +619,6 @@ export function Autocomplete(props: {
|
||||
})
|
||||
}
|
||||
setStore("visible", false)
|
||||
popMode?.()
|
||||
popMode = undefined
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -631,33 +630,35 @@ export function Autocomplete(props: {
|
||||
unsubscribeMention()
|
||||
})
|
||||
|
||||
const ref = {
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
onInput(value?: string) {
|
||||
if (!props.input().focused) return
|
||||
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/)
|
||||
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
|
||||
// "/<command>" is not the sole content
|
||||
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
|
||||
) {
|
||||
hide(false)
|
||||
hide()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
|
||||
const offset = props.input().cursorOffset
|
||||
if (offset === 0) return
|
||||
const text = value ?? (props.input().getTextRange(0, 1) === "/" ? props.input().getTextRange(0, offset) : "")
|
||||
if (text.startsWith("/") && !text.slice(0, offset).match(/\s/)) {
|
||||
|
||||
// Check for "/" at position 0 - reopen slash commands
|
||||
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
|
||||
show("/")
|
||||
setStore("index", 0)
|
||||
return
|
||||
}
|
||||
if (value === undefined) return
|
||||
|
||||
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
|
||||
const idx = mentionTriggerIndex(value, offset)
|
||||
@@ -666,22 +667,9 @@ export function Autocomplete(props: {
|
||||
setStore("index", idx)
|
||||
}
|
||||
},
|
||||
}
|
||||
props.ref(ref)
|
||||
const stopInputSync = keymap.intercept("key", () => ref.onInput())
|
||||
onCleanup(() => {
|
||||
stopInputSync()
|
||||
popMode?.()
|
||||
})
|
||||
})
|
||||
|
||||
function syncSearch() {
|
||||
const next = props.input().getTextRange(store.index + 1, props.input().cursorOffset)
|
||||
if (next === search()) return
|
||||
setSearch(next)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
const height = createMemo(() => {
|
||||
const count = options().length || 1
|
||||
if (!store.visible) return Math.min(10, count)
|
||||
|
||||
@@ -82,7 +82,6 @@ function pastedFilepath(value: string, platform: string) {
|
||||
|
||||
export type PromptRef = {
|
||||
focused: boolean
|
||||
empty: boolean
|
||||
current: PromptInfo
|
||||
set(prompt: PromptInfo): void
|
||||
reset(): void
|
||||
@@ -145,7 +144,6 @@ function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: TextareaRenderable
|
||||
let anchor: BoxRenderable
|
||||
let promptSyncQueued = false
|
||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
||||
|
||||
const leader = Keymap.useLeaderActive()
|
||||
@@ -344,7 +342,6 @@ export function Prompt(props: PromptProps) {
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
if (input.getTextRange(0, 1) === "") return false
|
||||
clearPrompt()
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -449,7 +446,6 @@ export function Prompt(props: PromptProps) {
|
||||
name: "prompt.editor",
|
||||
slash: { name: "editor" },
|
||||
run: async () => {
|
||||
if (promptSyncQueued) await flushPromptSync()
|
||||
dialog.clear()
|
||||
|
||||
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
|
||||
@@ -542,9 +538,6 @@ export function Prompt(props: PromptProps) {
|
||||
get focused() {
|
||||
return input.focused
|
||||
},
|
||||
get empty() {
|
||||
return input.getTextRange(0, 1) === ""
|
||||
},
|
||||
get current() {
|
||||
return store.prompt
|
||||
},
|
||||
@@ -584,7 +577,6 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (promptSyncQueued) void flushPromptSync()
|
||||
if (store.prompt.text) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
@@ -710,9 +702,9 @@ export function Prompt(props: PromptProps) {
|
||||
title: "Stash prompt",
|
||||
name: "prompt.stash",
|
||||
category: "Prompt",
|
||||
enabled: !!store.prompt.text,
|
||||
run: () => {
|
||||
if (input.getTextRange(0, 1) === "") return false
|
||||
void flushPromptSync()
|
||||
if (!store.prompt.text) return
|
||||
stash.push({ prompt: store.prompt })
|
||||
input.extmarks.clear()
|
||||
input.clear()
|
||||
@@ -783,7 +775,7 @@ export function Prompt(props: PromptProps) {
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: () => inputTarget() !== undefined && !props.disabled,
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
||||
bindings: ["prompt.clear"],
|
||||
}
|
||||
})
|
||||
@@ -807,10 +799,6 @@ export function Prompt(props: PromptProps) {
|
||||
title: "Shell mode",
|
||||
group: "Prompt",
|
||||
run: () => {
|
||||
if (input.visualCursor.offset !== 0) {
|
||||
input.insertText("!")
|
||||
return
|
||||
}
|
||||
setStore("placeholder", randomIndex(shell().length))
|
||||
setStore("mode", "shell")
|
||||
},
|
||||
@@ -933,7 +921,6 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
async function submitInner() {
|
||||
if (promptSyncQueued) await flushPromptSync()
|
||||
// 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.
|
||||
@@ -1236,22 +1223,6 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
async function flushPromptSync() {
|
||||
promptSyncQueued = false
|
||||
renderer.removeFrameCallback(flushPromptSync)
|
||||
if (!input || input.isDestroyed) return
|
||||
const value = input.plainText
|
||||
setStore("prompt", "text", value)
|
||||
auto()?.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
|
||||
function queuePromptSync() {
|
||||
if (promptSyncQueued) return
|
||||
promptSyncQueued = true
|
||||
renderer.setFrameCallback(flushPromptSync)
|
||||
}
|
||||
|
||||
async function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
@@ -1293,7 +1264,6 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
function clearPrompt() {
|
||||
if (promptSyncQueued) void flushPromptSync()
|
||||
if (
|
||||
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
|
||||
store.prompt.pasted.length > 0 ||
|
||||
@@ -1420,7 +1390,13 @@ export function Prompt(props: PromptProps) {
|
||||
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
|
||||
minHeight={1}
|
||||
maxHeight={maxHeight()}
|
||||
onContentChange={queuePromptSync}
|
||||
onContentChange={() => {
|
||||
const value = input.plainText
|
||||
setStore("prompt", "text", value)
|
||||
auto()?.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
setCursorVersion((value) => value + 1)
|
||||
}}
|
||||
onCursorChange={() => setCursorVersion((value) => value + 1)}
|
||||
onKeyDown={(e: { preventDefault(): void }) => {
|
||||
if (props.disabled) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { usePlugin } from "../../plugin/use-plugin"
|
||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||
|
||||
const id = "opencode.plugins"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
|
||||
import { batch, createEffect, on, onCleanup, onMount, type ParentProps } from "solid-js"
|
||||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Page, Slot } from "@opencode-ai/plugin/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
@@ -14,32 +14,12 @@ import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
|
||||
import { PluginContext, type PluginState, type RegisteredPlugin } from "./use-plugin"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
type State =
|
||||
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
|
||||
| { readonly target: string; readonly status: "unsupported" }
|
||||
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
||||
|
||||
type RegisteredPlugin = {
|
||||
readonly id: string
|
||||
readonly source: "builtin" | "external"
|
||||
readonly active: boolean
|
||||
}
|
||||
|
||||
type Value = {
|
||||
readonly ready: () => boolean
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
type Registration = {
|
||||
plugin: Plugin.Definition
|
||||
source: RegisteredPlugin["source"]
|
||||
@@ -55,8 +35,6 @@ type Registration = {
|
||||
// One entry of the desired plugin generation produced by the resolve phase.
|
||||
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
@@ -64,7 +42,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
states: [] as ReadonlyArray<State>,
|
||||
states: [] as ReadonlyArray<PluginState>,
|
||||
registrations: {} as Record<string, Registration>,
|
||||
})
|
||||
|
||||
@@ -194,7 +172,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
const desired = new Map<string, Desired>()
|
||||
for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
|
||||
const failures: State[] = []
|
||||
const failures: PluginState[] = []
|
||||
for (const entry of entries) {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-")) {
|
||||
@@ -326,8 +304,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
}
|
||||
|
||||
const failedTargets = new Set(failures.map((failure) => failure.target))
|
||||
const states: State[] = [
|
||||
...[...desired.values()].flatMap((item): State[] => {
|
||||
const states: PluginState[] = [
|
||||
...[...desired.values()].flatMap((item): PluginState[] => {
|
||||
if (item.target === undefined) return []
|
||||
// A failed reload keeps this item running; the failure entry covers it.
|
||||
if (failedTargets.has(item.target)) return []
|
||||
@@ -502,9 +480,3 @@ function isPlugin(value: unknown): value is Plugin.Definition {
|
||||
typeof value.setup === "function"
|
||||
)
|
||||
}
|
||||
|
||||
export function usePlugin() {
|
||||
const value = useContext(PluginContext)
|
||||
if (!value) throw new Error("PluginProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { usePlugin } from "./context"
|
||||
import { usePlugin } from "./use-plugin"
|
||||
|
||||
// Contain render-time plugin crashes: a throwing slot or route must not take
|
||||
// down the app or the other plugins. The crash surfaces as one error toast.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import { createContext, useContext } from "solid-js"
|
||||
|
||||
export type RegisteredPlugin = {
|
||||
readonly id: string
|
||||
readonly source: "builtin" | "external"
|
||||
readonly active: boolean
|
||||
}
|
||||
|
||||
export type PluginState =
|
||||
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
|
||||
| { readonly target: string; readonly status: "unsupported" }
|
||||
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
||||
|
||||
type Value = {
|
||||
readonly ready: () => boolean
|
||||
readonly list: () => ReadonlyArray<PluginState>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: <Name extends SlotName>(
|
||||
name: Name,
|
||||
) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export const PluginContext = createContext<Value>()
|
||||
|
||||
export function usePlugin() {
|
||||
const value = useContext(PluginContext)
|
||||
if (!value) throw new Error("PluginProvider is missing")
|
||||
return value
|
||||
}
|
||||
@@ -37,9 +37,8 @@ export function displayCharAt(value: string, offset: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export function mentionTriggerIndex(value: string, offset?: number) {
|
||||
if (!value.includes("@")) return
|
||||
const text = displaySlice(value, 0, offset ?? promptOffsetWidth(value))
|
||||
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
|
||||
const text = displaySlice(value, 0, offset)
|
||||
const index = text.lastIndexOf("@")
|
||||
if (index === -1) return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user