Skip to content
Open
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
55 changes: 30 additions & 25 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { Ide } from "@/ide"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
import { createColors, createFrames } from "../../ui/spinner.ts"
Expand All @@ -44,7 +45,6 @@ export type PromptRef = {
reset(): void
blur(): void
focus(): void
submit(): void
}

const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
Expand Down Expand Up @@ -116,7 +116,7 @@ export function Prompt(props: PromptProps) {
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
Expand Down Expand Up @@ -312,6 +312,10 @@ export function Prompt(props: PromptProps) {
input.insertText(evt.properties.text)
})

sdk.event.on(Ide.Event.SelectionChanged.type, (evt) => {
updateIdeSelection(evt.properties.selection)
})

createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
Expand Down Expand Up @@ -342,6 +346,12 @@ export function Prompt(props: PromptProps) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
})

function updateIdeSelection(_selection: Ide.Selection | null) {
// Selection is now displayed in footer via local.selection
// No visual insertion in the input needed
// Content will be included at submit time from local.selection
}

function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
input.extmarks.clear()
setStore("extmarkToPartIndex", new Map())
Expand Down Expand Up @@ -448,14 +458,11 @@ export function Prompt(props: PromptProps) {
})
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
},
})

async function submit() {
if (props.disabled) return
if (autocomplete?.visible) return
if (autocomplete.visible) return
if (!store.prompt.input) return
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
Expand All @@ -476,6 +483,8 @@ export function Prompt(props: PromptProps) {
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input

// IDE selection is displayed in footer only - not injected into message

// Expand pasted text inline before submitting
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
Expand All @@ -495,9 +504,6 @@ export function Prompt(props: PromptProps) {
// Filter out text parts (pasted content) since they're now expanded inline
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")

// Capture mode before it gets reset
const currentMode = store.mode

if (store.mode === "shell") {
sdk.client.session.shell({
sessionID,
Expand Down Expand Up @@ -539,17 +545,20 @@ export function Prompt(props: PromptProps) {
type: "text",
text: inputText,
},
...(local.selection.current()?.text ? [{
id: Identifier.ascending("part"),
type: "text" as const,
text: `\n\n[IDE Selection: ${local.selection.current()!.filePath.split(/[\/\\]/).pop() || local.selection.current()!.filePath}:${local.selection.current()!.selection.start.line + 1}-${local.selection.current()!.selection.end.line + 1}]\n\`\`\`\n${local.selection.current()!.text}\n\`\`\``,
synthetic: true,
}] : []),
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
})
}
history.append({
...store.prompt,
mode: currentMode,
})
history.append(store.prompt)
input.extmarks.clear()
setStore("prompt", {
input: "",
Expand Down Expand Up @@ -715,8 +724,8 @@ export function Prompt(props: PromptProps) {
>
<textarea
placeholder={props.sessionID ? undefined : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
textColor={keybind.leader ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
textColor={theme.text}
focusedTextColor={theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
Expand All @@ -742,12 +751,8 @@ export function Prompt(props: PromptProps) {
return
}
if (keybind.match("app_exit", e)) {
if (store.prompt.input === "") {
await exit()
// Don't preventDefault - let textarea potentially handle the event
e.preventDefault()
return
}
await exit()
return
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("mode", "shell")
Expand All @@ -773,7 +778,6 @@ export function Prompt(props: PromptProps) {
if (item) {
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
Expand Down Expand Up @@ -865,7 +869,7 @@ export function Prompt(props: PromptProps) {
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
<text flexShrink={0} fg={theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
Expand All @@ -880,15 +884,16 @@ export function Prompt(props: PromptProps) {
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
// when the background is transparent, don't draw the vertical line
vertical: theme.background.a != 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={
theme.backgroundElement.a !== 0
theme.background.a != 0
? {
...EmptyBorder,
horizontal: "▀",
Expand Down
58 changes: 55 additions & 3 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createStore } from "solid-js/store"
import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
Expand All @@ -12,6 +12,7 @@ import { Provider } from "@/provider/provider"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Ide } from "@/ide"

export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
Expand Down Expand Up @@ -52,11 +53,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})

const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
current: agents().find((x) => x.default)?.name ?? agents()[0].name,
current: agents()[0].name,
})
const { theme } = useTheme()
const colors = createMemo(() => [
Expand Down Expand Up @@ -329,10 +330,61 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
}

const ide = {
isConnected(name: string) {
const status = sync.data.ide[name]
return status?.status === "connected"
},
getWorkspaceFolders(name: string) {
const status = sync.data.ide[name]
if (status && "workspaceFolders" in status && status.workspaceFolders) {
return status.workspaceFolders
}
return []
},
async toggle(name: string) {
const current = sync.data.ide[name]
if (current?.status === "connected") {
await sdk.client.ide.disconnect({ name })
} else {
await sdk.client.ide.connect({ name })
}
const status = await sdk.client.ide.status()
if (status.data) sync.set("ide", reconcile(status.data))
},
}


const selection = iife(() => {
const [selStore, setSelStore] = createStore<{
current: Ide.Selection | null
}>({ current: null })

sdk.event.on(Ide.Event.SelectionChanged.type, async (evt) => {
setSelStore("current", evt.properties.selection)
// Refresh IDE status when we receive a selection
const status = await sdk.client.ide.status()
if (status.data) sync.set("ide", reconcile(status.data))
})

return {
current: () => selStore.current,
clear: () => setSelStore("current", null),
formatted: () => {
const sel = selStore.current
if (!sel || !sel.text) return null
const lines = sel.text.split("\n").length
return `${lines} lines`
},
}
})

const result = {
model,
agent,
mcp,
ide,
selection,
}
return result
},
Expand Down
18 changes: 16 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
import { useDirectory } from "../context/directory"
import { useLocal } from "../context/local"
import { useRoute, useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
Expand Down Expand Up @@ -57,10 +58,11 @@ export function Home() {
} else if (args.prompt) {
prompt.set({ input: args.prompt, parts: [] })
once = true
prompt.submit()
}
})
const directory = useDirectory()
const local = useLocal()
const ide = createMemo(() => Object.values(sync.data.ide).find((x) => x.status === "connected"))

return (
<>
Expand Down Expand Up @@ -92,8 +94,20 @@ export function Home() {
</Switch>
{connectedMcpCount()} MCP
</text>
<text fg={theme.textMuted}>/status</text>
</Show>
<Show when={ide()}>
<text fg={theme.text}>
<span style={{ fg: theme.success }}>◆ </span>
{ide()!.name}
</text>
</Show>
<Show when={local.selection.formatted()}>
<text fg={theme.text}>
<span style={{ fg: theme.accent }}>[] </span>
{local.selection.formatted()}
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
</box>
<box flexGrow={1} />
<box flexShrink={0}>
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useDirectory } from "../../context/directory"
import { useConnected } from "../../component/dialog-model"
import { createStore } from "solid-js/store"
import { useRoute } from "../../context/route"
import { useLocal } from "../../context/local"

export function Footer() {
const { theme } = useTheme()
Expand All @@ -13,12 +14,14 @@ export function Footer() {
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
const lsp = createMemo(() => Object.keys(sync.data.lsp))
const ide = createMemo(() => Object.values(sync.data.ide).find((x) => x.status === "connected"))
const permissions = createMemo(() => {
if (route.data.type !== "session") return []
return sync.data.permission[route.data.sessionID] ?? []
})
const directory = useDirectory()
const connected = useConnected()
const local = useLocal()

const [store, setStore] = createStore({
welcome: false,
Expand Down Expand Up @@ -79,6 +82,18 @@ export function Footer() {
{mcp()} MCP
</text>
</Show>
<Show when={ide()}>
<text fg={theme.text}>
<span style={{ fg: theme.success }}>◆ </span>
{ide()!.name}
</text>
</Show>
<Show when={local.selection.formatted()}>
<text fg={theme.text}>
<span style={{ fg: theme.accent }}>[] </span>
{local.selection.formatted()}
</text>
</Show>
<text fg={theme.textMuted}>/status</text>
</Match>
</Switch>
Expand Down
Loading