Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov
import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider } from "@tui/context/project"
import { EditorContextProvider } from "@tui/context/editor"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading"
Expand Down Expand Up @@ -177,7 +178,9 @@ export function tui(input: {
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App onSnapshot={input.onSnapshot} />
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
Expand Down
104 changes: 77 additions & 27 deletions packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort"
import path from "path"
import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { useEditorContext } from "@tui/context/editor"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
Expand Down Expand Up @@ -77,6 +79,7 @@ export function Autocomplete(props: {
agentStyleId: number
promptPartTypeId: () => number
}) {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
Expand Down Expand Up @@ -221,6 +224,70 @@ export function Autocomplete(props: {
}
}

function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
const urlObj = pathToFileURL(fullPath)
const filename =
lineRange && !item.endsWith("/")
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
: item

if (lineRange && !item.endsWith("/")) {
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
}

return {
filename,
url: urlObj.href,
part: {
type: "file" as const,
mime: "text/plain",
filename,
url: urlObj.href,
source: {
type: "file" as const,
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
},
}
}

function normalizeMentionPath(filePath: string) {
const baseDir = sync.path.directory || process.cwd()
const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute)

if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
return relative.split(path.sep).join("/")
}

return absolute.split(path.sep).join("/")
}

function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
const item = normalizeMentionPath(input.filePath)
const lineRange = {
startLine: input.lineStart,
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
}
const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset

command.keybinds(true)
setStore("visible", false)
setStore("index", index)
insertPart(filename, part)
}

const [files] = createResource(
() => search(),
async (query) => {
Expand Down Expand Up @@ -250,18 +317,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4
options.push(
...sortedFiles.map((item): AutocompleteOption => {
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath)
let filename = item
if (lineRange && !item.endsWith("/")) {
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
}
const url = urlObj.href
const { filename, url, part } = createFilePart(item, lineRange)

const isDir = item.endsWith("/")
return {
Expand All @@ -270,21 +326,7 @@ export function Autocomplete(props: {
isDirectory: isDir,
path: item,
onSelect: () => {
insertPart(filename, {
type: "file",
mime: "text/plain",
filename,
url,
source: {
type: "file",
text: {
start: 0,
end: 0,
value: "",
},
path: item,
},
})
insertPart(filename, part)
},
}
}),
Expand Down Expand Up @@ -501,6 +543,14 @@ export function Autocomplete(props: {
}

onMount(() => {
const unsubscribeMention = editor.onMention((mention) => {
insertFileMention(mention)
})

onCleanup(() => {
unsubscribeMention()
})

props.ref({
get visible() {
return store.visible
Expand Down
50 changes: 49 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { useEditorContext } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
Expand All @@ -21,7 +22,7 @@ import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, type JSX } from "@opentui/solid"
import { useRenderer, useTerminalDimensions, 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 @@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) {
const local = useLocal()
const args = useArgs()
const sdk = useSDK()
const editor = useEditorContext()
const route = useRoute()
const project = useProject()
const sync = useSync()
Expand All @@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) {
const stash = usePromptStash()
const command = useCommandDialog()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
const kv = useKV()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const editorPath = createMemo(() => editor.selection()?.filePath)
const editorSelectionLabel = createMemo(() => {
const selection = editor.selection()?.selection
if (!selection) return
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
return `#${selection.start.line}-${selection.end.line}`
})
const editorFileLabel = createMemo(() => {
const value = editorPath()
if (!value) return
const filename = path.basename(value)
const file = /^index\.[^./]+$/.test(filename)
? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/")
: filename
return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}`
})
const editorFileLabelDisplay = createMemo(() => {
const file = editorFileLabel()
if (!file) return
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
})
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
Expand Down Expand Up @@ -721,6 +746,27 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const editorSelection = editor.selection()
const editorParts = editorSelection
? [
{
id: PartID.ascending(),
type: "text" as const,
text: (() => {
const start = editorSelection.selection.start
const end = editorSelection.selection.end
if (start.line === end.line && start.character === end.character) {
return `Note: The user opened the file "${editorSelection.filePath}".`
}
if (start.line === end.line) {
return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}`
}
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
})(),
synthetic: true,
},
]
: []

if (store.mode === "shell") {
void sdk.client.session.shell({
Expand Down Expand Up @@ -773,6 +819,7 @@ export function Prompt(props: PromptProps) {
model: selectedModel,
variant,
parts: [
...editorParts,
{
id: PartID.ascending(),
type: "text",
Expand Down Expand Up @@ -1332,6 +1379,7 @@ export function Prompt(props: PromptProps) {
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
<Switch>
<Match when={store.mode === "normal"}>
<Switch>
Expand Down
Loading
Loading