Skip to content

Commit 6dfa514

Browse files
committed
feat(tui): support builtin protocol for handling context from editors
1 parent 38deb0f commit 6dfa514

4 files changed

Lines changed: 458 additions & 46 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: 95 additions & 45 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) => {
@@ -233,10 +300,10 @@ export function Autocomplete(props: {
233300
query: baseQuery,
234301
})
235302

236-
const options: AutocompleteOption[] = []
303+
const options: AutocompleteOption[] = []
237304

238-
// Add file options
239-
if (!result.error && result.data) {
305+
// Add file options
306+
if (!result.error && result.data) {
240307
const sortedFiles = result.data.sort((a, b) => {
241308
const aScore = frecency.getFrecency(a)
242309
const bScore = frecency.getFrecency(b)
@@ -247,49 +314,24 @@ export function Autocomplete(props: {
247314
return a.localeCompare(b)
248315
})
249316

250-
const width = props.anchor().width - 4
251-
options.push(
252-
...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))
317+
const width = props.anchor().width - 4
318+
options.push(
319+
...sortedFiles.map((item): AutocompleteOption => {
320+
const { filename, url, part } = createFilePart(item, lineRange)
321+
322+
const isDir = item.endsWith("/")
323+
return {
324+
display: Locale.truncateMiddle(filename, width),
325+
value: filename,
326+
isDirectory: isDir,
327+
path: item,
328+
onSelect: () => {
329+
insertPart(filename, part)
330+
},
262331
}
263-
}
264-
const url = urlObj.href
265-
266-
const isDir = item.endsWith("/")
267-
return {
268-
display: Locale.truncateMiddle(filename, width),
269-
value: filename,
270-
isDirectory: isDir,
271-
path: item,
272-
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-
})
288-
},
289-
}
290-
}),
291-
)
292-
}
332+
}),
333+
)
334+
}
293335

294336
return options
295337
},
@@ -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: 41 additions & 0 deletions
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"
@@ -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()
@@ -109,6 +111,22 @@ export function Prompt(props: PromptProps) {
109111
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
110112
const list = createMemo(() => props.placeholders?.normal ?? [])
111113
const shell = createMemo(() => props.placeholders?.shell ?? [])
114+
const editorPath = createMemo(() => editor.selection()?.filePath)
115+
const editorSelectionLabel = createMemo(() => {
116+
const selection = editor.selection()?.selection
117+
if (!selection) return
118+
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
119+
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
120+
return `#${selection.start.line}-${selection.end.line}`
121+
})
122+
const editorFileLabel = createMemo(() => {
123+
const value = editorPath()
124+
if (!value) return
125+
const root = sync.path.directory || process.cwd()
126+
const relative = path.relative(root, value)
127+
const file = relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : value
128+
return `${file.split(path.sep).join("/")}${editorSelectionLabel() ?? ""}`
129+
})
112130
const [auto, setAuto] = createSignal<AutocompleteRef>()
113131
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
114132
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -721,6 +739,27 @@ export function Prompt(props: PromptProps) {
721739
// Capture mode before it gets reset
722740
const currentMode = store.mode
723741
const variant = local.model.variant.current()
742+
const editorSelection = editor.selection()
743+
const editorParts = editorSelection
744+
? [
745+
{
746+
id: PartID.ascending(),
747+
type: "text" as const,
748+
text: (() => {
749+
const start = editorSelection.selection.start
750+
const end = editorSelection.selection.end
751+
if (start.line === end.line && start.character === end.character) {
752+
return `Note: The user opened the file "${editorSelection.filePath}".`
753+
}
754+
if (start.line === end.line) {
755+
return `Note: The user selected line ${start.line} from "${editorSelection.filePath}": ${editorSelection.text}`
756+
}
757+
return `Note: The user selected lines ${start.line} to ${end.line} from "${editorSelection.filePath}": ${editorSelection.text}`
758+
})(),
759+
synthetic: true,
760+
},
761+
]
762+
: []
724763

725764
if (store.mode === "shell") {
726765
void sdk.client.session.shell({
@@ -773,6 +812,7 @@ export function Prompt(props: PromptProps) {
773812
model: selectedModel,
774813
variant,
775814
parts: [
815+
...editorParts,
776816
{
777817
id: PartID.ascending(),
778818
type: "text",
@@ -1332,6 +1372,7 @@ export function Prompt(props: PromptProps) {
13321372
</Show>
13331373
<Show when={status().type !== "retry"}>
13341374
<box gap={2} flexDirection="row">
1375+
<Show when={editorFileLabel()}>{(file) => <text fg={theme.warning}>{file()}</text>}</Show>
13351376
<Switch>
13361377
<Match when={store.mode === "normal"}>
13371378
<Switch>

0 commit comments

Comments
 (0)