diff --git a/AGENTS.md b/AGENTS.md index 3897bad..478455a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/bun.lock b/bun.lock index a560948..03a455a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,13 @@ "": { "name": "opencode-ghost", "dependencies": { - "@opentui/core": "^0.8.0", - "@opentui/solid": "^0.8.0", + "@opentui/core": "^0.8.1", + "@opentui/solid": "^0.8.1", }, "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", diff --git a/install.sh b/install.sh index 493f3d6..ccf68ce 100755 --- a/install.sh +++ b/install.sh @@ -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." diff --git a/package.json b/package.json index bf5df3e..08327ef 100644 --- a/package.json +++ b/package.json @@ -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": "*", @@ -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", diff --git a/scripts/install.mjs b/scripts/install.mjs new file mode 100644 index 0000000..a8f3fe6 --- /dev/null +++ b/scripts/install.mjs @@ -0,0 +1,145 @@ +// Installs the ghost plugin: node scripts/install.mjs ... +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}`) +} diff --git a/src/composer.ts b/src/composer.ts index 89cc1c8..b5e3f0a 100644 --- a/src/composer.ts +++ b/src/composer.ts @@ -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 { @@ -79,10 +79,6 @@ export function locateComposerEditor(marker: EditorNode | return { reason: "no editor found above the marker" } } -export function findComposerEditor(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 diff --git a/src/keymap.ts b/src/keymap.ts index 4d8a189..5176d6c 100644 --- a/src/keymap.ts +++ b/src/keymap.ts @@ -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 @@ -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)] -} diff --git a/src/stateless.ts b/src/stateless.ts index 1eb88d9..44f7d91 100644 --- a/src/stateless.ts +++ b/src/stateless.ts @@ -32,14 +32,14 @@ function pause(ms: number, signal: AbortSignal): Promise { export async function generateSuggestion( client: GenerateClient, prompt: string, - model: SuggestionModel | undefined | Promise, + model: SuggestionModel | undefined, signal: AbortSignal, onError?: (error: unknown) => void, allowed: () => boolean = () => true, retryDelays: readonly number[] = MODEL_UNAVAILABLE_RETRY_MS, ): Promise { 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++) { diff --git a/src/text.ts b/src/text.ts index d2ffeef..ccb85f2 100644 --- a/src/text.ts +++ b/src/text.ts @@ -15,8 +15,7 @@ export type SmallModelClient = { export function selectSmallModel(models: readonly ModelInfo[], providerID: string): SuggestionModel | undefined { const available = models.filter((model) => model.providerID === providerID && model.enabled && - model.status === "active" && model.capabilities.input.some((item) => item.startsWith("text")) && - model.capabilities.output.some((item) => item.startsWith("text"))) + model.status === "active" && isTextModel(model)) for (const family of ["gpt-luna", "gemini-flash-lite", "gemini-flash", "claude-haiku"]) { const model = available.find((model) => model.family === family) if (model) return { providerID: model.providerID, modelID: model.id } @@ -140,7 +139,7 @@ function withLowEffortVariant(model: SuggestionModel, info: ModelInfo | undefine } function isTextModel(model: ModelInfo) { - const text = (items: readonly string[]) => items.length === 0 || items.some((item) => item.startsWith("text")) + const text = (items: readonly string[]) => items.some((item) => item.startsWith("text")) return text(model.capabilities.input) && text(model.capabilities.output) } @@ -151,6 +150,21 @@ export function parseModel(spec: string | undefined): { providerID: string; mode return { providerID: spec.slice(0, index), modelID: spec.slice(index + 1) } } +const segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : undefined + +export function graphemeLength(text: string): number { + return graphemes(text).length +} + +function graphemes(text: string): string[] { + return segmenter ? Array.from(segmenter.segment(text), (part) => part.segment) : [...text] +} + +export function clipCodePointsTail(text: string, max: number): string { + const points = [...text] + return points.length <= max ? text : points.slice(points.length - max).join("") +} + export function collapse(text: string): string { return text.replace(/\s+/g, " ").trim() } @@ -169,6 +183,10 @@ export function clipTail(text: string, max: number): string { return "…" + points.slice(points.length - (max - 1)).join("").trimStart() } +export function isNone(text: string): boolean { + return /^none\.?$/i.test(text.trim()) || /^[*_`\s]*NONE(?:[\s.,;:!?\-–—*`]|$)/.test(text) +} + export function normalize(raw: string, maxChars: number): string | undefined { const first = raw .split(/\r?\n/) @@ -183,8 +201,8 @@ export function normalize(raw: string, maxChars: number): string | undefined { .replace(/[`"'*_#\s]+$/, "") .replace(/`+/g, "") .trim() - if (!cleaned || /^none\.?$/i.test(cleaned)) return undefined - const points = [...cleaned] + if (!cleaned || isNone(cleaned)) return undefined + const points = graphemes(cleaned) if (points.length <= maxChars) return cleaned return points.slice(0, maxChars - 1).join("").trimEnd() + "…" } diff --git a/src/transcript.ts b/src/transcript.ts index 35803dd..b48f4d7 100644 --- a/src/transcript.ts +++ b/src/transcript.ts @@ -1,4 +1,4 @@ -import { clip, clipTail } from "./text" +import { clip, clipCodePointsTail, clipTail } from "./text" const MAX_MESSAGE_CHARS = 800 const MAX_TRANSCRIPT_CHARS = 6000 @@ -28,7 +28,7 @@ export function buildTranscript(messages: readonly TranscriptMessage[], recentMe lines.push(`${entry.role}: ${(entry.role === "Assistant" ? clipTail : clip)(entry.text, MAX_MESSAGE_CHARS)}`) } if (lines.length === 0) return "" - return `${lines.join("\n").slice(-MAX_TRANSCRIPT_CHARS)}\n\nTask: write the user's next message.` + return `${clipCodePointsTail(lines.join("\n"), MAX_TRANSCRIPT_CHARS)}\n\nTask: write the user's next message.` } export function lastUserText(messages: readonly TranscriptMessage[]): string { diff --git a/src/tui.tsx b/src/tui.tsx index 278a09b..bae8540 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -10,7 +10,7 @@ import { acceptCommand, acceptShortcuts, ghostBaseLayer, ghostSuggestLayer } fro import { parseSuggestCommand, removedOptionKeys, resolveOptions, suggestionModel } from "./options" import { createInlinePlaceholder, dimPlaceholderColor } from "./placeholder" import { generateSuggestion } from "./stateless" -import { createCatalog, isEcho, isNoSmallModel, normalize, resolveExplicitModel, resolveSmallModel, type SuggestionModel } from "./text" +import { createCatalog, graphemeLength, isEcho, isNone, isNoSmallModel, normalize, resolveExplicitModel, resolveSmallModel, type SuggestionModel } from "./text" import { buildTranscript, lastUserText, suggestionPrompt, type TranscriptMessage } from "./transcript" type Suggestion = { sessionID: string; text: string } @@ -142,17 +142,17 @@ export default Plugin.define({ } const clean = normalize(raw, opts.maxChars) if (!clean) { - const none = /^none\.?$/i.test(raw.trim()) - noteGeneration({ outcome: "empty", model: label, ...(none ? {} : { chars: raw.length }) }) + const none = isNone(raw.trim().split(/\r?\n/)[0] ?? "") + noteGeneration({ outcome: "empty", model: label, ...(none ? {} : { chars: graphemeLength(raw) }) }) return } const previous = lastUserText(messages) if (previous && isEcho(clean, previous)) { - noteGeneration({ outcome: "echo", model: label, chars: clean.length }) + noteGeneration({ outcome: "echo", model: label, chars: graphemeLength(clean) }) return } setSuggestion({ sessionID, text: clean }) - noteGeneration({ outcome: "ok", model: label, chars: clean.length }) + noteGeneration({ outcome: "ok", model: label, chars: graphemeLength(clean) }) } catch (error) { if (signal.aborted) return if (syncing) { @@ -220,7 +220,7 @@ export default Plugin.define({ const recorded = displayLog.record({ sessionID: current.sessionID, outcome: status === "shown" ? "shown" : "hidden", - chars: current.text.length, + chars: graphemeLength(current.text), ...(status === "shown" ? {} : { detail: status }), }) if (state.debug === true) context.ui.toast.show({ title: "Ghost", message: formatDiagnostic(recorded) }) @@ -297,81 +297,92 @@ export default Plugin.define({ inline.clear() }) + const canAccept = (sessionID: string) => { + const current = suggestion() + if (!current || current.sessionID !== sessionID) return false + if (!canGenerate(sessionID)) return false + return inline.visible(getComposer(sessionID).editor, current.text) && isComposerEmpty(sessionID) + } + const acceptLayer = () => ghostBaseLayer( opts.acceptKeys.map((key) => acceptCommand(key, () => { const route = context.ui.router.current() return route.type === "session" ? route.sessionID : undefined - }, () => suggestion()?.sessionID, act)), + }, () => suggestion()?.sessionID, act, canAccept)), ) const suggestLayer = () => ghostSuggestLayer(state.enabled, async (input) => { - const command = parseSuggestCommand(input) - if (command.type === "model") { - cancelSuggestion() - lifecycle.resetWarning() - catalog.clear() - await updateState((draft) => { draft.model = command.model }) - cancelSuggestion() - context.ui.toast.show({ message: `Suggestion model: ${state.model ?? opts.model ?? "OpenCode small default"}` }) - return - } - if (command.type === "debug") { - const next = command.value ?? (state.debug !== true) - await updateState((draft) => { draft.debug = next }) - context.ui.toast.show({ message: `Ghost debug: ${next ? "on" : "off"}` }) - return - } - if (command.type === "log") { - const override = state.model ?? opts.model - const summary = `suggestions ${state.enabled ? "on" : "off"}, debug ${state.debug === true ? "on" : "off"}, model ${override ?? "OpenCode small default"}` - const current = suggestion() - const color = ghostColor() - const route = context.ui.router.current() - const entry = route.type === "session" ? composers.get(route.sessionID) : undefined - const editor = lookupComposer(entry?.marker).editor - const live = [ - `route ${route.type}`, - `suggestion ${current ? `retained (${current.text.length} chars)` : "none"}`, - `display ${current ? displayStatus(current) : "n/a"}`, - `focused ${editor ? (editor === context.renderer.currentFocusedEditor && editor.focused ? "yes" : "no") : "n/a"}`, - `mode ${context.keymap.mode.current()}`, - `color rgba(${[color.r, color.g, color.b, color.a].map((value) => value.toFixed(2)).join(", ")})`, - ].join("\n") - const message = `${summary}\n\nNow\n${live}\n\nDisplay changes\n${displayLog.list().length ? formatDiagnostics(displayLog.list()) : "None recorded yet."}\n\nGenerations\n${formatDiagnostics(diagnostics.list())}` - context.ui.dialog.set({ size: "large", centered: true }) - context.ui.dialog.show(() => { - const dimensions = useTerminalDimensions() - return ( - - - Ghost — diagnostics - esc - - - - - - ) - }) - return - } - if (command.type === "invalid") { - context.ui.toast.show({ message: "Use /suggest, /suggest model provider/model, /suggest model clear, /suggest debug [on|off], or /suggest log", variant: "warning" }) - return - } - cancelSuggestion() - const next = !state.enabled - await updateState((draft) => { - draft.enabled = next - }) - cancelSuggestion() - if (!next) { - context.ui.toast.show({ message: "Prompt suggestions: off" }) - return - } - context.ui.toast.show({ message: "Prompt suggestions: on" }) - catalog.clear() - const route = context.ui.router.current() - if (route.type === "session") void lifecycle.run(route.sessionID) + const command = parseSuggestCommand(input) + if (command.type === "model") { + cancelSuggestion() + lifecycle.resetWarning() + catalog.clear() + await updateState((draft) => { draft.model = command.model }) + cancelSuggestion() + context.ui.toast.show({ message: `Suggestion model: ${state.model ?? opts.model ?? "OpenCode small default"}` }) + return + } + if (command.type === "debug") { + let next = false + await updateState((draft) => { + next = command.value ?? (draft.debug !== true) + draft.debug = next + }) + context.ui.toast.show({ message: `Ghost debug: ${next ? "on" : "off"}` }) + return + } + if (command.type === "log") { + const override = state.model ?? opts.model + const summary = `suggestions ${state.enabled ? "on" : "off"}, debug ${state.debug === true ? "on" : "off"}, model ${override ?? "OpenCode small default"}` + const current = suggestion() + const color = ghostColor() + const route = context.ui.router.current() + const entry = route.type === "session" ? composers.get(route.sessionID) : undefined + const editor = lookupComposer(entry?.marker).editor + const live = [ + `route ${route.type}`, + `suggestion ${current ? `retained (${graphemeLength(current.text)} chars)` : "none"}`, + `display ${current ? displayStatus(current) : "n/a"}`, + `focused ${editor ? (editor === context.renderer.currentFocusedEditor && editor.focused ? "yes" : "no") : "n/a"}`, + `mode ${context.keymap.mode.current()}`, + `color rgba(${[color.r, color.g, color.b, color.a].map((value) => value.toFixed(2)).join(", ")})`, + ].join("\n") + const message = `${summary}\n\nNow\n${live}\n\nDisplay changes\n${displayLog.list().length ? formatDiagnostics(displayLog.list()) : "None recorded yet."}\n\nGenerations\n${formatDiagnostics(diagnostics.list())}` + context.ui.dialog.set({ size: "large", centered: true }) + context.ui.dialog.show(() => { + const dimensions = useTerminalDimensions() + return ( + + + Ghost — diagnostics + esc + + + + + + ) + }) + return + } + if (command.type === "invalid") { + context.ui.toast.show({ message: "Use /suggest, /suggest model provider/model, /suggest model clear, /suggest debug [on|off], or /suggest log", variant: "warning" }) + return + } + cancelSuggestion() + let next = false + await updateState((draft) => { + next = !draft.enabled + draft.enabled = next + }) + cancelSuggestion() + if (!next) { + context.ui.toast.show({ message: "Prompt suggestions: off" }) + return + } + context.ui.toast.show({ message: "Prompt suggestions: on" }) + catalog.clear() + const route = context.ui.router.current() + if (route.type === "session") void lifecycle.run(route.sessionID) }) context.keymap.layer(acceptLayer) context.keymap.layer(suggestLayer) diff --git a/test/composer.test.ts b/test/composer.test.ts index 762e417..26e4acc 100644 --- a/test/composer.test.ts +++ b/test/composer.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { InternalKeyHandler, KeyEvent, type KeyHandler } from "@opentui/core" -import { canonicalKey, isValidKey, removesOnly, composerAction, composerBlocker, findComposerEditor, keyName, locateComposerEditor, observeComposerInput, type Editor, type EditorNode } from "../src/composer" +import { canonicalKey, isValidKey, removesOnly, composerAction, composerBlocker, editsComposer, keyName, locateComposerEditor, observeComposerInput, type Editor, type EditorNode } from "../src/composer" import { createLifecycle } from "../src/lifecycle" test("input cancellation runs before consuming keymap listeners, aborts active generation and cleans up", async () => { @@ -143,7 +143,7 @@ test("footer ancestry identifies only its own composer and refuses missing or am attach(root, prompt, modal) attach(prompt, footer, target) attach(footer, marker) - const find = () => findComposerEditor(marker, (item): item is Node => (item as Node).editor === true, (item) => item.composer === true) + const find = () => locateComposerEditor(marker, (item): item is Node => (item as Node).editor === true, (item) => item.composer === true).editor expect(find()).toBe(target) target.composer = false expect(find()).toBeUndefined() @@ -152,7 +152,7 @@ test("footer ancestry identifies only its own composer and refuses missing or am expect(find()).toBeUndefined() marker.isDestroyed = true expect(find()).toBeUndefined() - expect(findComposerEditor(undefined, (item): item is Node => false, () => true)).toBeUndefined() + expect(locateComposerEditor(undefined, (item): item is Node => false, () => true).editor).toBeUndefined() }) test("locateComposerEditor and composerBlocker name the reason a composer is refused", () => { @@ -223,3 +223,17 @@ test("removal keys include plain, ctrl, alt/option word and super line deletion expect(cancelled).toBe(3) off() }) + +test("editsComposer treats multi-code-point text as an edit and keeps keys and escapes non-editing", () => { + const key = (name: string, sequence: string, mods: Partial<{ ctrl: boolean; meta: boolean }> = {}) => ({ name, sequence, ctrl: false, meta: false, ...mods }) + expect(editsComposer(key("a", "a"))).toBe(true) + expect(editsComposer(key("", "你好"))).toBe(true) + expect(editsComposer(key("e", "e\u0301"))).toBe(true) + expect(editsComposer(key("", "😀"))).toBe(true) + expect(editsComposer(key("up", "\u001b[A"))).toBe(false) + expect(editsComposer(key("tab", "\t"))).toBe(false) + expect(editsComposer(key("escape", "\u001b"))).toBe(false) + expect(editsComposer(key("", ""))).toBe(false) + expect(editsComposer(key("a", "a", { meta: true }))).toBe(false) + expect(editsComposer(key("a", "\u0001", { ctrl: true }))).toBe(false) +}) diff --git a/test/keymap.test.ts b/test/keymap.test.ts index 60a8b8a..9c9a1a9 100644 --- a/test/keymap.test.ts +++ b/test/keymap.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { acceptCommand, acceptCommandId, acceptShortcuts, ghostKeymapLayers } from "../src/keymap" +import { acceptCommand, acceptCommandId, acceptShortcuts, ghostBaseLayer, ghostSuggestLayer } from "../src/keymap" import { parseSuggestCommand } from "../src/options" test("accept command returns false to the host for missing, mismatched or ineligible composers", () => { @@ -30,15 +30,15 @@ test("accept shortcuts follow rebound and unbound command bindings", () => { expect([...acceptShortcuts(["tab", "right"], (id) => bindings[id] ?? [])].sort()).toEqual(["alt+enter", "tab"]) }) -test("ghostKeymapLayers configures base mode for composer and global mode for suggest", async () => { +test("ghost layers configure base mode for composer and global mode for suggest", async () => { const composerCommands = [ { id: "ghost.accept.tab", bind: "tab", run: () => {} }, { id: "ghost.accept.right", bind: "right", run: () => {} }, ] const received: ReturnType[] = [] - const layers = ghostKeymapLayers(composerCommands, true, async (input) => { + const layers = [ghostBaseLayer(composerCommands), ghostSuggestLayer(true, async (input) => { received.push(parseSuggestCommand(input)) - }) + })] expect(layers).toHaveLength(2) @@ -62,6 +62,22 @@ test("ghostKeymapLayers configures base mode for composer and global mode for su await suggestCommand.run(undefined) expect(received.at(-1)).toEqual({ type: "toggle" }) - const disabledLayers = ghostKeymapLayers(composerCommands, false, () => {}) - expect(disabledLayers[1].commands![0].title).toBe("Prompt suggestions: off") + expect(ghostSuggestLayer(false, () => {}).commands![0].title).toBe("Prompt suggestions: off") +}) + +test("accept command is enabled only for the current session's eligible composer", () => { + let route: string | undefined = "current" + let suggestion: string | undefined = "current" + let eligible = true + const command = acceptCommand("tab", () => route, () => suggestion, () => true, () => eligible) + const enabled = () => (command.enabled as () => boolean)() + expect(enabled()).toBe(true) + eligible = false + expect(enabled()).toBe(false) + eligible = true + route = "other" + expect(enabled()).toBe(false) + route = "current" + suggestion = undefined + expect(enabled()).toBe(false) }) diff --git a/test/stateless.test.ts b/test/stateless.test.ts index 743f674..9d75b81 100644 --- a/test/stateless.test.ts +++ b/test/stateless.test.ts @@ -6,18 +6,16 @@ function fakeClient(impl: GenerateClient["generate"]["text"]): GenerateClient { } describe("generateSuggestion", () => { - test("abort and route changes during model resolution prevent native generation", async () => { + test("abort and route changes before the request prevent native generation", async () => { for (const reason of ["abort", "route", "unchanged"]) { const controller = new AbortController() let currentSession = "current" let requests = 0 - let resolve!: (model: { providerID: string; modelID: string }) => void - const model = new Promise<{ providerID: string; modelID: string }>((done) => { resolve = done }) + const model = { providerID: "a", modelID: "b" } const client = fakeClient(async () => { requests++; return { text: "next" } }) - const pending = generateSuggestion(client, "prompt", model, controller.signal, undefined, () => currentSession === "current") if (reason === "abort") controller.abort() if (reason === "route") currentSession = "other" - resolve({ providerID: "a", modelID: "b" }) + const pending = generateSuggestion(client, "prompt", model, controller.signal, undefined, () => currentSession === "current") expect(await pending).toBe(reason === "unchanged" ? "next" : undefined) expect(requests).toBe(reason === "unchanged" ? 1 : 0) } diff --git a/test/text.test.ts b/test/text.test.ts index d56b84d..f85c061 100644 --- a/test/text.test.ts +++ b/test/text.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { clip, clipTail, isEcho, normalize, parseModel } from "../src/text" +import { clip, clipTail, isEcho, isNone, normalize, parseModel } from "../src/text" describe("parseModel", () => { test("parses provider/model", () => { @@ -102,3 +102,25 @@ describe("normalize identifiers", () => { expect([...out!]).toEqual([..."😀😀😀😀", "…"]) }) }) + +describe("normalize", () => { + test("never splits a ZWJ emoji sequence when truncating", () => { + const family = "👨‍👩‍👧‍👦" + expect(normalize(`ab${family}${family}`, 4)).toBe(`ab${family}${family}`) + expect(normalize(`a${family}${family}${family}`, 3)).toBe(`a${family}…`) + expect(normalize(`${family}${family}${family}`, 2)).toBe(`${family}…`) + }) + + test("treats NONE followed by punctuation or more text as no suggestion", () => { + for (const raw of ["NONE", "NONE.", "none", "NONE - done", "NONE: nothing fits", "NONE, thanks", "**NONE**"]) { + expect(normalize(raw, 100)).toBeUndefined() + } + expect(normalize("NONE\nrun the tests", 100)).toBeUndefined() + }) + + test("keeps real prompts that merely start with none-like words", () => { + expect(normalize("NONEXISTENT file handling", 100)).toBe("NONEXISTENT file handling") + expect(normalize("None of the tests pass, fix them", 100)).toBe("None of the tests pass, fix them") + expect(isNone("NONE_ENTITY")).toBe(false) + }) +}) diff --git a/test/transcript.test.ts b/test/transcript.test.ts index 7a4d28a..ec1917e 100644 --- a/test/transcript.test.ts +++ b/test/transcript.test.ts @@ -87,3 +87,14 @@ describe("lastUserText", () => { expect(lastUserText([{ type: "assistant", content: [] }])).toBe("") }) }) + +describe("buildTranscript truncation", () => { + test("never splits a surrogate pair at the transcript cut", () => { + for (const pad of ["", "a"]) { + const messages: TranscriptMessage[] = Array.from({ length: 12 }, (_, i) => ({ type: "user", text: pad + "😀".repeat(i % 2 === 0 ? 700 : 701) })) + const transcript = buildTranscript(messages, 12) + expect(transcript.isWellFormed()).toBe(true) + expect(transcript.length).toBeGreaterThan(5000) + } + }) +}) diff --git a/test/tui.test.tsx b/test/tui.test.tsx index 57872ec..f09c192 100644 --- a/test/tui.test.tsx +++ b/test/tui.test.tsx @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test" import { InternalKeyHandler, RGBA, ScrollBoxRenderable, TextRenderable } from "@opentui/core" import { testRender, type JSX } from "@opentui/solid" -import { createRoot, createSignal } from "solid-js" +import { createEffect, createRoot, createSignal } from "solid-js" import { createStore, produce } from "solid-js/store" import type { Context } from "@opencode/plugin/tui/context" import plugin from "../src/tui" @@ -46,7 +46,8 @@ function makeContext(width: number, height: number) { let dialogView: Awaited> | undefined let dialogOptions: { size?: string; centered?: boolean } = {} const toasts: { message: string; variant?: string }[] = [] - const commands = new Map unknown }>() + const commands = new Map boolean); run: (input?: string) => unknown }>() + const layers: { target?: unknown }[] = [] const sessions = new Map([ ["session-a", { status: "idle", messages: [{ type: "user", text: "please add a test" }] }], ["session-b", { status: "idle", messages: [{ type: "user", text: "please add a test" }] }], @@ -81,8 +82,13 @@ function makeContext(width: number, height: number) { location: { default: () => location }, }, keymap: { - layer: (factory: () => { commands?: { id?: string; run: (input?: string) => unknown }[] }) => { - for (const command of factory().commands ?? []) if (command.id) commands.set(command.id, command) + layer: (factory: () => { target?: () => unknown; commands?: { id?: string; enabled?: boolean | (() => boolean); run: (input?: string) => unknown }[] }) => { + createEffect(() => { + const layer = factory() + layers.push(layer) + if (layer.target && !layer.target()) return + for (const command of layer.commands ?? []) if (command.id) commands.set(command.id, command) + }) }, mode: { current: () => "base" }, shortcuts: () => [], @@ -140,6 +146,8 @@ function makeContext(width: number, height: number) { dialog: { get view() { return dialogView }, get options() { return dialogOptions } }, closeDialog: () => context.ui.dialog.clear(), clientState, + commands, + layers, sessions, setBeforeStoreUpdate: (fn: (() => Promise) | undefined) => { beforeStoreUpdate = fn }, messages: context.data.session.message, @@ -447,3 +455,33 @@ test("cancels generation queued while updateState is awaiting during toggle or m } } }) + +test("a quick double toggle flips twice and toasts the values actually written", async () => { + for (const cmd of [undefined, "debug"] as const) { + const harness = boot() + try { + harness.setBeforeStoreUpdate(async () => { await tick(10) }) + await Promise.all([harness.dispatchSuggest(cmd), harness.dispatchSuggest(cmd)]) + harness.setBeforeStoreUpdate(undefined) + const messages = harness.toasts.map((toast) => toast.message) + expect(messages).toEqual(cmd === "debug" ? ["Ghost debug: on", "Ghost debug: off"] : ["Prompt suggestions: off", "Prompt suggestions: on"]) + } finally { + harness.unload() + } + } +}) + +test("accept layer has no target and its commands are enabled only for an eligible composer", async () => { + const harness = boot() + try { + harness.emit("session.execution.succeeded", { id: "evt-accept", data: { sessionID: "session-a" } }) + await tick(40) + const accept = harness.commands.get("ghost.accept.tab") + expect(accept).toBeDefined() + expect(harness.layers.every((layer) => layer.target === undefined)).toBe(true) + const enabled = accept!.enabled as () => boolean + expect(enabled()).toBe(false) + } finally { + harness.unload() + } +})