diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 93185fc077..494ddb783d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -52,6 +52,7 @@ type Inline = { type SessionInfo = { id: string title?: string + directory?: string } function inline(info: Inline) { @@ -227,24 +228,41 @@ export const RunCommand = cmd({ process.exit(1) } + const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) const directory = (() => { - if (!args.dir) return undefined + if (!args.dir) return args.attach ? undefined : root if (args.attach) return args.dir + try { - process.chdir(args.dir) + process.chdir(path.isAbsolute(args.dir) ? args.dir : path.join(root, args.dir)) return process.cwd() } catch { UI.error("Failed to change directory to " + args.dir) process.exit(1) } })() + const attachHeaders = (() => { + if (!args.attach) return undefined + const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + return { Authorization: auth } + })() + const attachSDK = (dir?: string) => { + return createOpencodeClient({ + baseUrl: args.attach!, + directory: dir, + headers: attachHeaders, + }) + } const files: FilePart[] = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = path.resolve(args.attach ? root : (directory ?? root), filePath) if (!(await Filesystem.exists(resolvedPath))) { UI.error(`File not found: ${filePath}`) process.exit(1) @@ -324,12 +342,14 @@ export const RunCommand = cmd({ return { id, title: forked.data?.title ?? current.data.title, + directory: forked.data?.directory ?? current.data.directory, } } return { id: current.data.id, title: current.data.title, + directory: current.data.directory, } } @@ -347,6 +367,7 @@ export const RunCommand = cmd({ return { id, title: forked.data?.title ?? base.title, + directory: forked.data?.directory ?? base.directory, } } @@ -354,6 +375,7 @@ export const RunCommand = cmd({ return { id: base.id, title: base.title, + directory: base.directory, } } @@ -370,6 +392,7 @@ export const RunCommand = cmd({ return { id, title: result.data?.title ?? name, + directory: result.data?.directory, } } @@ -388,6 +411,23 @@ export const RunCommand = cmd({ } } + async function current(sdk: OpencodeClient): Promise { + if (!args.attach) { + return directory ?? root + } + + const next = await sdk.path + .get() + .then((x) => x.data?.directory) + .catch(() => undefined) + if (next) { + return next + } + + UI.error("Failed to resolve remote directory") + process.exit(1) + } + async function localAgent() { if (!args.agent) return undefined @@ -475,7 +515,11 @@ export const RunCommand = cmd({ return false } - async function loop(events: Awaited>) { + // Consume one subscribed event stream for the active session and mirror it + // to stdout/UI. `client` is passed explicitly because attach mode may + // rebind the SDK to the session's directory after the subscription is + // created, and replies issued from inside the loop must use that client. + async function loop(client: OpencodeClient, events: Awaited>) { const toggles = new Map() let error: string | undefined @@ -582,7 +626,7 @@ export const RunCommand = cmd({ if (permission.sessionID !== sessionID) continue if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "once", }) @@ -592,7 +636,7 @@ export const RunCommand = cmd({ UI.Style.TEXT_NORMAL + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, ) - await sdk.permission.reply({ + await client.permission.reply({ requestID: permission.id, reply: "reject", }) @@ -601,26 +645,29 @@ export const RunCommand = cmd({ } } - // Validate agent if specified - const agent = await pickAgent(sdk) - const sess = await session(sdk) if (!sess?.id) { UI.error("Session not found") process.exit(1) } + const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root) + const client = args.attach ? attachSDK(cwd) : sdk + + // Validate agent if specified + const agent = await pickAgent(client) + const sessionID = sess.id - await share(sdk, sessionID) + await share(client, sessionID) if (!args.interactive) { - const events = await sdk.event.subscribe() - loop(events).catch((e) => { + const events = await client.event.subscribe() + loop(client, events).catch((e) => { console.error(e) process.exit(1) }) if (args.command) { - await sdk.session.command({ + await client.session.command({ sessionID, agent, model: args.model, @@ -632,7 +679,7 @@ export const RunCommand = cmd({ } const model = pick(args.model) - await sdk.session.prompt({ + await client.session.prompt({ sessionID, agent, model, @@ -645,7 +692,8 @@ export const RunCommand = cmd({ const model = pick(args.model) const { runInteractiveMode } = await runtimeTask await runInteractiveMode({ - sdk, + sdk: client, + directory: cwd, sessionID, sessionTitle: sess.title, resume: Boolean(args.session) && !args.fork, @@ -671,6 +719,7 @@ export const RunCommand = cmd({ }) as typeof globalThis.fetch return await runInteractiveLocalMode({ + directory: directory ?? root, fetch: fetchFn, resolveAgent: localAgent, session, @@ -687,22 +736,11 @@ export const RunCommand = cmd({ } if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() - const sdk = createOpencodeClient({ - baseUrl: args.attach, - directory, - headers, - }) + const sdk = attachSDK(directory) return await execute(sdk) } - await bootstrap(process.cwd(), async () => { + await bootstrap(directory ?? root, async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const { Server } = await import("../../server/server") const request = new Request(input, init) @@ -711,6 +749,7 @@ export const RunCommand = cmd({ const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn, + directory, }) await execute(sdk) }) diff --git a/packages/opencode/src/cli/cmd/run/demo.ts b/packages/opencode/src/cli/cmd/run/demo.ts index 342253abf3..a8fcd944c3 100644 --- a/packages/opencode/src/cli/cmd/run/demo.ts +++ b/packages/opencode/src/cli/cmd/run/demo.ts @@ -17,7 +17,7 @@ import path from "path" import type { Event } from "@opencode-ai/sdk/v2" import { createSessionData, reduceSessionData, type SessionData } from "./session-data" import { writeSessionOutput } from "./stream" -import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunDemo } from "./types" +import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunDemo, RunPrompt } from "./types" const KINDS = ["text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"] const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const @@ -975,8 +975,8 @@ export function createRunDemo(input: Input) { } } - const prompt = async (line: string, signal?: AbortSignal): Promise => { - const text = line.trim() + const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise => { + const text = line.text.trim() const list = text.split(/\s+/) const cmd = list[0] || "" diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index b3d6a638b7..077a76e488 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -1,20 +1,27 @@ // Prompt textarea component and its state machine for direct interactive mode. // -// createPromptState() wires keybinds, history navigation, leader-key sequences -// for variant cycling, and the submit/interrupt/exit flow. It produces a -// PromptState that RunPromptBody renders as an OpenTUI textarea. -// -// The leader-key pattern: press the leader key (default ctrl+x), then press -// "t" within 2 seconds to cycle the model variant. This mirrors vim-style -// two-key sequences. The timer auto-clears if the second key doesn't arrive. -// -// History uses arrow keys at cursor boundaries: up at offset 0 scrolls back, -// down at end-of-text scrolls forward, restoring the draft when you return -// past the end of history. +// createPromptState() wires keybinds, history navigation, leader-key sequences, +// and direct-mode `@` autocomplete for files, subagents, and MCP resources. +// It produces a PromptState that RunPromptBody renders as an OpenTUI textarea, +// while RunPromptAutocomplete renders a fixed-height suggestion list below it. /** @jsxImportSource @opentui/solid */ -import { StyledText, bg, fg, type KeyBinding } from "@opentui/core" +import { pathToFileURL } from "bun" +import { StyledText, bg, fg, type KeyBinding, type KeyEvent, type TextareaRenderable } from "@opentui/core" import { useKeyboard } from "@opentui/solid" -import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import fuzzysort from "fuzzysort" +import path from "path" +import { + Index, + Show, + createEffect, + createMemo, + createResource, + createSignal, + onCleanup, + onMount, + type Accessor, +} from "solid-js" +import { Locale } from "../../../util/locale" import { createPromptHistory, isExitCommand, @@ -25,13 +32,29 @@ import { promptKeys, pushPromptHistory, } from "./prompt.shared" -import type { FooterKeybinds, FooterState } from "./types" +import type { FooterKeybinds, FooterState, RunAgent, RunPrompt, RunPromptPart, RunResource } from "./types" import type { RunFooterTheme } from "./theme" const LEADER_TIMEOUT_MS = 2000 +const AUTOCOMPLETE_ROWS = 6 + +const EMPTY_BORDER = { + topLeft: "", + bottomLeft: "", + vertical: "", + topRight: "", + bottomRight: "", + horizontal: " ", + bottomT: "", + topT: "", + cross: "", + leftT: "", + rightT: "", +} export const TEXTAREA_MIN_ROWS = 1 export const TEXTAREA_MAX_ROWS = 6 +export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 export const HINT_BREAKPOINTS = { send: 50, @@ -40,40 +63,29 @@ export const HINT_BREAKPOINTS = { variant: 95, } -type Area = { - isDestroyed: boolean - virtualLineCount: number - visualCursor: { - visualRow: number - } - plainText: string - cursorOffset: number - height?: number - setText(text: string): void - focus(): void - on(event: string, fn: () => void): void - off(event: string, fn: () => void): void -} +type Mention = Extract -type Key = { - name: string - ctrl?: boolean - meta?: boolean - shift?: boolean - super?: boolean - hyper?: boolean - preventDefault(): void +type Auto = { + display: string + value: string + part: Mention + description?: string + directory?: boolean } type PromptInput = { + directory: string + findFiles: (query: string) => Promise + agents: Accessor + resources: Accessor keybinds: FooterKeybinds state: Accessor view: Accessor prompt: Accessor width: Accessor theme: Accessor - history?: string[] - onSubmit: (text: string) => boolean + history?: RunPrompt[] + onSubmit: (input: RunPrompt) => boolean | Promise onCycle: () => void onInterrupt: () => boolean onExitRequest?: () => boolean @@ -85,16 +97,49 @@ type PromptInput = { export type PromptState = { placeholder: Accessor bindings: Accessor + visible: Accessor + options: Accessor + selected: Accessor onSubmit: () => void - onKeyDown: (event: Key) => void + onKeyDown: (event: KeyEvent) => void onContentChange: () => void - bind: (area?: Area) => void + bind: (area?: TextareaRenderable) => void } function clamp(rows: number): number { return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows)) } +function clonePrompt(prompt: RunPrompt): RunPrompt { + return { + text: prompt.text, + parts: structuredClone(prompt.parts), + } +} + +function removeLineRange(input: string) { + const hash = input.lastIndexOf("#") + return hash === -1 ? input : input.slice(0, hash) +} + +function extractLineRange(input: string) { + const hash = input.lastIndexOf("#") + if (hash === -1) { + return { base: input } + } + + const base = input.slice(0, hash) + const line = input.slice(hash + 1) + const match = line.match(/^(\d+)(?:-(\d*))?$/) + if (!match) { + return { base } + } + + const start = Number(match[1]) + const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined + return { base, line: { start, end } } +} + export function hintFlags(width: number) { return { send: width >= HINT_BREAKPOINTS.send, @@ -109,14 +154,14 @@ export function RunPromptBody(props: { placeholder: () => StyledText | string bindings: () => KeyBinding[] onSubmit: () => void - onKeyDown: (event: Key) => void + onKeyDown: (event: KeyEvent) => void onContentChange: () => void - bind: (area?: Area) => void + bind: (area?: TextareaRenderable) => void }) { - let item: Area | undefined + let area: TextareaRenderable | undefined onMount(() => { - props.bind(item) + props.bind(area) }) onCleanup(() => { @@ -124,33 +169,94 @@ export function RunPromptBody(props: { }) return ( - -