Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ behaviour.
outlined DejaVu Sans Mono Bold paths, no font dependency.
- `test/` — bun tests (`bun test`); `bunfig.toml` preloads OpenTUI's reactive Solid build.
- `tests/changelog.sh`, `scripts/changelog.sh` — release bookkeeping.
- `install.sh` — copies the plugin into opencode's config and registers it in
- `install.sh` + `scripts/install.mjs` — the bash wrapper resolves targets and
`scripts/install.mjs` copies the plugin into opencode's config and registers it in
`cli.json` by directory, migrating old v1 `tui.json`/`tui.jsonc` registrations
if found. Supports explicit config directories and `--all`.

Expand Down
5 changes: 3 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 1 addition & 111 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,116 +80,6 @@ if ! command -v node >/dev/null 2>&1; then
exit 1
fi

node -e '
const fs = require("fs")
const path = require("path")
const [source, ...targetDirs] = process.argv.slice(1)
const repoSrc = path.resolve(source)
const sourceFiles = fs.readdirSync(source).filter((name) => /\.tsx?$/.test(name))
const ownedFiles = new Set(sourceFiles)

const readConfig = (file, key, entry, legacy = false) => {
if (!fs.existsSync(file)) return {}
const raw = fs.readFileSync(file, "utf8")
try {
const config = JSON.parse(raw)
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error()
if (key in config && !Array.isArray(config[key])) throw new Error()
return config
} catch {
if (legacy && !raw.includes("opencode-ghost")) {
console.error(`Warning: skipping unrelated legacy config ${file}; cannot parse it as JSON`)
return {}
}
console.error(`Cannot safely parse ${file} as a JSON object; no files were changed. For JSONC, register manually: "plugins": ["${entry}"]`)
process.exit(1)
}
}

const cleanOptions = (options) => {
if (!options || typeof options !== "object" || Array.isArray(options)) return undefined
const { apiKey, endpoint, internalSessionMarkerDir, argHints, ...kept } = options
return kept
}

const plans = targetDirs.map((configDir) => {
const destination = `${configDir}/plugins/opencode-ghost`
const entry = path.resolve(destination)
const cliFile = `${configDir}/cli.json`
const tuiFile = `${configDir}/tui.json`
const resolvesTo = (spec) => {
if (typeof spec !== "string") return false
if (/^opencode-ghost(?:@[^/]+)?$/.test(spec)) return true
const expanded = spec.startsWith("~/") ? path.join(process.env.HOME, spec.slice(2)) : spec
let resolved
try {
resolved = expanded.startsWith("file://") ? require("url").fileURLToPath(expanded) : path.resolve(configDir, expanded)
} catch {
return false
}
return [entry, path.join(entry, "tui.tsx"), path.join(entry, "src"), path.join(entry, "src", "tui.tsx"), repoSrc, path.join(repoSrc, "tui.tsx")].includes(resolved)
}

const cli = readConfig(cliFile, "plugins", entry)
const legacyConfigs = [tuiFile, `${tuiFile}c`].map((file) => ({ file, config: readConfig(file, "plugin", entry, true) }))

let carriedOptions
const migrated = []
for (const { file, config: tui } of legacyConfigs) {
const list = Array.isArray(tui.plugin) ? tui.plugin : []
const remaining = list.filter((item) => {
const spec = Array.isArray(item) ? item[0] : item && typeof item === "object" ? item.package : item
if (!resolvesTo(spec)) return true
const options = cleanOptions(Array.isArray(item) ? item[1] : item.options)
if (options) carriedOptions = { ...carriedOptions, ...options }
return false
})
if (remaining.length !== list.length) {
tui.plugin = remaining
migrated.push({ file, config: tui })
}
}

const plugins = []
let position
let registration = {}
let options = carriedOptions
for (const item of cli.plugins ?? []) {
const spec = Array.isArray(item) ? item[0] : item && typeof item === "object" ? item.package : item
if (!resolvesTo(spec)) {
plugins.push(item)
continue
}
if (position === undefined) {
position = plugins.length
plugins.push(entry)
}
if (item && typeof item === "object") {
if (!Array.isArray(item)) {
const { package: ignoredPackage, options: ignoredOptions, ...fields } = item
registration = { ...registration, ...fields }
}
options = { ...options, ...cleanOptions(Array.isArray(item) ? item[1] : item.options) }
}
}
const normalized = options || Object.keys(registration).length ? { ...registration, package: entry, ...(options ? { options } : {}) } : entry
if (position === undefined) plugins.push(normalized)
else plugins[position] = normalized
cli.plugins = plugins
return { destination, entry, cliFile, cli, migrated }
})

for (const { destination, entry, cliFile, cli, migrated } of plans) {
fs.mkdirSync(entry, { recursive: true })
for (const name of sourceFiles) fs.copyFileSync(path.join(source, name), path.join(entry, name))
for (const name of fs.readdirSync(entry)) {
if (/\.tsx?$/.test(name) && !ownedFiles.has(name)) fs.rmSync(path.join(entry, name), { force: true })
}
fs.writeFileSync(cliFile, JSON.stringify(cli, null, 2) + "\n")
for (const { file, config } of migrated) fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n")
console.log(`installed -> ${destination}`)
console.log(`registered in ${cliFile}`)
}
' "$src" ${unique_targets+"${unique_targets[@]}"}
node "$here/scripts/install.mjs" "$src" ${unique_targets+"${unique_targets[@]}"}
echo
echo "Restart opencode to load it."
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@
"test:unit": "bun test",
"test:changelog": "bash tests/changelog.sh",
"typecheck": "tsc --noEmit",
"lint:sh": "for script in install.sh tests/*.sh scripts/*.sh; do bash -n \"$script\" || exit; done",
"lint:sh": "for script in install.sh tests/*.sh scripts/*.sh; do bash -n \"$script\" || exit; done && node --check scripts/install.mjs",
"prepublishOnly": "bun run test && bun run typecheck"
},
"dependencies": {
"@opentui/core": "^0.8.0",
"@opentui/solid": "^0.8.0"
"@opentui/core": "^0.8.1",
"@opentui/solid": "^0.8.1"
},
"peerDependencies": {
"@opencode/plugin": "*",
Expand All @@ -60,6 +60,7 @@
"devDependencies": {
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@opencode/client": "2.2.5",
"@opencode/plugin": "2.2.5",
"@types/bun": "^1.4.3",
"@types/node": "26.6.5",
Expand Down
145 changes: 145 additions & 0 deletions scripts/install.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Installs the ghost plugin: node scripts/install.mjs <source-dir> <config-dir>...
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const [source, ...targetDirs] = process.argv.slice(2)
const repoSrc = path.resolve(source)
const sourceFiles = fs.readdirSync(source).filter((name) => /\.tsx?$/.test(name))
const ownedFiles = new Set(sourceFiles)

const readConfig = (file, key, entry, legacy = false) => {
if (!fs.existsSync(file)) return {}
const raw = fs.readFileSync(file, "utf8")
try {
const config = JSON.parse(raw)
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error()
if (key in config && !Array.isArray(config[key])) throw new Error()
return config
} catch {
if (legacy && !raw.includes("opencode-ghost")) {
console.error(`Warning: skipping unrelated legacy config ${file}; cannot parse it as JSON`)
return {}
}
console.error(`Cannot safely parse ${file} as a JSON object; no files were changed. For JSONC, register manually: "plugins": ["${entry}"]`)
process.exit(1)
}
}

const cleanOptions = (options) => {
if (!options || typeof options !== "object" || Array.isArray(options)) return undefined
const { apiKey, endpoint, internalSessionMarkerDir, argHints, ...kept } = options
return kept
}

const plans = targetDirs.map((configDir) => {
const destination = `${configDir}/plugins/opencode-ghost`
const entry = path.resolve(destination)
const cliFile = `${configDir}/cli.json`
const tuiFile = `${configDir}/tui.json`
const resolvesTo = (spec) => {
if (typeof spec !== "string") return false
if (/^opencode-ghost(?:@[^/]+)?$/.test(spec)) return true
const expanded = spec.startsWith("~/") ? path.join(process.env.HOME, spec.slice(2)) : spec
let resolved
try {
resolved = expanded.startsWith("file://") ? fileURLToPath(expanded) : path.resolve(configDir, expanded)
} catch {
return false
}
return [entry, path.join(entry, "tui.tsx"), path.join(entry, "src"), path.join(entry, "src", "tui.tsx"), repoSrc, path.join(repoSrc, "tui.tsx")].includes(resolved)
}

const cli = readConfig(cliFile, "plugins", entry)
const legacyConfigs = [tuiFile, `${tuiFile}c`].map((file) => ({ file, config: readConfig(file, "plugin", entry, true) }))

let carriedOptions
const migrated = []
for (const { file, config: tui } of legacyConfigs) {
const list = Array.isArray(tui.plugin) ? tui.plugin : []
const remaining = list.filter((item) => {
const spec = Array.isArray(item) ? item[0] : item && typeof item === "object" ? item.package : item
if (!resolvesTo(spec)) return true
const options = cleanOptions(Array.isArray(item) ? item[1] : item.options)
if (options) carriedOptions = { ...carriedOptions, ...options }
return false
})
if (remaining.length !== list.length) {
tui.plugin = remaining
migrated.push({ file, config: tui })
}
}

const plugins = []
let position
let registration = {}
let options = carriedOptions
for (const item of cli.plugins ?? []) {
const spec = Array.isArray(item) ? item[0] : item && typeof item === "object" ? item.package : item
if (!resolvesTo(spec)) {
plugins.push(item)
continue
}
if (position === undefined) {
position = plugins.length
plugins.push(entry)
}
if (item && typeof item === "object") {
if (!Array.isArray(item)) {
const { package: ignoredPackage, options: ignoredOptions, ...fields } = item
registration = { ...registration, ...fields }
}
options = { ...options, ...cleanOptions(Array.isArray(item) ? item[1] : item.options) }
}
}
const normalized = options || Object.keys(registration).length ? { ...registration, package: entry, ...(options ? { options } : {}) } : entry
if (position === undefined) plugins.push(normalized)
else plugins[position] = normalized
cli.plugins = plugins
return { destination, entry, cliFile, cli, migrated }
})

// Writes through symlinks (including dangling chains) so a linked config keeps its link.
const resolveLink = (file) => {
let current = file
for (let hops = 0; hops < 40; hops++) {
let target
try {
target = fs.readlinkSync(current)
} catch {
return current
}
current = path.resolve(path.dirname(current), target)
}
return current
}

const writeAtomic = (requested, data) => {
const file = resolveLink(requested)
const temp = `${file}.${process.pid}.tmp`
let mode
try {
mode = fs.statSync(file).mode & 0o7777
} catch {}
try {
fs.writeFileSync(temp, data, { flag: "wx", ...(mode === undefined ? {} : { mode }) })
if (mode !== undefined) fs.chmodSync(temp, mode)
fs.renameSync(temp, file)
} catch (error) {
fs.rmSync(temp, { force: true })
throw error
}
}

const json = (config) => JSON.stringify(config, null, 2) + "\n"

for (const { destination, entry, cliFile, cli, migrated } of plans) {
fs.mkdirSync(entry, { recursive: true })
for (const name of sourceFiles) writeAtomic(path.join(entry, name), fs.readFileSync(path.join(source, name)))
for (const name of fs.readdirSync(entry)) {
if (/\.tsx?$/.test(name) && !ownedFiles.has(name)) fs.rmSync(path.join(entry, name), { force: true })
}
writeAtomic(cliFile, json(cli))
for (const { file, config } of migrated) writeAtomic(file, json(config))
console.log(`installed -> ${destination}`)
console.log(`registered in ${cliFile}`)
}
6 changes: 1 addition & 5 deletions src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function editsComposer(event: { name: string; sequence: string; ctrl: boo
if (event.super && !event.ctrl && !event.meta && !event.option && !event.hyper && event.name === "z") return true
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return false
if (["return", "enter", "linefeed", "space"].includes(event.name)) return true
return [...event.sequence].length === 1 && !/[\u0000-\u001f\u007f]/.test(event.sequence)
return event.sequence.length > 0 && !/[\u0000-\u001f\u007f-\u009f]/.test(event.sequence)
}

export function removesOnly(event: { name: string; ctrl: boolean; meta: boolean; option?: boolean; super?: boolean; hyper?: boolean }): boolean {
Expand Down Expand Up @@ -79,10 +79,6 @@ export function locateComposerEditor<T extends EditorNode>(marker: EditorNode |
return { reason: "no editor found above the marker" }
}

export function findComposerEditor<T extends EditorNode>(marker: EditorNode | undefined, isEditor: (node: EditorNode) => node is T, isComposer: (editor: T) => boolean): T | undefined {
return locateComposerEditor(marker, isEditor, isComposer).editor
}

export function composerAction(editor: Editor | undefined, focused: () => unknown, mode: string, normal: boolean, allowed: boolean, text: string | undefined): boolean {
if (!allowed || !text || !editor || editor !== focused() || !editor.focused || editor.isDestroyed || mode !== "base" || !normal) return false
if (editor.plainText.length !== 0) return false
Expand Down
15 changes: 5 additions & 10 deletions src/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { canonicalKey } from "./composer"

export const acceptCommandId = (key: string) => `ghost.accept.${key}`

export function acceptCommand(key: string, session: () => string | undefined, suggestionSession: () => string | undefined, act: (sessionID: string) => boolean): KeymapCommand {
export function acceptCommand(key: string, session: () => string | undefined, suggestionSession: () => string | undefined, act: (sessionID: string) => boolean, eligible: (sessionID: string) => boolean = () => true): KeymapCommand {
return {
id: acceptCommandId(key),
bind: key,
enabled: () => Boolean(suggestionSession()),
enabled: () => {
const sessionID = session()
return !!sessionID && suggestionSession() === sessionID && eligible(sessionID)
},
run: () => {
const sessionID = session()
if (!sessionID || suggestionSession() !== sessionID || !act(sessionID)) return false
Expand Down Expand Up @@ -40,11 +43,3 @@ export function ghostSuggestLayer(enabled: boolean, suggest: KeymapCommand["run"
}],
}
}

export function ghostKeymapLayers(
composerCommands: KeymapCommand[],
enabled: boolean,
suggest: KeymapCommand["run"],
): KeymapLayer[] {
return [ghostBaseLayer(composerCommands), ghostSuggestLayer(enabled, suggest)]
}
4 changes: 2 additions & 2 deletions src/stateless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ function pause(ms: number, signal: AbortSignal): Promise<void> {
export async function generateSuggestion(
client: GenerateClient,
prompt: string,
model: SuggestionModel | undefined | Promise<SuggestionModel | undefined>,
model: SuggestionModel | undefined,
signal: AbortSignal,
onError?: (error: unknown) => void,
allowed: () => boolean = () => true,
retryDelays: readonly number[] = MODEL_UNAVAILABLE_RETRY_MS,
): Promise<string | undefined> {
try {
const resolved = await model
const resolved = model
if (signal.aborted || !allowed() || !resolved) return undefined
const input = { prompt, model: { id: resolved.modelID, providerID: resolved.providerID, ...(resolved.variant ? { variant: resolved.variant } : {}) } }
for (let attempt = 0; ; attempt++) {
Expand Down
Loading
Loading