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
28 changes: 18 additions & 10 deletions packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Global } from "@/global"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import * as fuzzysort from "fuzzysort"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
Expand All @@ -14,14 +15,14 @@ export type PromptInfo = {
| Omit<FilePart, "id" | "messageID" | "sessionID">
| Omit<AgentPart, "id" | "messageID" | "sessionID">
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
source?: {
text: {
start: number
end: number
value: string
}
source?: {
text: {
start: number
end: number
value: string
}
})
}
})
)[]
}

Expand Down Expand Up @@ -51,7 +52,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
// Rewrite file with only valid entries to self-heal corruption
if (lines.length > 0) {
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
writeFile(historyFile.name!, content).catch(() => { })
}
})

Expand Down Expand Up @@ -97,11 +98,18 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create

if (trimmed) {
const content = store.history.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(historyFile.name!, content).catch(() => {})
writeFile(historyFile.name!, content).catch(() => { })
return
}

appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => {})
appendFile(historyFile.name!, JSON.stringify(entry) + "\n").catch(() => { })
},
search(query: string): PromptInfo[] {
const items = store.history.slice().reverse()
if (!query.trim()) return items
return fuzzysort
.go(query, items, { key: "input" })
.map((r) => r.obj) as PromptInfo[]
},
}
},
Expand Down
44 changes: 31 additions & 13 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogHistorySearch } from "../../ui/dialog-history-search"

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -529,9 +530,9 @@ export function Prompt(props: PromptProps) {
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input

Expand Down Expand Up @@ -620,7 +621,7 @@ export function Prompt(props: PromptProps) {
})),
],
})
.catch(() => {})
.catch(() => { })
}
history.append({
...store.prompt,
Expand Down Expand Up @@ -885,6 +886,23 @@ export function Prompt(props: PromptProps) {
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
input.cursorOffset = input.plainText.length

if (keybind.match("history_search", e)) {
e.preventDefault()
dialog.replace(() => (
<DialogHistorySearch
searchHistory={(query) => history.search(query)}
onSelect={(item) => {
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
input.cursorOffset = input.plainText.length
}}
/>
))
return
}
}
}}
onSubmit={submit}
Expand Down Expand Up @@ -914,7 +932,7 @@ export function Prompt(props: PromptProps) {
// Handle SVG as raw text content, not as base64 image
if (file.type === "image/svg+xml") {
event.preventDefault()
const content = await file.text().catch(() => {})
const content = await file.text().catch(() => { })
if (content) {
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
return
Expand All @@ -925,7 +943,7 @@ export function Prompt(props: PromptProps) {
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
.catch(() => { })
if (content) {
await pasteImage({
filename: file.name,
Expand All @@ -935,7 +953,7 @@ export function Prompt(props: PromptProps) {
return
}
}
} catch {}
} catch { }
}

const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
Expand Down Expand Up @@ -1010,13 +1028,13 @@ export function Prompt(props: PromptProps) {
customBorderChars={
theme.backgroundElement.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
}
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
Expand Down
144 changes: 144 additions & 0 deletions packages/opencode/src/cli/cmd/tui/ui/dialog-history-search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { TextAttributes } from "@opentui/core"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useDialog } from "@tui/ui/dialog"
import type { PromptInfo } from "@tui/component/prompt/history"
import { Locale } from "@/util/locale"

export interface DialogHistorySearchProps {
onSelect: (item: PromptInfo) => void
searchHistory: (query: string) => PromptInfo[]
}

export function DialogHistorySearch(props: DialogHistorySearchProps) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
selected: 0,
filter: "",
})

const filtered = createMemo(() => props.searchHistory(store.filter))

const dimensions = useTerminalDimensions()
const height = createMemo(() => Math.min(filtered().length, Math.floor(dimensions().height / 2) - 6))

const selected = createMemo(() => filtered()[store.selected])

function move(direction: number) {
if (filtered().length === 0) return
let next = store.selected + direction
if (next < 0) next = filtered().length - 1
if (next >= filtered().length) next = 0
setStore("selected", next)
}

useKeyboard((evt) => {
if (evt.name === "up" || (evt.ctrl && evt.name === "p") || (evt.ctrl && evt.name === "r")) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)

if (evt.name === "return") {
const item = selected()
if (item) {
evt.preventDefault()
evt.stopPropagation()
dialog.clear()
props.onSelect(item)
}
}
})

return (
<box gap={1} paddingBottom={1}>
<box paddingLeft={4} paddingRight={4}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Search History
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box paddingTop={1}>
<input
onInput={(e) => {
setStore("filter", e)
setStore("selected", 0)
}}
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
setTimeout(() => {
if (!r || r.isDestroyed) return
r.focus()
}, 1)
}}
placeholder="(reverse-i-search)"
/>
</box>
</box>
<Show
when={filtered().length > 0}
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No history found</text>
</box>
}
>
<scrollbox
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
maxHeight={height()}
>
<For each={filtered()}>
{(item, index) => {
const active = createMemo(() => index() === store.selected)
const fg = selectedForeground(theme)
return (
<box
flexDirection="row"
backgroundColor={active() ? theme.primary : undefined}
paddingLeft={3}
paddingRight={3}
onMouseUp={() => {
dialog.clear()
props.onSelect(item)
}}
onMouseOver={() => setStore("selected", index())}
>
<text
flexGrow={1}
fg={active() ? fg : theme.text}
attributes={active() ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
>
{Locale.truncate(item.input.replace(/\n/g, " "), 70)}
</text>
</box>
)
}}
</For>
</scrollbox>
</Show>
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
<text>
<span style={{ fg: theme.text }}>
<b>Select</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>Enter</span>
</text>
<text>
<span style={{ fg: theme.text }}>
<b>Next</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>Ctrl+R</span>
</text>
</box>
</box>
)
}
21 changes: 11 additions & 10 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,12 @@ export namespace Config {
// Only scan project .opencode/ directories when project discovery is enabled
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Instance.directory,
stop: Instance.worktree,
}),
)
Filesystem.up({
targets: [".opencode"],
start: Instance.directory,
stop: Instance.worktree,
}),
)
: []),
// Always scan ~/.opencode/ (user home directory)
...(await Array.fromAsync(
Expand Down Expand Up @@ -771,7 +771,7 @@ export namespace Config {
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
session_fork: z.string().optional().default("none").describe("Fork session from message"),
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
session_rename: z.string().optional().default("<leader>R").describe("Rename session"),
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
Expand Down Expand Up @@ -901,6 +901,7 @@ export namespace Config {
.describe("Delete word backward in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"),
history_search: z.string().optional().default("ctrl+r").describe("Search prompt history"),
session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
Expand Down Expand Up @@ -1214,7 +1215,7 @@ export namespace Config {
await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
await fs.unlink(legacy)
})
.catch(() => {})
.catch(() => { })
}

return result
Expand Down Expand Up @@ -1305,15 +1306,15 @@ export namespace Config {
parsed.data.$schema = "https://opencode.ai/config.json"
// Write the $schema to the original text to preserve variables like {env:VAR}
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(configFilepath, updated).catch(() => {})
await Bun.write(configFilepath, updated).catch(() => { })
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
} catch (err) {}
} catch (err) { }
}
}
return data
Expand Down
Loading
Loading