Skip to content

Commit 98ea5b6

Browse files
authored
feat(tui): support builtin protocol for handling context from editors (#24034)
1 parent 3f8c659 commit 98ea5b6

4 files changed

Lines changed: 448 additions & 29 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { DialogProvider as DialogProviderList } from "@tui/component/dialog-prov
2424
import { ErrorComponent } from "@tui/component/error-component"
2525
import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
2626
import { ProjectProvider } from "@tui/context/project"
27+
import { EditorContextProvider } from "@tui/context/editor"
2728
import { useEvent } from "@tui/context/event"
2829
import { SDKProvider, useSDK } from "@tui/context/sdk"
2930
import { StartupLoading } from "@tui/component/startup-loading"
@@ -177,7 +178,9 @@ export function tui(input: {
177178
<FrecencyProvider>
178179
<PromptHistoryProvider>
179180
<PromptRefProvider>
180-
<App onSnapshot={input.onSnapshot} />
181+
<EditorContextProvider>
182+
<App onSnapshot={input.onSnapshot} />
183+
</EditorContextProvider>
181184
</PromptRefProvider>
182185
</PromptHistoryProvider>
183186
</FrecencyProvider>

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
22
import { pathToFileURL } from "bun"
33
import fuzzysort from "fuzzysort"
4+
import path from "path"
45
import { firstBy } from "remeda"
56
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
67
import { createStore } from "solid-js/store"
8+
import { useEditorContext } from "@tui/context/editor"
79
import { useSDK } from "@tui/context/sdk"
810
import { useSync } from "@tui/context/sync"
911
import { getScrollAcceleration } from "../../util/scroll"
@@ -77,6 +79,7 @@ export function Autocomplete(props: {
7779
agentStyleId: number
7880
promptPartTypeId: () => number
7981
}) {
82+
const editor = useEditorContext()
8083
const sdk = useSDK()
8184
const sync = useSync()
8285
const command = useCommandDialog()
@@ -221,6 +224,70 @@ export function Autocomplete(props: {
221224
}
222225
}
223226

227+
function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) {
228+
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
229+
const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item)
230+
const urlObj = pathToFileURL(fullPath)
231+
const filename =
232+
lineRange && !item.endsWith("/")
233+
? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
234+
: item
235+
236+
if (lineRange && !item.endsWith("/")) {
237+
urlObj.searchParams.set("start", String(lineRange.startLine))
238+
if (lineRange.endLine !== undefined) {
239+
urlObj.searchParams.set("end", String(lineRange.endLine))
240+
}
241+
}
242+
243+
return {
244+
filename,
245+
url: urlObj.href,
246+
part: {
247+
type: "file" as const,
248+
mime: "text/plain",
249+
filename,
250+
url: urlObj.href,
251+
source: {
252+
type: "file" as const,
253+
text: {
254+
start: 0,
255+
end: 0,
256+
value: "",
257+
},
258+
path: item,
259+
},
260+
},
261+
}
262+
}
263+
264+
function normalizeMentionPath(filePath: string) {
265+
const baseDir = sync.path.directory || process.cwd()
266+
const absolute = path.resolve(filePath)
267+
const relative = path.relative(baseDir, absolute)
268+
269+
if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
270+
return relative.split(path.sep).join("/")
271+
}
272+
273+
return absolute.split(path.sep).join("/")
274+
}
275+
276+
function insertFileMention(input: { filePath: string; lineStart: number; lineEnd: number }) {
277+
const item = normalizeMentionPath(input.filePath)
278+
const lineRange = {
279+
startLine: input.lineStart,
280+
endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined,
281+
}
282+
const { filename, part } = createFilePart(item, lineRange)
283+
const index = store.visible === "@" ? store.index : props.input().cursorOffset
284+
285+
command.keybinds(true)
286+
setStore("visible", false)
287+
setStore("index", index)
288+
insertPart(filename, part)
289+
}
290+
224291
const [files] = createResource(
225292
() => search(),
226293
async (query) => {
@@ -250,18 +317,7 @@ export function Autocomplete(props: {
250317
const width = props.anchor().width - 4
251318
options.push(
252319
...sortedFiles.map((item): AutocompleteOption => {
253-
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "")
254-
const fullPath = `${baseDir}/${item}`
255-
const urlObj = pathToFileURL(fullPath)
256-
let filename = item
257-
if (lineRange && !item.endsWith("/")) {
258-
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
259-
urlObj.searchParams.set("start", String(lineRange.startLine))
260-
if (lineRange.endLine !== undefined) {
261-
urlObj.searchParams.set("end", String(lineRange.endLine))
262-
}
263-
}
264-
const url = urlObj.href
320+
const { filename, url, part } = createFilePart(item, lineRange)
265321

266322
const isDir = item.endsWith("/")
267323
return {
@@ -270,21 +326,7 @@ export function Autocomplete(props: {
270326
isDirectory: isDir,
271327
path: item,
272328
onSelect: () => {
273-
insertPart(filename, {
274-
type: "file",
275-
mime: "text/plain",
276-
filename,
277-
url,
278-
source: {
279-
type: "file",
280-
text: {
281-
start: 0,
282-
end: 0,
283-
value: "",
284-
},
285-
path: item,
286-
},
287-
})
329+
insertPart(filename, part)
288330
},
289331
}
290332
}),
@@ -501,6 +543,14 @@ export function Autocomplete(props: {
501543
}
502544

503545
onMount(() => {
546+
const unsubscribeMention = editor.onMention((mention) => {
547+
insertFileMention(mention)
548+
})
549+
550+
onCleanup(() => {
551+
unsubscribeMention()
552+
})
553+
504554
props.ref({
505555
get visible() {
506556
return store.visible

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useRoute } from "@tui/context/route"
1212
import { useProject } from "@tui/context/project"
1313
import { useSync } from "@tui/context/sync"
1414
import { useEvent } from "@tui/context/event"
15+
import { useEditorContext } from "@tui/context/editor"
1516
import { MessageID, PartID } from "@/session/schema"
1617
import { createStore, produce, unwrap } from "solid-js/store"
1718
import { useKeybind } from "@tui/context/keybind"
@@ -21,7 +22,7 @@ import { usePromptStash } from "./stash"
2122
import { DialogStash } from "../dialog-stash"
2223
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
2324
import { useCommandDialog } from "../dialog-command"
24-
import { useRenderer, type JSX } from "@opentui/solid"
25+
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
2526
import * as Editor from "@tui/util/editor"
2627
import { useExit } from "../../context/exit"
2728
import * as Clipboard from "../../util/clipboard"
@@ -94,6 +95,7 @@ export function Prompt(props: PromptProps) {
9495
const local = useLocal()
9596
const args = useArgs()
9697
const sdk = useSDK()
98+
const editor = useEditorContext()
9799
const route = useRoute()
98100
const project = useProject()
99101
const sync = useSync()
@@ -104,11 +106,34 @@ export function Prompt(props: PromptProps) {
104106
const stash = usePromptStash()
105107
const command = useCommandDialog()
106108
const renderer = useRenderer()
109+
const dimensions = useTerminalDimensions()
107110
const { theme, syntax } = useTheme()
108111
const kv = useKV()
109112
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
110113
const list = createMemo(() => props.placeholders?.normal ?? [])
111114
const shell = createMemo(() => props.placeholders?.shell ?? [])
115+
const editorPath = createMemo(() => editor.selection()?.filePath)
116+
const editorSelectionLabel = createMemo(() => {
117+
const selection = editor.selection()?.selection
118+
if (!selection) return
119+
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
120+
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
121+
return `#${selection.start.line}-${selection.end.line}`
122+
})
123+
const editorFileLabel = createMemo(() => {
124+
const value = editorPath()
125+
if (!value) return
126+
const filename = path.basename(value)
127+
const file = /^index\.[^./]+$/.test(filename)
128+
? [path.basename(path.dirname(value)), filename].filter(Boolean).join("/")
129+
: filename
130+
return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}`
131+
})
132+
const editorFileLabelDisplay = createMemo(() => {
133+
const file = editorFileLabel()
134+
if (!file) return
135+
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
136+
})
112137
const [auto, setAuto] = createSignal<AutocompleteRef>()
113138
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
114139
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -721,6 +746,27 @@ export function Prompt(props: PromptProps) {
721746
// Capture mode before it gets reset
722747
const currentMode = store.mode
723748
const variant = local.model.variant.current()
749+
const editorSelection = editor.selection()
750+
const editorParts = editorSelection
751+
? [
752+
{
753+
id: PartID.ascending(),
754+
type: "text" as const,
755+
text: (() => {
756+
const start = editorSelection.selection.start
757+
const end = editorSelection.selection.end
758+
if (start.line === end.line && start.character === end.character) {
759+
return `Note: The user opened the file "${editorSelection.filePath}".`
760+
}
761+
if (start.line === end.line) {
762+
return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}`
763+
}
764+
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
765+
})(),
766+
synthetic: true,
767+
},
768+
]
769+
: []
724770

725771
if (store.mode === "shell") {
726772
void sdk.client.session.shell({
@@ -773,6 +819,7 @@ export function Prompt(props: PromptProps) {
773819
model: selectedModel,
774820
variant,
775821
parts: [
822+
...editorParts,
776823
{
777824
id: PartID.ascending(),
778825
type: "text",
@@ -1332,6 +1379,7 @@ export function Prompt(props: PromptProps) {
13321379
</Show>
13331380
<Show when={status().type !== "retry"}>
13341381
<box gap={2} flexDirection="row">
1382+
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
13351383
<Switch>
13361384
<Match when={store.mode === "normal"}>
13371385
<Switch>

0 commit comments

Comments
 (0)