Simon Klee 1 неделя назад
Родитель
Сommit
7db7ff3889

+ 67 - 28
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<string> {
+      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<ReturnType<typeof sdk.event.subscribe>>) {
+      // 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<ReturnType<typeof sdk.event.subscribe>>) {
         const toggles = new Map<string, boolean>()
         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)
     })

+ 3 - 3
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<boolean> => {
-    const text = line.trim()
+  const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
+    const text = line.text.trim()
     const list = text.split(/\s+/)
     const cmd = list[0] || ""
 

+ 627 - 121
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<RunPromptPart, { type: "file" | "agent" }>
 
-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<string[]>
+  agents: Accessor<RunAgent[]>
+  resources: Accessor<RunResource[]>
   keybinds: FooterKeybinds
   state: Accessor<FooterState>
   view: Accessor<string>
   prompt: Accessor<boolean>
   width: Accessor<number>
   theme: Accessor<RunFooterTheme>
-  history?: string[]
-  onSubmit: (text: string) => boolean
+  history?: RunPrompt[]
+  onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
   onCycle: () => void
   onInterrupt: () => boolean
   onExitRequest?: () => boolean
@@ -85,16 +97,49 @@ type PromptInput = {
 export type PromptState = {
   placeholder: Accessor<StyledText | string>
   bindings: Accessor<KeyBinding[]>
+  visible: Accessor<boolean>
+  options: Accessor<Auto[]>
+  selected: Accessor<number>
   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 (
-    <box id="run-direct-footer-prompt"
-      paddingTop={1}
-      paddingLeft={2}
-      paddingRight={2}
+    <box id="run-direct-footer-prompt" width="100%">
+      <box id="run-direct-footer-input-shell" paddingTop={1} paddingLeft={2} paddingRight={2}>
+        <textarea
+          id="run-direct-footer-composer"
+          width="100%"
+          minHeight={TEXTAREA_MIN_ROWS}
+          maxHeight={TEXTAREA_MAX_ROWS}
+          wrapMode="word"
+          placeholder={props.placeholder()}
+          placeholderColor={props.theme().muted}
+          textColor={props.theme().text}
+          focusedTextColor={props.theme().text}
+          backgroundColor={props.theme().surface}
+          focusedBackgroundColor={props.theme().surface}
+          cursorColor={props.theme().text}
+          keyBindings={props.bindings()}
+          onSubmit={props.onSubmit}
+          onKeyDown={props.onKeyDown}
+          onContentChange={props.onContentChange}
+          ref={(next) => {
+            area = next
+          }}
+        />
+      </box>
+    </box>
+  )
+}
+
+export function RunPromptAutocomplete(props: {
+  theme: () => RunFooterTheme
+  options: () => Auto[]
+  selected: () => number
+}) {
+  return (
+    <box
+      id="run-direct-footer-complete"
+      width="100%"
+      height={AUTOCOMPLETE_ROWS}
+      border={["left"]}
+      borderColor={props.theme().border}
+      customBorderChars={{
+        ...EMPTY_BORDER,
+        vertical: "┃",
+      }}
     >
-      <textarea
-        id="run-direct-footer-composer"
+      <box
+        id="run-direct-footer-complete-fill"
         width="100%"
-        minHeight={TEXTAREA_MIN_ROWS}
-        maxHeight={TEXTAREA_MAX_ROWS}
-        wrapMode="word"
-        placeholder={props.placeholder()}
-        placeholderColor={props.theme().muted}
-        textColor={props.theme().text}
-        focusedTextColor={props.theme().text}
-
-        backgroundColor={props.theme().surface}
-        focusedBackgroundColor={props.theme().surface}
-        cursorColor={props.theme().text}
-        keyBindings={props.bindings()}
-        onSubmit={props.onSubmit}
-        onKeyDown={props.onKeyDown}
-        onContentChange={props.onContentChange}
-        ref={(next) => {
-          item = next as Area
-        }}
-      />
+        height={AUTOCOMPLETE_ROWS}
+        flexDirection="column"
+        backgroundColor={props.theme().pane}
+      >
+        <Index
+          each={props.options()}
+          fallback={
+            <box paddingLeft={1} paddingRight={1}>
+              <text fg={props.theme().muted}>No matching items</text>
+            </box>
+          }
+        >
+          {(item, index) => (
+            <box
+              paddingLeft={1}
+              paddingRight={1}
+              flexDirection="row"
+              gap={1}
+              backgroundColor={index === props.selected() ? props.theme().highlight : undefined}
+            >
+              <text
+                fg={index === props.selected() ? props.theme().surface : props.theme().text}
+                wrapMode="none"
+                truncate
+              >
+                {item().display}
+              </text>
+              <Show when={item().description}>
+                <text
+                  fg={index === props.selected() ? props.theme().surface : props.theme().muted}
+                  wrapMode="none"
+                  truncate
+                >
+                  {item().description}
+                </text>
+              </Show>
+            </box>
+          )}
+        </Index>
+      </box>
     </box>
   )
 }
@@ -158,7 +264,6 @@ export function RunPromptBody(props: {
 export function createPromptState(input: PromptInput): PromptState {
   const keys = createMemo(() => promptKeys(input.keybinds))
   const bindings = createMemo(() => keys().bindings)
-  const [draft, setDraft] = createSignal("")
   const placeholder = createMemo(() => {
     if (!input.state().first) {
       return ""
@@ -170,12 +275,138 @@ export function createPromptState(input: PromptInput): PromptState {
   })
 
   let history = createPromptHistory(input.history)
-
-  let area: Area | undefined
+  let draft: RunPrompt = { text: "", parts: [] }
+  let stash: RunPrompt = { text: "", parts: [] }
+  let area: TextareaRenderable | undefined
   let leader = false
   let timeout: NodeJS.Timeout | undefined
   let tick = false
   let prev = input.view()
+  let type = 0
+  let parts: Mention[] = []
+  let marks = new Map<number, number>()
+
+  const [visible, setVisible] = createSignal(false)
+  const [at, setAt] = createSignal(0)
+  const [selected, setSelected] = createSignal(0)
+  const [query, setQuery] = createSignal("")
+
+  const width = createMemo(() => Math.max(20, input.width() - 8))
+  const agents = createMemo<Auto[]>(() => {
+    return input
+      .agents()
+      .filter((item) => !item.hidden && item.mode !== "primary")
+      .map((item) => ({
+        display: "@" + item.name,
+        value: item.name,
+        part: {
+          type: "agent",
+          name: item.name,
+          source: {
+            start: 0,
+            end: 0,
+            value: "",
+          },
+        },
+      }))
+  })
+  const resources = createMemo<Auto[]>(() => {
+    return input.resources().map((item) => ({
+      display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
+      value: item.name,
+      description: item.description,
+      part: {
+        type: "file",
+        mime: item.mimeType ?? "text/plain",
+        filename: item.name,
+        url: item.uri,
+        source: {
+          type: "resource",
+          clientName: item.client,
+          uri: item.uri,
+          text: {
+            start: 0,
+            end: 0,
+            value: "",
+          },
+        },
+      },
+    }))
+  })
+  const [files] = createResource(
+    query,
+    async (value) => {
+      if (!visible()) {
+        return []
+      }
+
+      const next = extractLineRange(value)
+      const list = await input.findFiles(next.base)
+      return list
+        .sort((a, b) => {
+          const dir = Number(b.endsWith("/")) - Number(a.endsWith("/"))
+          if (dir !== 0) {
+            return dir
+          }
+
+          const depth = a.split("/").length - b.split("/").length
+          if (depth !== 0) {
+            return depth
+          }
+
+          return a.localeCompare(b)
+        })
+        .map((item): Auto => {
+          const url = pathToFileURL(path.resolve(input.directory, item))
+          let filename = item
+          if (next.line && !item.endsWith("/")) {
+            filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
+            url.searchParams.set("start", String(next.line.start))
+            if (next.line.end !== undefined) {
+              url.searchParams.set("end", String(next.line.end))
+            }
+          }
+
+          return {
+            display: Locale.truncateMiddle("@" + filename, width()),
+            value: filename,
+            directory: item.endsWith("/"),
+            part: {
+              type: "file",
+              mime: item.endsWith("/") ? "application/x-directory" : "text/plain",
+              filename,
+              url: url.href,
+              source: {
+                type: "file",
+                path: item,
+                text: {
+                  start: 0,
+                  end: 0,
+                  value: "",
+                },
+              },
+            },
+          }
+        })
+    },
+    { initialValue: [] as Auto[] },
+  )
+  const options = createMemo(() => {
+    const mixed = [...agents(), ...files(), ...resources()]
+    if (!query()) {
+      return mixed.slice(0, AUTOCOMPLETE_ROWS)
+    }
+
+    return fuzzysort
+      .go(removeLineRange(query()), mixed, {
+        keys: [(item) => (item.value || item.display).trimEnd(), "description"],
+        limit: AUTOCOMPLETE_ROWS,
+      })
+      .map((item) => item.obj)
+  })
+  const popup = createMemo(() => {
+    return visible() ? AUTOCOMPLETE_ROWS - 1 : 0
+  })
 
   const clear = () => {
     leader = false
@@ -195,12 +426,18 @@ export function createPromptState(input: PromptInput): PromptState {
     }, LEADER_TIMEOUT_MS)
   }
 
+  const hide = () => {
+    setVisible(false)
+    setQuery("")
+    setSelected(0)
+  }
+
   const syncRows = () => {
     if (!area || area.isDestroyed) {
       return
     }
 
-    input.onRows(clamp(area.virtualLineCount || 1))
+    input.onRows(clamp(area.virtualLineCount || 1) + popup())
   }
 
   const scheduleRows = () => {
@@ -215,7 +452,146 @@ export function createPromptState(input: PromptInput): PromptState {
     })
   }
 
-  const bind = (next?: Area) => {
+  const syncParts = () => {
+    if (!area || area.isDestroyed || type === 0) {
+      return
+    }
+
+    const next: Mention[] = []
+    const map = new Map<number, number>()
+    for (const item of area.extmarks.getAllForTypeId(type)) {
+      const idx = marks.get(item.id)
+      if (idx === undefined) {
+        continue
+      }
+
+      const part = parts[idx]
+      if (!part) {
+        continue
+      }
+
+      const text = area.plainText.slice(item.start, item.end)
+      const prev =
+        part.type === "agent"
+          ? (part.source?.value ?? "@" + part.name)
+          : (part.source?.text.value ?? "@" + (part.filename ?? ""))
+      if (text !== prev) {
+        continue
+      }
+
+      const copy = structuredClone(part)
+      if (copy.type === "agent") {
+        copy.source = {
+          start: item.start,
+          end: item.end,
+          value: text,
+        }
+      }
+      if (copy.type === "file" && copy.source?.text) {
+        copy.source.text.start = item.start
+        copy.source.text.end = item.end
+        copy.source.text.value = text
+      }
+
+      map.set(item.id, next.length)
+      next.push(copy)
+    }
+
+    const stale = map.size !== marks.size
+    parts = next
+    marks = map
+    if (stale) {
+      restoreParts(next)
+    }
+  }
+
+  const clearParts = () => {
+    if (area && !area.isDestroyed) {
+      area.extmarks.clear()
+    }
+    parts = []
+    marks = new Map()
+  }
+
+  const restoreParts = (value: RunPromptPart[]) => {
+    clearParts()
+    parts = value
+      .filter((item): item is Mention => item.type === "file" || item.type === "agent")
+      .map((item) => structuredClone(item))
+    if (!area || area.isDestroyed || type === 0) {
+      return
+    }
+
+    const box = area
+    parts.forEach((item, idx) => {
+      const start = item.type === "agent" ? item.source?.start : item.source?.text.start
+      const end = item.type === "agent" ? item.source?.end : item.source?.text.end
+      if (start === undefined || end === undefined) {
+        return
+      }
+
+      const id = box.extmarks.create({
+        start,
+        end,
+        virtual: true,
+        typeId: type,
+      })
+      marks.set(id, idx)
+    })
+  }
+
+  const restore = (value: RunPrompt, cursor = value.text.length) => {
+    draft = clonePrompt(value)
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    hide()
+    area.setText(value.text)
+    restoreParts(value.parts)
+    area.cursorOffset = Math.min(cursor, area.plainText.length)
+    scheduleRows()
+    area.focus()
+  }
+
+  const refresh = () => {
+    if (!area || area.isDestroyed) {
+      return
+    }
+
+    const cursor = area.cursorOffset
+    const text = area.plainText
+    if (visible()) {
+      if (cursor <= at() || /\s/.test(text.slice(at(), cursor))) {
+        hide()
+        return
+      }
+
+      setQuery(text.slice(at() + 1, cursor))
+      return
+    }
+
+    if (cursor === 0) {
+      return
+    }
+
+    const head = text.slice(0, cursor)
+    const idx = head.lastIndexOf("@")
+    if (idx === -1) {
+      return
+    }
+
+    const before = idx === 0 ? undefined : head[idx - 1]
+    const tail = head.slice(idx)
+    if ((before === undefined || /\s/.test(before)) && !/\s/.test(tail)) {
+      setAt(idx)
+      setSelected(0)
+      setVisible(true)
+      setQuery(head.slice(idx + 1))
+    }
+  }
+
+  const bind = (next?: TextareaRenderable) => {
     if (area === next) {
       return
     }
@@ -229,19 +605,17 @@ export function createPromptState(input: PromptInput): PromptState {
       return
     }
 
+    if (type === 0) {
+      type = area.extmarks.registerType("run-direct-prompt-part")
+    }
     area.on("line-info-change", scheduleRows)
     queueMicrotask(() => {
       if (!area || area.isDestroyed || !input.prompt()) {
         return
       }
 
-      if (area.plainText !== draft()) {
-        area.setText(draft())
-      }
-
-      area.cursorOffset = area.plainText.length
-      scheduleRows()
-      area.focus()
+      restore(draft)
+      refresh()
     })
   }
 
@@ -250,31 +624,39 @@ export function createPromptState(input: PromptInput): PromptState {
       return
     }
 
-    setDraft(area.plainText)
+    syncParts()
+    draft = {
+      text: area.plainText,
+      parts: structuredClone(parts),
+    }
   }
 
-  const push = (text: string) => {
-    history = pushPromptHistory(history, text)
+  const push = (value: RunPrompt) => {
+    history = pushPromptHistory(history, value)
   }
 
-  const move = (dir: -1 | 1, event: Key) => {
+  const move = (dir: -1 | 1, event: KeyEvent) => {
     if (!area || area.isDestroyed) {
       return
     }
 
+    if (history.index === null && dir === -1) {
+      stash = clonePrompt(draft)
+    }
+
     const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
     if (!next.apply || next.text === undefined || next.cursor === undefined) {
       return
     }
 
     history = next.state
-    area.setText(next.text)
-    area.cursorOffset = next.cursor
+    const value =
+      next.state.index === null ? stash : (next.state.items[next.state.index] ?? { text: next.text, parts: [] })
+    restore(value, next.cursor)
     event.preventDefault()
-    syncRows()
   }
 
-  const cycle = (event: Key): boolean => {
+  const cycle = (event: KeyEvent): boolean => {
     const next = promptCycle(leader, promptInfo(event), keys().leaders, keys().cycles)
     if (!next.consume) {
       return false
@@ -296,7 +678,130 @@ export function createPromptState(input: PromptInput): PromptState {
     return true
   }
 
-  const onKeyDown = (event: Key) => {
+  const select = (item?: Auto) => {
+    const next = item ?? options()[selected()]
+    if (!next || !area || area.isDestroyed) {
+      return
+    }
+
+    const cursor = area.cursorOffset
+    const tail = area.plainText.at(cursor)
+    const append = "@" + next.value + (tail === " " ? "" : " ")
+    area.cursorOffset = at()
+    const start = area.logicalCursor
+    area.cursorOffset = cursor
+    const end = area.logicalCursor
+    area.deleteRange(start.row, start.col, end.row, end.col)
+    area.insertText(append)
+
+    const text = "@" + next.value
+    const startOffset = at()
+    const endOffset = startOffset + Bun.stringWidth(text)
+    const part = structuredClone(next.part)
+    if (part.type === "agent") {
+      part.source = {
+        start: startOffset,
+        end: endOffset,
+        value: text,
+      }
+    }
+    if (part.type === "file" && part.source?.text) {
+      part.source.text.start = startOffset
+      part.source.text.end = endOffset
+      part.source.text.value = text
+    }
+
+    if (part.type === "file") {
+      const prev = parts.findIndex((item) => item.type === "file" && item.url === part.url)
+      if (prev !== -1) {
+        const mark = [...marks.entries()].find((item) => item[1] === prev)?.[0]
+        if (mark !== undefined) {
+          area.extmarks.delete(mark)
+        }
+        parts = parts.filter((_, idx) => idx !== prev)
+        marks = new Map(
+          [...marks.entries()]
+            .filter((item) => item[0] !== mark)
+            .map((item) => [item[0], item[1] > prev ? item[1] - 1 : item[1]]),
+        )
+      }
+    }
+
+    const id = area.extmarks.create({
+      start: startOffset,
+      end: endOffset,
+      virtual: true,
+      typeId: type,
+    })
+    marks.set(id, parts.length)
+    parts.push(part)
+    hide()
+    syncDraft()
+    scheduleRows()
+    area.focus()
+  }
+
+  const expand = () => {
+    const next = options()[selected()]
+    if (!next?.directory || !area || area.isDestroyed) {
+      return
+    }
+
+    const cursor = area.cursorOffset
+    area.cursorOffset = at()
+    const start = area.logicalCursor
+    area.cursorOffset = cursor
+    const end = area.logicalCursor
+    area.deleteRange(start.row, start.col, end.row, end.col)
+    area.insertText("@" + next.value)
+    syncDraft()
+    refresh()
+  }
+
+  const onKeyDown = (event: KeyEvent) => {
+    if (visible()) {
+      const name = event.name.toLowerCase()
+      const ctrl = event.ctrl && !event.meta && !event.shift
+      if (name === "up" || (ctrl && name === "p")) {
+        event.preventDefault()
+        if (options().length > 0) {
+          setSelected((selected() - 1 + options().length) % options().length)
+        }
+        return
+      }
+
+      if (name === "down" || (ctrl && name === "n")) {
+        event.preventDefault()
+        if (options().length > 0) {
+          setSelected((selected() + 1) % options().length)
+        }
+        return
+      }
+
+      if (name === "escape") {
+        event.preventDefault()
+        hide()
+        return
+      }
+
+      if (name === "return") {
+        event.preventDefault()
+        select()
+        return
+      }
+
+      if (name === "tab") {
+        event.preventDefault()
+        if (options()[selected()]?.directory) {
+          expand()
+          return
+        }
+
+        select()
+        return
+      }
+    }
+
     if (event.ctrl && event.name === "c") {
       const handled = input.onExitRequest ? input.onExitRequest() : (input.onExit(), true)
       if (handled) {
@@ -364,36 +869,36 @@ export function createPromptState(input: PromptInput): PromptState {
       return
     }
 
-    const text = area.plainText.trim()
-    if (!text) {
+    if (visible()) {
+      select()
+      return
+    }
+
+    syncDraft()
+    const next = clonePrompt(draft)
+    if (!next.text.trim()) {
       input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
       return
     }
 
-    if (isExitCommand(text)) {
+    if (isExitCommand(next.text)) {
       input.onExit()
       return
     }
 
     area.setText("")
-    setDraft("")
+    clearParts()
+    hide()
+    draft = { text: "", parts: [] }
     scheduleRows()
     area.focus()
-    queueMicrotask(() => {
-      if (input.onSubmit(text)) {
-        push(text)
-        return
-      }
-
-      if (!area || area.isDestroyed) {
+    queueMicrotask(async () => {
+      if (await input.onSubmit(next)) {
+        push(next)
         return
       }
 
-      area.setText(text)
-      setDraft(text)
-      area.cursorOffset = area.plainText.length
-      syncRows()
-      area.focus()
+      restore(next)
     })
   }
 
@@ -406,11 +911,17 @@ export function createPromptState(input: PromptInput): PromptState {
 
   createEffect(() => {
     input.width()
+    popup()
     if (input.prompt()) {
       scheduleRows()
     }
   })
 
+  createEffect(() => {
+    query()
+    setSelected(0)
+  })
+
   createEffect(() => {
     input.state().phase
     if (!input.prompt() || !area || area.isDestroyed || input.state().phase !== "idle") {
@@ -427,8 +938,8 @@ export function createPromptState(input: PromptInput): PromptState {
   })
 
   createEffect(() => {
-    const type = input.view()
-    if (type === prev) {
+    const kind = input.view()
+    if (kind === prev) {
       return
     }
 
@@ -437,33 +948,28 @@ export function createPromptState(input: PromptInput): PromptState {
     }
 
     clear()
-    prev = type
-    if (type !== "prompt") {
+    hide()
+    prev = kind
+    if (kind !== "prompt") {
       return
     }
 
     queueMicrotask(() => {
-      if (!area || area.isDestroyed) {
-        return
-      }
-
-      if (area.plainText !== draft()) {
-        area.setText(draft())
-      }
-
-      area.cursorOffset = area.plainText.length
-      scheduleRows()
-      area.focus()
+      restore(draft)
     })
   })
 
   return {
     placeholder,
     bindings,
+    visible,
+    options,
+    selected,
     onSubmit,
     onKeyDown,
     onContentChange: () => {
       syncDraft()
+      refresh()
       scheduleRows()
     },
     bind,

+ 21 - 11
packages/opencode/src/cli/cmd/run/footer.ts

@@ -26,7 +26,7 @@
 import { CliRenderEvents, type CliRenderer } from "@opentui/core"
 import { render } from "@opentui/solid"
 import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
-import { TEXTAREA_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
+import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
 import { printableBinding } from "./prompt.shared"
 import { RunFooterView } from "./footer.view"
 import { normalizeEntry } from "./scrollback.format"
@@ -35,10 +35,13 @@ import { spacerWriter } from "./scrollback.writer"
 import { toolView } from "./tool"
 import type { RunTheme } from "./theme"
 import type {
+  RunAgent,
   FooterApi,
   FooterEvent,
   FooterKeybinds,
   FooterPatch,
+  RunPrompt,
+  RunResource,
   FooterState,
   FooterView,
   PermissionReply,
@@ -54,10 +57,14 @@ type CycleResult = {
 }
 
 type RunFooterOptions = {
+  directory: string
+  findFiles: (query: string) => Promise<string[]>
+  agents: RunAgent[]
+  resources: RunResource[]
   agentLabel: string
   modelLabel: string
   first: boolean
-  history?: string[]
+  history?: RunPrompt[]
   theme: RunTheme
   keybinds: FooterKeybinds
   diffStyle: RunDiffStyle
@@ -72,11 +79,10 @@ type RunFooterOptions = {
 const PERMISSION_ROWS = 12
 const QUESTION_ROWS = 14
 
-
 export class RunFooter implements FooterApi {
   private closed = false
   private destroyed = false
-  private prompts = new Set<(text: string) => void>()
+  private prompts = new Set<(input: RunPrompt) => void>()
   private closes = new Set<() => void>()
   // Most recent visible scrollback commit.
   private tail: StreamCommit | undefined
@@ -124,8 +130,12 @@ export class RunFooter implements FooterApi {
     void render(
       () =>
         createComponent(RunFooterView, {
+          directory: options.directory,
           state: this.state,
           view: this.view,
+          findFiles: options.findFiles,
+          agents: () => options.agents,
+          resources: () => options.resources,
           theme: options.theme.footer,
           block: options.theme.block,
           diffStyle: options.diffStyle,
@@ -155,7 +165,7 @@ export class RunFooter implements FooterApi {
     return this.closed || this.destroyed || this.renderer.isDestroyed
   }
 
-  public onPrompt(fn: (text: string) => void): () => void {
+  public onPrompt(fn: (input: RunPrompt) => void): () => void {
     this.prompts.add(fn)
     return () => {
       this.prompts.delete(fn)
@@ -165,7 +175,7 @@ export class RunFooter implements FooterApi {
   public onClose(fn: () => void): () => void {
     if (this.isClosed) {
       fn()
-      return () => { }
+      return () => {}
     }
 
     this.closes.add(fn)
@@ -320,7 +330,7 @@ export class RunFooter implements FooterApi {
       return Promise.resolve()
     }
 
-    return this.renderer.idle().catch(() => { })
+    return this.renderer.idle().catch(() => {})
   }
 
   public close(): void {
@@ -377,7 +387,7 @@ export class RunFooter implements FooterApi {
         ? this.base + PERMISSION_ROWS
         : type === "question"
           ? this.base + QUESTION_ROWS
-          : Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + TEXTAREA_MAX_ROWS, this.base + this.rows))
+          : Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + PROMPT_MAX_ROWS, this.base + this.rows))
 
     if (height !== this.renderer.footerHeight) {
       this.renderer.footerHeight = height
@@ -389,7 +399,7 @@ export class RunFooter implements FooterApi {
       return
     }
 
-    const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, value))
+    const rows = Math.max(TEXTAREA_MIN_ROWS, Math.min(PROMPT_MAX_ROWS, value))
     if (rows === this.rows) {
       return
     }
@@ -400,7 +410,7 @@ export class RunFooter implements FooterApi {
     }
   }
 
-  private handlePrompt = (text: string): boolean => {
+  private handlePrompt = (input: RunPrompt): boolean => {
     if (this.isClosed) {
       return false
     }
@@ -415,7 +425,7 @@ export class RunFooter implements FooterApi {
     }
 
     for (const fn of [...this.prompts]) {
-      fn(text)
+      fn(input)
     }
 
     return true

+ 100 - 71
packages/opencode/src/cli/cmd/run/footer.view.tsx

@@ -14,12 +14,15 @@ import { useTerminalDimensions } from "@opentui/solid"
 import { Match, Show, Switch, createMemo } from "solid-js"
 import "opentui-spinner/solid"
 import { createColors, createFrames } from "../tui/ui/spinner"
-import { RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
+import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
 import { RunPermissionBody } from "./footer.permission"
 import { RunQuestionBody } from "./footer.question"
 import { printableBinding } from "./prompt.shared"
 import type {
   FooterKeybinds,
+  RunAgent,
+  RunPrompt,
+  RunResource,
   FooterState,
   FooterView,
   PermissionReply,
@@ -44,15 +47,19 @@ const EMPTY_BORDER = {
 }
 
 type RunFooterViewProps = {
+  directory: string
+  findFiles: (query: string) => Promise<string[]>
+  agents: () => RunAgent[]
+  resources: () => RunResource[]
   state: () => FooterState
   view?: () => FooterView
   theme?: RunFooterTheme
   block?: RunBlockTheme
   diffStyle?: RunDiffStyle
   keybinds: FooterKeybinds
-  history?: string[]
+  history?: RunPrompt[]
   agent: string
-  onSubmit: (text: string) => boolean
+  onSubmit: (input: RunPrompt) => boolean
   onPermissionReply: (input: PermissionReply) => void | Promise<void>
   onQuestionReply: (input: QuestionReply) => void | Promise<void>
   onQuestionReject: (input: QuestionReject) => void | Promise<void>
@@ -107,6 +114,10 @@ export function RunFooterView(props: RunFooterViewProps) {
     return view.type === "question" ? view : undefined
   })
   const composer = createPromptState({
+    directory: props.directory,
+    findFiles: props.findFiles,
+    agents: props.agents,
+    resources: props.resources,
     keybinds: props.keybinds,
     state: props.state,
     view: () => active().type,
@@ -122,6 +133,7 @@ export function RunFooterView(props: RunFooterViewProps) {
     onRows: props.onRows,
     onStatus: props.onStatus,
   })
+  const menu = createMemo(() => active().type === "prompt" && composer.visible())
 
   return (
     <box
@@ -192,7 +204,15 @@ export function RunFooterView(props: RunFooterViewProps) {
             </Switch>
           </box>
 
-          <box id="run-direct-footer-meta-row" width="100%" flexDirection="row" gap={1} paddingLeft={2} flexShrink={0} paddingTop={1}>
+          <box
+            id="run-direct-footer-meta-row"
+            width="100%"
+            flexDirection="row"
+            gap={1}
+            paddingLeft={2}
+            flexShrink={0}
+            paddingTop={1}
+          >
             <text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
               {props.agent}
             </text>
@@ -209,6 +229,7 @@ export function RunFooterView(props: RunFooterViewProps) {
         height={1}
         border={["left"]}
         borderColor={theme().highlight}
+        backgroundColor="transparent"
         customBorderChars={{
           ...EMPTY_BORDER,
           vertical: "╹",
@@ -220,7 +241,8 @@ export function RunFooterView(props: RunFooterViewProps) {
           width="100%"
           height={1}
           border={["bottom"]}
-          borderColor={theme().line}
+          borderColor={theme().surface}
+          backgroundColor={menu() ? theme().shade : "transparent"}
           customBorderChars={{
             ...EMPTY_BORDER,
             horizontal: "▀",
@@ -228,79 +250,86 @@ export function RunFooterView(props: RunFooterViewProps) {
         />
       </box>
 
-      <box
-        id="run-direct-footer-row"
-        width="100%"
-        height={1}
-        flexDirection="row"
-        justifyContent="space-between"
-        gap={1}
-        flexShrink={0}
-      >
-        <Show when={busy() || exiting()}>
-          <box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
-            <Show when={exiting()}>
-              <text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
-                Press Ctrl-c again to exit
-              </text>
-            </Show>
+      <Show
+        when={menu()}
+        fallback={
+          <box
+            id="run-direct-footer-row"
+            width="100%"
+            height={1}
+            flexDirection="row"
+            justifyContent="space-between"
+            gap={1}
+            flexShrink={0}
+          >
+            <Show when={busy() || exiting()}>
+              <box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
+                <Show when={exiting()}>
+                  <text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
+                    Press Ctrl-c again to exit
+                  </text>
+                </Show>
+
+                <Show when={busy() && !exiting()}>
+                  <box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
+                    <spinner color={spin().color} frames={spin().frames} interval={40} />
+                  </box>
 
-            <Show when={busy() && !exiting()}>
-              <box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
-                <spinner color={spin().color} frames={spin().frames} interval={40} />
+                  <text
+                    id="run-direct-footer-hint-interrupt"
+                    fg={armed() ? theme().highlight : theme().text}
+                    wrapMode="none"
+                    truncate
+                  >
+                    {interruptKey()}{" "}
+                    <span style={{ fg: armed() ? theme().highlight : theme().muted }}>
+                      {armed() ? "again to interrupt" : "interrupt"}
+                    </span>
+                  </text>
+                </Show>
               </box>
+            </Show>
 
-              <text
-                id="run-direct-footer-hint-interrupt"
-                fg={armed() ? theme().highlight : theme().text}
-                wrapMode="none"
-                truncate
-              >
-                {interruptKey()}{" "}
-                <span style={{ fg: armed() ? theme().highlight : theme().muted }}>
-                  {armed() ? "again to interrupt" : "interrupt"}
-                </span>
-              </text>
+            <Show when={!busy() && !exiting() && duration().length > 0}>
+              <box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
+                <text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
+                  ▣
+                </text>
+                <box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
+                  <text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
+                    ·
+                  </text>
+                  <text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
+                    {duration()}
+                  </text>
+                </box>
+              </box>
             </Show>
-          </box>
-        </Show>
 
-        <Show when={!busy() && !exiting() && duration().length > 0}>
-          <box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
-            <text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
-              ▣
-            </text>
-            <box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
-              <text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
-                ·
-              </text>
-              <text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
-                {duration()}
-              </text>
+            <box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
+
+            <box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
+              <Show when={queue() > 0}>
+                <text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
+                  {queue()} queued
+                </text>
+              </Show>
+              <Show when={usage().length > 0}>
+                <text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
+                  {usage()}
+                </text>
+              </Show>
+              <Show when={variant().length > 0 && hints().variant}>
+                <text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
+                  {variant()} variant
+                </text>
+              </Show>
             </box>
           </box>
-        </Show>
-
-        <box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
-
-        <box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
-          <Show when={queue() > 0}>
-            <text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
-              {queue()} queued
-            </text>
-          </Show>
-          <Show when={usage().length > 0}>
-            <text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
-              {usage()}
-            </text>
-          </Show>
-          <Show when={variant().length > 0 && hints().variant}>
-            <text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
-              {variant()} variant
-            </text>
-          </Show>
-        </box>
-      </box>
+        }
+      >
+        <RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
+      </Show>
     </box>
   )
 }

+ 33 - 15
packages/opencode/src/cli/cmd/run/prompt.shared.ts

@@ -13,12 +13,12 @@
 // arms the leader, second press within the timeout fires the action.
 import type { KeyBinding } from "@opentui/core"
 import { Keybind } from "../../../util/keybind"
-import type { FooterKeybinds } from "./types"
+import type { FooterKeybinds, RunPrompt } from "./types"
 
 const HISTORY_LIMIT = 200
 
 export type PromptHistoryState = {
-  items: string[]
+  items: RunPrompt[]
   index: number | null
   draft: string
 }
@@ -46,6 +46,17 @@ export type PromptMove = {
   apply: boolean
 }
 
+function copy(prompt: RunPrompt): RunPrompt {
+  return {
+    text: prompt.text,
+    parts: structuredClone(prompt.parts),
+  }
+}
+
+function same(a: RunPrompt, b: RunPrompt): boolean {
+  return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
+}
+
 function mapInputBindings(binding: string, action: "submit" | "newline"): KeyBinding[] {
   return Keybind.parse(binding).map((item) => ({
     name: item.name,
@@ -159,24 +170,31 @@ export function promptCycle(
   }
 }
 
-export function createPromptHistory(items?: string[]): PromptHistoryState {
+export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState {
+  const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(copy)
+  const next: RunPrompt[] = []
+  for (const item of list) {
+    if (next.length > 0 && same(next[next.length - 1], item)) {
+      continue
+    }
+
+    next.push(item)
+  }
+
   return {
-    items: (items ?? [])
-      .map((item) => item.trim())
-      .filter((item) => item.length > 0)
-      .filter((item, idx, all) => idx === 0 || item !== all[idx - 1])
-      .slice(-HISTORY_LIMIT),
+    items: next.slice(-HISTORY_LIMIT),
     index: null,
     draft: "",
   }
 }
 
-export function pushPromptHistory(state: PromptHistoryState, text: string): PromptHistoryState {
-  if (!text) {
+export function pushPromptHistory(state: PromptHistoryState, prompt: RunPrompt): PromptHistoryState {
+  if (!prompt.text.trim()) {
     return state
   }
 
-  if (state.items[state.items.length - 1] === text) {
+  const next = copy(prompt)
+  if (state.items[state.items.length - 1] && same(state.items[state.items.length - 1], next)) {
     return {
       ...state,
       index: null,
@@ -184,7 +202,7 @@ export function pushPromptHistory(state: PromptHistoryState, text: string): Prom
     }
   }
 
-  const items = [...state.items, text].slice(-HISTORY_LIMIT)
+  const items = [...state.items, next].slice(-HISTORY_LIMIT)
   return {
     ...state,
     items,
@@ -218,7 +236,7 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
         index: idx,
         draft: text,
       },
-      text: state.items[idx],
+      text: state.items[idx].text,
       cursor: 0,
       apply: true,
     }
@@ -246,8 +264,8 @@ export function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text:
       ...state,
       index: idx,
     },
-    text: state.items[idx],
-    cursor: dir === -1 ? 0 : state.items[idx].length,
+    text: state.items[idx].text,
+    cursor: dir === -1 ? 0 : state.items[idx].text.length,
     apply: true,
   }
 }

+ 2 - 2
packages/opencode/src/cli/cmd/run/runtime.boot.ts

@@ -7,7 +7,7 @@
 // none block each other.
 import { TuiConfig } from "../../../config/tui"
 import { resolveSession, sessionHistory } from "./session.shared"
-import type { FooterKeybinds, RunDiffStyle, RunInput } from "./types"
+import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt } from "./types"
 import { pickVariant } from "./variant.shared"
 
 const DEFAULT_KEYBINDS: FooterKeybinds = {
@@ -27,7 +27,7 @@ export type ModelInfo = {
 
 export type SessionInfo = {
   first: boolean
-  history: string[]
+  history: RunPrompt[]
   variant: string | undefined
 }
 

+ 17 - 6
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -18,8 +18,11 @@ import type {
   PermissionReply,
   QuestionReject,
   QuestionReply,
+  RunAgent,
   RunDiffStyle,
   RunInput,
+  RunPrompt,
+  RunResource,
 } from "./types"
 import { formatModelLabel } from "./variant.shared"
 
@@ -42,10 +45,14 @@ type FooterLabels = {
 }
 
 export type LifecycleInput = {
+  directory: string
+  findFiles: (query: string) => Promise<string[]>
+  agents: RunAgent[]
+  resources: RunResource[]
   sessionID: string
   sessionTitle?: string
   first: boolean
-  history: string[]
+  history: RunPrompt[]
   agent: string | undefined
   model: RunInput["model"]
   variant: string | undefined
@@ -84,21 +91,21 @@ function shutdown(renderer: CliRenderer): void {
   }
 }
 
-function splashTitle(title: string | undefined, history: string[]): string | undefined {
+function splashTitle(title: string | undefined, history: RunPrompt[]): string | undefined {
   if (title && !DEFAULT_TITLE.test(title)) {
     return title
   }
 
-  const next = history.find((item) => item.trim().length > 0)
-  return next ?? title
+  const next = history.find((item) => item.text.trim().length > 0)
+  return next?.text ?? title
 }
 
-function splashSession(title: string | undefined, history: string[]): boolean {
+function splashSession(title: string | undefined, history: RunPrompt[]): boolean {
   if (title && !DEFAULT_TITLE.test(title)) {
     return true
   }
 
-  return !!history.find((item) => item.trim().length > 0)
+  return !!history.find((item) => item.text.trim().length > 0)
 }
 
 function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
@@ -189,6 +196,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
     variant: input.variant,
   })
   const footer = new RunFooter(renderer, {
+    directory: input.directory,
+    findFiles: input.findFiles,
+    agents: input.agents,
+    resources: input.resources,
     ...labels,
     first: input.first,
     history: input.history,

+ 10 - 11
packages/opencode/src/cli/cmd/run/runtime.queue.ts

@@ -11,7 +11,7 @@
 // Resolves when the footer closes and all in-flight work finishes.
 import { Locale } from "../../../util/locale"
 import { isExitCommand } from "./prompt.shared"
-import type { FooterApi, FooterEvent } from "./types"
+import type { FooterApi, FooterEvent, RunPrompt } from "./types"
 
 type Trace = {
   write(type: string, data?: unknown): void
@@ -22,7 +22,7 @@ export type QueueInput = {
   initialInput?: string
   trace?: Trace
   onPrompt?: () => void
-  run: (prompt: string, signal: AbortSignal) => Promise<void>
+  run: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
 }
 
 // Runs the prompt queue until the footer closes.
@@ -32,7 +32,7 @@ export type QueueInput = {
 // a turn is running, they queue up and execute in order. The footer shows
 // the queue depth so the user knows how many are pending.
 export async function runPromptQueue(input: QueueInput): Promise<void> {
-  const q: string[] = []
+  const q: RunPrompt[] = []
   let busy = false
   let closed = input.footer.isClosed
   let ctrl: AbortController | undefined
@@ -102,7 +102,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
             (error) => ({ type: "error" as const, error }),
           )
           await input.footer.idle()
-          const commit = { kind: "user", text: prompt, phase: "start", source: "system" } as const
+          const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
           input.trace?.write("ui.commit", commit)
           input.footer.append(commit)
           const out = await Promise.race([task, until.then(() => ({ type: "closed" as const }))])
@@ -147,13 +147,12 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
     }
   }
 
-  const push = (text: string) => {
-    const prompt = text
-    if (!prompt.trim() || closed) {
+  const push = (prompt: RunPrompt) => {
+    if (!prompt.text.trim() || closed) {
       return
     }
 
-    if (isExitCommand(prompt)) {
+    if (isExitCommand(prompt.text)) {
       input.footer.close()
       return
     }
@@ -181,8 +180,8 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
     void pump().catch(fail)
   }
 
-  const offPrompt = input.footer.onPrompt((text) => {
-    push(text)
+  const offPrompt = input.footer.onPrompt((prompt) => {
+    push(prompt)
   })
   const offClose = input.footer.onClose(() => {
     closed = true
@@ -197,7 +196,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
       return
     }
 
-    push(input.initialInput ?? "")
+    push({ text: input.initialInput ?? "", parts: [] })
     await pump()
 
     if (!closed) {

+ 24 - 2
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -25,7 +25,7 @@ export { pickVariant, resolveVariant } from "./variant.shared"
 /** @internal Exported for testing */
 export { runPromptQueue } from "./runtime.queue"
 
-type BootContext = Pick<RunInput, "sdk" | "sessionID" | "sessionTitle" | "agent" | "model" | "variant">
+type BootContext = Pick<RunInput, "sdk" | "directory" | "sessionID" | "sessionTitle" | "agent" | "model" | "variant">
 
 type RunRuntimeInput = {
   boot: () => Promise<BootContext>
@@ -38,6 +38,7 @@ type RunRuntimeInput = {
 }
 
 type RunLocalInput = {
+  directory: string
   fetch: typeof globalThis.fetch
   resolveAgent: () => Promise<string | undefined>
   session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined>
@@ -66,21 +67,39 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
   const modelTask = resolveModelInfo(ctx.sdk, ctx.model)
   const sessionTask = resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
   const savedTask = resolveSavedVariant(ctx.model)
+  const agentsTask = ctx.sdk.app
+    .agents({ directory: ctx.directory })
+    .then((x) => x.data ?? [])
+    .catch(() => [])
+  const resourcesTask = ctx.sdk.experimental.resource
+    .list({ directory: ctx.directory })
+    .then((x) => Object.values(x.data ?? {}))
+    .catch(() => [])
   let variants: string[] = []
   let limits: Record<string, number> = {}
   let aborting = false
   let shown = false
   let demo: ReturnType<typeof createRunDemo> | undefined
-  const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
+  const [keybinds, diffStyle, session, savedVariant, agents, resources] = await Promise.all([
     keybindTask,
     diffTask,
     sessionTask,
     savedTask,
+    agentsTask,
+    resourcesTask,
   ])
   shown = !session.first
   let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
 
   const shell = await createRuntimeLifecycle({
+    directory: ctx.directory,
+    findFiles: (query) =>
+      ctx.sdk.find
+        .files({ query, directory: ctx.directory })
+        .then((x) => x.data ?? [])
+        .catch(() => []),
+    agents,
+    resources,
     sessionID: ctx.sessionID,
     sessionTitle: ctx.sessionTitle,
     first: session.first,
@@ -254,6 +273,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
   const sdk = createOpencodeClient({
     baseUrl: "http://opencode.internal",
     fetch: input.fetch,
+    directory: input.directory,
   })
 
   return runInteractiveRuntime({
@@ -272,6 +292,7 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
 
       return {
         sdk,
+        directory: input.directory,
         sessionID: session.id,
         sessionTitle: session.title,
         agent,
@@ -292,6 +313,7 @@ export async function runInteractiveMode(input: RunInput): Promise<void> {
     demoText: input.demoText,
     boot: async () => ({
       sdk: input.sdk,
+      directory: input.directory,
       sessionID: input.sessionID,
       sessionTitle: input.sessionTitle,
       agent: input.agent,

+ 113 - 15
packages/opencode/src/cli/cmd/run/session.shared.ts

@@ -3,14 +3,16 @@
 // Fetches session messages from the SDK and extracts user turn text for
 // the prompt history ring. Also finds the most recently used variant for
 // the current model so the footer can pre-select it.
-import type { RunInput } from "./types"
+import path from "path"
+import { fileURLToPath } from "url"
+import type { RunInput, RunPrompt } from "./types"
 
 const LIMIT = 200
 
 export type SessionMessages = NonNullable<Awaited<ReturnType<RunInput["sdk"]["session"]["messages"]>>["data"]>
 
 type Turn = {
-  text: string
+  prompt: RunPrompt
   provider: string | undefined
   model: string | undefined
   variant: string | undefined
@@ -21,12 +23,108 @@ export type RunSession = {
   turns: Turn[]
 }
 
-function text(msg: SessionMessages[number]): string {
-  return msg.parts
-    .filter((part) => part.type === "text")
-    .map((part) => part.text.trim())
-    .filter((part) => part.length > 0)
-    .join("\n")
+function copy(prompt: RunPrompt): RunPrompt {
+  return {
+    text: prompt.text,
+    parts: structuredClone(prompt.parts),
+  }
+}
+
+function same(a: RunPrompt, b: RunPrompt): boolean {
+  return a.text === b.text && JSON.stringify(a.parts) === JSON.stringify(b.parts)
+}
+
+function fileName(url: string, filename?: string) {
+  if (filename) {
+    return filename
+  }
+
+  try {
+    const next = new URL(url)
+    if (next.protocol === "file:") {
+      return path.basename(fileURLToPath(next)) || url
+    }
+  } catch {}
+
+  return url
+}
+
+function fileSource(
+  part: Extract<SessionMessages[number]["parts"][number], { type: "file" }>,
+  text: { start: number; end: number; value: string },
+) {
+  if (part.source) {
+    return {
+      ...structuredClone(part.source),
+      text,
+    }
+  }
+
+  return {
+    type: "file" as const,
+    path: part.filename ?? part.url,
+    text,
+  }
+}
+
+function prompt(msg: SessionMessages[number]): RunPrompt {
+  const files: Array<Extract<SessionMessages[number]["parts"][number], { type: "file" }>> = []
+  const parts: RunPrompt["parts"] = []
+  for (const part of msg.parts) {
+    if (part.type === "file") {
+      if (!part.source?.text) {
+        files.push(part)
+        continue
+      }
+
+      parts.push({
+        type: "file",
+        mime: part.mime,
+        filename: part.filename,
+        url: part.url,
+        source: structuredClone(part.source),
+      })
+      continue
+    }
+
+    if (part.type === "agent" && part.source) {
+      parts.push({
+        type: "agent",
+        name: part.name,
+        source: structuredClone(part.source),
+      })
+    }
+  }
+
+  let text = msg.parts
+    .filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
+      return part.type === "text" && !part.synthetic
+    })
+    .map((part) => part.text)
+    .join("")
+  let cursor = Bun.stringWidth(text)
+
+  for (const part of files) {
+    const value = "@" + fileName(part.url, part.filename)
+    const gap = text ? " " : ""
+    const start = cursor + Bun.stringWidth(gap)
+    text += gap + value
+    const end = start + Bun.stringWidth(value)
+    cursor = end
+    parts.push({
+      type: "file",
+      mime: part.mime,
+      filename: part.filename,
+      url: part.url,
+      source: fileSource(part, {
+        start,
+        end,
+        value,
+      }),
+    })
+  }
+
+  return { text, parts }
 }
 
 function turn(msg: SessionMessages[number]): Turn | undefined {
@@ -35,10 +133,10 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
   }
 
   return {
-    text: text(msg),
+    prompt: prompt(msg),
     provider: msg.info.model.providerID,
     model: msg.info.model.modelID,
-    variant: msg.info.variant,
+    variant: msg.info.model.variant,
   }
 }
 
@@ -60,19 +158,19 @@ export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, li
   return createSession(response.data ?? [])
 }
 
-export function sessionHistory(session: RunSession, limit = LIMIT): string[] {
-  const out: string[] = []
+export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] {
+  const out: RunPrompt[] = []
 
   for (const turn of session.turns) {
-    if (!turn.text) {
+    if (!turn.prompt.text.trim()) {
       continue
     }
 
-    if (out[out.length - 1] === turn.text) {
+    if (out[out.length - 1] && same(out[out.length - 1], turn.prompt)) {
       continue
     }
 
-    out.push(turn.text)
+    out.push(copy(turn.prompt))
   }
 
   return out.slice(-limit)

+ 7 - 3
packages/opencode/src/cli/cmd/run/stream.transport.ts

@@ -15,7 +15,7 @@
 import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
 import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data"
 import { writeSessionOutput } from "./stream"
-import type { FooterApi, RunFilePart, RunInput, StreamCommit } from "./types"
+import type { FooterApi, RunFilePart, RunInput, RunPrompt, StreamCommit } from "./types"
 
 type Trace = {
   write(type: string, data?: unknown): void
@@ -43,7 +43,7 @@ export type SessionTurnInput = {
   agent: string | undefined
   model: RunInput["model"]
   variant: string | undefined
-  prompt: string
+  prompt: RunPrompt
   files: RunFilePart[]
   includeFiles: boolean
   signal?: AbortSignal
@@ -281,7 +281,11 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         agent: next.agent,
         model: next.model,
         variant: next.variant,
-        parts: [...(next.includeFiles ? next.files : []), { type: "text" as const, text: next.prompt }],
+        parts: [
+          ...(next.includeFiles ? next.files : []),
+          { type: "text" as const, text: next.prompt.text },
+          ...next.prompt.parts,
+        ],
       }
       input.trace?.write("send.prompt", req)
       await input.sdk.session.prompt(req, {

+ 7 - 0
packages/opencode/src/cli/cmd/run/theme.ts

@@ -27,8 +27,10 @@ export type RunFooterTheme = {
   error: ColorInput
   muted: ColorInput
   text: ColorInput
+  shade: ColorInput
   surface: ColorInput
   pane: ColorInput
+  border: ColorInput
   line: ColorInput
 }
 
@@ -99,6 +101,7 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n
 function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
   const bg = theme.background
   const pane = theme.backgroundElement
+  const shade = fade(pane, bg, 0.12, 0.56, 0.72)
   const surface = fade(pane, bg, 0.18, 0.76, 0.9)
   const line = fade(pane, bg, 0.24, 0.9, 0.98)
 
@@ -111,8 +114,10 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
       error: theme.error,
       muted: theme.textMuted,
       text: theme.text,
+      shade,
       surface,
       pane,
+      border: theme.border,
       line,
     },
     entry: {
@@ -180,8 +185,10 @@ export const RUN_THEME_FALLBACK: RunTheme = {
     error: seed.error,
     muted: seed.muted,
     text: seed.text,
+    shade: alpha(seed.panel, 0.68),
     surface: alpha(seed.panel, 0.86),
     pane: seed.panel,
+    border: seed.muted,
     line: alpha(seed.panel, 0.96),
   },
   entry: {

+ 5 - 5
packages/opencode/src/cli/cmd/run/tool.ts

@@ -127,13 +127,13 @@ export type ToolSnapshot =
   | ToolTodoSnapshot
   | ToolQuestionSnapshot
 
-export type ToolProps<T extends Tool.Info> = {
+export type ToolProps<T = Tool.Info> = {
   input: Partial<Tool.InferParameters<T>>
   metadata: Partial<Tool.InferMetadata<T>>
   frame: ToolFrame
 }
 
-type ToolPermissionProps<T extends Tool.Info> = {
+type ToolPermissionProps<T = Tool.Info> = {
   input: Partial<Tool.InferParameters<T>>
   metadata: Partial<Tool.InferMetadata<T>>
   patterns: string[]
@@ -169,7 +169,7 @@ type ToolDefs = {
 
 type ToolName = keyof ToolDefs
 
-type ToolRule<T extends Tool.Info> = {
+type ToolRule<T = Tool.Info> = {
   view: ToolView
   run: (props: ToolProps<T>) => ToolInline
   scroll?: Partial<Record<ToolPhase, (props: ToolProps<T>) => string>>
@@ -191,7 +191,7 @@ function dict(v: unknown): ToolDict {
   return v as ToolDict
 }
 
-function props<T extends Tool.Info = Tool.Info>(frame: ToolFrame): ToolProps<T> {
+function props<T = Tool.Info>(frame: ToolFrame): ToolProps<T> {
   return {
     input: frame.input as Partial<Tool.InferParameters<T>>,
     metadata: frame.meta as Partial<Tool.InferMetadata<T>>,
@@ -199,7 +199,7 @@ function props<T extends Tool.Info = Tool.Info>(frame: ToolFrame): ToolProps<T>
   }
 }
 
-function permission<T extends Tool.Info = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
+function permission<T = Tool.Info>(ctx: ToolPermissionCtx): ToolPermissionProps<T> {
   return {
     input: ctx.input as Partial<Tool.InferParameters<T>>,
     metadata: ctx.meta as Partial<Tool.InferMetadata<T>>,

+ 16 - 1
packages/opencode/src/cli/cmd/run/types.ts

@@ -21,9 +21,24 @@ export type RunFilePart = {
 }
 
 type PromptModel = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
+type PromptInput = Parameters<OpencodeClient["session"]["prompt"]>[0]
+
+export type RunPromptPart = NonNullable<PromptInput["parts"]>[number]
+
+export type RunPrompt = {
+  text: string
+  parts: RunPromptPart[]
+}
+
+export type RunAgent = NonNullable<Awaited<ReturnType<OpencodeClient["app"]["agents"]>>["data"]>[number]
+
+type RunResourceMap = NonNullable<Awaited<ReturnType<OpencodeClient["experimental"]["resource"]["list"]>>["data"]>
+
+export type RunResource = RunResourceMap[string]
 
 export type RunInput = {
   sdk: OpencodeClient
+  directory: string
   sessionID: string
   sessionTitle?: string
   resume?: boolean
@@ -170,7 +185,7 @@ export type StreamCommit = {
 // touch the renderer directly -- they go through this interface.
 export type FooterApi = {
   readonly isClosed: boolean
-  onPrompt(fn: (text: string) => void): () => void
+  onPrompt(fn: (input: RunPrompt) => void): () => void
   onClose(fn: () => void): () => void
   event(next: FooterEvent): void
   append(commit: StreamCommit): void