From eb3c1038c17dcfa61cf88bcd712fb5702b33844c Mon Sep 17 00:00:00 2001 From: LycanW Date: Wed, 6 May 2026 23:46:56 +0800 Subject: [PATCH 1/2] feat: bash-like Tab completion for shell mode (! commands) in CLI TUI - Single match auto-completes, directories get / suffix - Multiple matches shown without path prefix - Common prefix auto-expansion - File completion via readdirSync (cross-platform) - Command name completion via compgen fallback (first word only) - Works on Linux, macOS, and Windows --- .../cli/cmd/tui/component/prompt/index.tsx | 100 +++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) 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..165cd2e1c4ab 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" @@ -24,7 +25,7 @@ import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, 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" @@ -600,6 +601,103 @@ export function Prompt(props: PromptProps) { ] }) + const [shellCompletions, setShellCompletions] = createSignal([]) + const [shellCompletionBase, setShellCompletionBase] = createSignal("") + + useKeyboard((evt) => { + if (store.mode !== "shell" || evt.name !== "tab" || evt.ctrl || evt.meta) return + evt.preventDefault() + evt.stopPropagation() + if (!input) return + + const text = input.plainText + const words = text.split(/\s+/) + const partial = words[words.length - 1] || "" + const partialIndex = text.lastIndexOf(partial) + if (partial.length === 0 || partialIndex < 0) return + + 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).isDirectory() } catch { return false } } + + if (partial === shellCompletionBase() && shellCompletions().length > 0) { + const base = partial.substring(0, partial.lastIndexOf("/") + 1) + const display = shellCompletions().slice(0, 8).map((s) => base ? s.slice(base.length) : s).join(" ") + toast.show({ message: display + (shellCompletions().length > 8 ? ` ...(${shellCompletions().length} total)` : ""), variant: "info", duration: 3000 }) + return + } + + try { + const isFirstWord = words.length === 1 + let completions: string[] = [] + + let dir: string + let searchPrefix: string + if (partial.endsWith("/")) { + dir = partial.slice(0, -1) || "/" + searchPrefix = "" + } else { + dir = path.dirname(partial) || "." + searchPrefix = path.basename(partial) + } + try { + const entries = readdirSync(dir) + completions = entries + .filter((e) => !searchPrefix || e.startsWith(searchPrefix)) + .map((e) => partial.endsWith("/") ? partial + e : path.join(dir, e)) + } catch (e) { + toast.show({ message: `readdir failed: ${e}`, variant: "error", duration: 3000 }) + } + + if (isFirstWord && 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) { + toast.show({ message: `no matches for "${partial}"`, variant: "info", duration: 2000 }) + return + } + + if (completions.length === 1) { + const suffix = isDir(completions[0]) && !completions[0].endsWith("/") ? "/" : "" + const applied = completions[0] + suffix + apply(applied) + setShellCompletionBase(applied) + setShellCompletions([]) + return + } + + let prefix = completions[0] + for (let i = 1; i < completions.length; i++) { + while (!completions[i].startsWith(prefix)) prefix = prefix.slice(0, -1) + if (prefix.length <= partial.length) break + } + + if (prefix.length > partial.length) { + apply(prefix) + setShellCompletionBase(prefix) + } else { + setShellCompletionBase(partial) + } + + setShellCompletions(completions) + const base = partial.substring(0, partial.lastIndexOf("/") + 1) + const display = completions.slice(0, 8).map((s) => base ? s.slice(base.length) : s).join(" ") + toast.show({ message: display + (completions.length > 8 ? ` ...(${completions.length} total)` : ""), variant: "info", duration: 3000 }) + } catch (e) { + toast.show({ message: `tab complete error: ${e}`, variant: "error", duration: 5000 }) + } + }) + const ref: PromptRef = { get focused() { return input.focused From 1bd6a8e903d669927e7ef865ac0580342245b91f Mon Sep 17 00:00:00 2001 From: LycanW Date: Thu, 7 May 2026 14:17:18 +0800 Subject: [PATCH 2/2] feat: popover-based Tab completion for shell mode (! commands) - Autocomplete popover for multi-match completions (up/down navigation) - File/path completion via readdirSync (cross-platform) - Bash/zsh programmable completion for subcommands (git, systemctl, etc.) - Command name completion via compgen fallback - Common prefix auto-expansion - Tilde (~) path expansion - Single match auto-complete with / suffix for directories --- .../cmd/tui/component/prompt/autocomplete.tsx | 21 ++- .../cli/cmd/tui/component/prompt/index.tsx | 167 +++++++++++++----- 2 files changed, 136 insertions(+), 52 deletions(-) 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 165cd2e1c4ab..74e182cd1326 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -23,7 +23,7 @@ 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, useKeyboard, type JSX } from "@opentui/solid" import * as Editor from "@tui/util/editor" @@ -348,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, @@ -600,21 +604,28 @@ export function Prompt(props: PromptProps) { }, ] }) - - const [shellCompletions, setShellCompletions] = createSignal([]) - const [shellCompletionBase, setShellCompletionBase] = createSignal("") - useKeyboard((evt) => { - if (store.mode !== "shell" || evt.name !== "tab" || evt.ctrl || evt.meta) return + 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+/) - const partial = words[words.length - 1] || "" - const partialIndex = text.lastIndexOf(partial) - if (partial.length === 0 || partialIndex < 0) return + 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 @@ -622,38 +633,77 @@ export function Prompt(props: PromptProps) { setStore("prompt", "input", newText) input.cursorOffset = Bun.stringWidth(newText) } - const isDir = (p: string) => { try { return statSync(p).isDirectory() } catch { return false } } + 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) { - const base = partial.substring(0, partial.lastIndexOf("/") + 1) - const display = shellCompletions().slice(0, 8).map((s) => base ? s.slice(base.length) : s).join(" ") - toast.show({ message: display + (shellCompletions().length > 8 ? ` ...(${shellCompletions().length} total)` : ""), variant: "info", duration: 3000 }) + autocomplete.show("shell") return } try { - const isFirstWord = words.length === 1 let completions: string[] = [] - let dir: string - let searchPrefix: string - if (partial.endsWith("/")) { - dir = partial.slice(0, -1) || "/" - searchPrefix = "" - } else { - dir = path.dirname(partial) || "." - searchPrefix = path.basename(partial) + 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 }) + } } - try { - const entries = readdirSync(dir) - completions = entries - .filter((e) => !searchPrefix || e.startsWith(searchPrefix)) - .map((e) => partial.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 (isFirstWord && completions.length === 0) { + 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) @@ -662,37 +712,54 @@ export function Prompt(props: PromptProps) { } completions = [...new Set(completions)] - if (completions.length === 0) { - toast.show({ message: `no matches for "${partial}"`, variant: "info", duration: 2000 }) - return - } + if (completions.length === 0) return if (completions.length === 1) { const suffix = isDir(completions[0]) && !completions[0].endsWith("/") ? "/" : "" - const applied = completions[0] + suffix - apply(applied) - setShellCompletionBase(applied) + apply(completions[0] + suffix) + setShellCompletionBase(completions[0] + suffix) setShellCompletions([]) return } - let prefix = completions[0] - for (let i = 1; i < completions.length; i++) { - while (!completions[i].startsWith(prefix)) prefix = prefix.slice(0, -1) - if (prefix.length <= partial.length) break + 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 (prefix.length > partial.length) { - apply(prefix) - setShellCompletionBase(prefix) - } else { - setShellCompletionBase(partial) + 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 } - setShellCompletions(completions) const base = partial.substring(0, partial.lastIndexOf("/") + 1) - const display = completions.slice(0, 8).map((s) => base ? s.slice(base.length) : s).join(" ") - toast.show({ message: display + (completions.length > 8 ? ` ...(${completions.length} total)` : ""), variant: "info", duration: 3000 }) + 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 }) } @@ -1316,6 +1383,7 @@ export function Prompt(props: PromptProps) { fileStyleId={fileStyleId} agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} + shellOptions={shellCompletionOptions()} /> (anchor = r)} visible={props.visible !== false}>