Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -78,6 +79,7 @@ export function Autocomplete(props: {
fileStyleId: number
agentStyleId: number
promptPartTypeId: () => number
shellOptions?: AutocompleteOption[]
}) {
const editor = useEditorContext()
const sdk = useSDK()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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()
Expand Down
171 changes: 169 additions & 2 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -347,6 +348,10 @@ export function Prompt(props: PromptProps) {
interrupt: 0,
})

const [shellCompletions, setShellCompletions] = createSignal<string[]>([])
const [shellCompletionBase, setShellCompletionBase] = createSignal("")
const [shellCompletionOptions, setShellCompletionOptions] = createSignal<AutocompleteOption[]>([])

createEffect(
on(
() => props.sessionID,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -1218,6 +1383,7 @@ export function Prompt(props: PromptProps) {
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
shellOptions={shellCompletionOptions()}
/>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
Expand Down Expand Up @@ -1303,6 +1469,7 @@ export function Prompt(props: PromptProps) {
}
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (store.mode === "shell" && autocomplete.visible) autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
Expand Down
Loading