diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 47bb162cb4bc..b65bff17dced 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -53,7 +53,8 @@ function extractLineRange(input: string) { export type AutocompleteRef = { onInput: (value: string) => void onKeyDown: (e: KeyEvent) => void - visible: false | "@" | "/" + visible: false | "@" | "/" | "shell" + show: (mode: "@" | "/" | "shell") => void } export type AutocompleteOption = { @@ -78,6 +79,7 @@ export function Autocomplete(props: { fileStyleId: number agentStyleId: number promptPartTypeId: () => number + shellOptions?: AutocompleteOption[] }) { const editor = useEditorContext() const sdk = useSDK() @@ -428,6 +430,7 @@ export function Autocomplete(props: { }) const options = createMemo((prev: AutocompleteOption[] | undefined) => { + if (store.visible === "shell") return props.shellOptions ?? [] const filesValue = files() const agentsValue = agents() const commandsValue = commands() @@ -520,11 +523,11 @@ export function Autocomplete(props: { setStore("selected", 0) } - function show(mode: "@" | "/") { + function show(mode: "@" | "/" | "shell") { command.keybinds(false) setStore({ visible: mode, - index: props.input().cursorOffset, + index: mode === "shell" ? 0 : props.input().cursorOffset, }) } @@ -555,8 +558,15 @@ export function Autocomplete(props: { get visible() { return store.visible }, + show(mode: "@" | "/" | "shell") { + show(mode) + }, onInput(value) { if (store.visible) { + if (store.visible === "shell") { + hide() + return + } if ( // Typed text before the trigger props.input().cursorOffset <= store.index || @@ -623,6 +633,11 @@ export function Autocomplete(props: { return } if (name === "tab") { + if (store.visible === "shell") { + select() + e.preventDefault() + return + } const selected = options()[store.selected] if (selected?.isDirectory) { expandDirectory() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 41e32539eef5..74e182cd1326 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,6 +1,7 @@ import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" +import { statSync, readdirSync } from "fs" import path from "path" import { fileURLToPath } from "url" import { Filesystem } from "@/util/filesystem" @@ -22,9 +23,9 @@ import { computePromptTraits } from "./traits" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" -import { type AutocompleteRef, Autocomplete } from "./autocomplete" +import { type AutocompleteRef, type AutocompleteOption, Autocomplete } from "./autocomplete" import { useCommandDialog } from "../dialog-command" -import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { useRenderer, useTerminalDimensions, useKeyboard, type JSX } from "@opentui/solid" import * as Editor from "@tui/util/editor" import { useExit } from "../../context/exit" import * as Clipboard from "../../util/clipboard" @@ -347,6 +348,10 @@ export function Prompt(props: PromptProps) { interrupt: 0, }) + const [shellCompletions, setShellCompletions] = createSignal([]) + const [shellCompletionBase, setShellCompletionBase] = createSignal("") + const [shellCompletionOptions, setShellCompletionOptions] = createSignal([]) + createEffect( on( () => props.sessionID, @@ -599,6 +604,166 @@ export function Prompt(props: PromptProps) { }, ] }) + useKeyboard((evt) => { + if (store.mode !== "shell" || evt.ctrl || evt.meta) return + if (autocomplete.visible === "shell") { + if (evt.name === "tab" || evt.name === "return" || evt.name === "up" || evt.name === "down" || evt.name === "escape") { + autocomplete.onKeyDown(evt) + if (evt.name === "tab" || evt.name === "return" || evt.name === "escape") evt.preventDefault() + } + return + } + if (evt.name !== "tab") return + evt.preventDefault() + evt.stopPropagation() + if (!input) return + + const text = input.plainText + const endsWithSpace = text.endsWith(" ") || text.endsWith("\t") + const words = text.split(/\s+/) + let partial = endsWithSpace ? "" : (words[words.length - 1] || "") + const partialIndex = endsWithSpace ? text.length : text.lastIndexOf(partial) + if (partial.length === 0 && !endsWithSpace) return + if (partialIndex < 0) return + const fsPartial = partial.startsWith("~") ? partial.replace(/^~/, process.env.HOME || "/root") : partial + + const apply = (c: string) => { + const newText = text.substring(0, partialIndex) + c + input.setText(newText) + setStore("prompt", "input", newText) + input.cursorOffset = Bun.stringWidth(newText) + } + const isDir = (p: string) => { try { return statSync(p.startsWith("~") ? p.replace(/^~/, process.env.HOME || "/root") : p).isDirectory() } catch { return false } } + + if (partial === shellCompletionBase() && shellCompletions().length > 0) { + autocomplete.show("shell") + return + } + + try { + let completions: string[] = [] + + if (!endsWithSpace) { + let dir: string + let searchPrefix: string + if (fsPartial.endsWith("/")) { + dir = fsPartial.slice(0, -1) || "/" + searchPrefix = "" + } else { + dir = path.dirname(fsPartial) || "." + searchPrefix = path.basename(fsPartial) + } + try { + const entries = readdirSync(dir) + completions = entries + .filter((e) => !searchPrefix || e.startsWith(searchPrefix)) + .map((e) => fsPartial.endsWith("/") ? partial + e : path.join(dir, e)) + } catch (e) { + toast.show({ message: `readdir failed: ${e}`, variant: "error", duration: 3000 }) + } + } + + if (completions.length === 0) { + try { + const compPoint = partialIndex + partial.length + const bashScript = `set +o nounset 2>/dev/null +source /usr/share/bash-completion/bash_completion 2>/dev/null || true +COMP_LINE=${JSON.stringify(text)} +COMP_POINT=${compPoint} +COMP_WORDS=(${JSON.stringify(text)}) +COMP_CWORD=$(( ${JSON.stringify(text).split(" ").length - 1} )) +COMP_TYPE=9 +CMD=${JSON.stringify(words[0])} +_completion_loader $CMD 2>/dev/null || true +fn=$(complete -p $CMD 2>/dev/null | sed -n "s/.* -F \\([^ ]*\\).*/\\1/p") +[[ -n "$fn" ]] && declare -f "$fn" >/dev/null 2>&1 && $fn 2>/dev/null +printf '%s\\n' "\${COMPREPLY[@]}"`.replace(/\\n/g, "\n") + const zshScript = `autoload -Uz compinit 2>/dev/null && compinit -id 2>/dev/null +_compadd() { for x in "$@"; do [[ "$x" != -* ]] && echo "$x"; done } +alias compadd=_compadd +COMP_LINE=${JSON.stringify(text)} +COMP_POINT=${compPoint} +_main_complete 2>/dev/null` + const shell = process.env.SHELL || "/bin/bash" + const script = shell.includes("zsh") ? zshScript : bashScript + const sh = shell.includes("zsh") ? "zsh" : "bash" + const proc = Bun.spawnSync({ cmd: [sh, "-c", script], stdout: "pipe", stderr: "pipe" }) + const output = new TextDecoder().decode(proc.stdout) + completions = [...new Set(output.split("\n").filter((s) => s.trim().length > 0))] + } catch {} + } + + if (completions.length === 0 && endsWithSpace) { + try { + const listDir = fsPartial.startsWith("/") ? fsPartial : "." + const entries = readdirSync(listDir) + completions = entries + } catch (e) { + toast.show({ message: `readdir failed: ${e}`, variant: "error", duration: 3000 }) + } + } + + if (!endsWithSpace && completions.length === 0) { + try { + const proc = Bun.spawnSync({ cmd: ["bash", "-c", `compgen -c -- ${JSON.stringify(partial)}`], stdout: "pipe", stderr: "pipe" }) + const output = new TextDecoder().decode(proc.stdout) + completions = [...new Set(output.split("\n").filter((s) => s.trim().length > 0))] + } catch {} + } + + completions = [...new Set(completions)] + if (completions.length === 0) return + + if (completions.length === 1) { + const suffix = isDir(completions[0]) && !completions[0].endsWith("/") ? "/" : "" + apply(completions[0] + suffix) + setShellCompletionBase(completions[0] + suffix) + setShellCompletions([]) + return + } + + const sorted = [...completions].sort((a, b) => a.length - b.length) + let lcp = sorted[0] + for (let i = 1; i < sorted.length; i++) { + while (!sorted[i].startsWith(lcp)) lcp = lcp.slice(0, -1) + if (lcp.length <= partial.length) break + } + + if (lcp.length > partial.length) { + apply(lcp) + setShellCompletionBase(lcp) + setShellCompletions(completions) + const lcpBase = lcp.substring(0, lcp.lastIndexOf("/") + 1) + setShellCompletionOptions( + completions.map((s) => ({ + display: lcpBase ? s.slice(lcpBase.length) : s, + onSelect: () => { + const suffix = isDir(s) && !s.endsWith("/") ? "/" : "" + apply(s + suffix) + }, + })), + ) + autocomplete.show("shell") + return + } + + const base = partial.substring(0, partial.lastIndexOf("/") + 1) + setShellCompletions(completions) + setShellCompletionBase(partial) + setShellCompletionOptions( + completions.map((s) => ({ + display: base ? s.slice(base.length) : s, + onSelect: () => { + const suffix = isDir(s) && !s.endsWith("/") ? "/" : "" + apply(s + suffix) + }, + })), + ) + autocomplete.show("shell") + } catch (e) { + toast.show({ message: `tab complete error: ${e}`, variant: "error", duration: 5000 }) + } + }) const ref: PromptRef = { get focused() { @@ -1218,6 +1383,7 @@ export function Prompt(props: PromptProps) { fileStyleId={fileStyleId} agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} + shellOptions={shellCompletionOptions()} /> (anchor = r)} visible={props.visible !== false}>