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
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
})

return {
get items() {
return store.history
},
setIndex(index: number) {
setStore("index", index)
},
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
Expand Down
188 changes: 185 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core"
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding, type ParsedKey } from "@opentui/core"
import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import { useLocal } from "@tui/context/local"
Expand All @@ -12,6 +12,7 @@ import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { clone } from "remeda"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
Expand Down Expand Up @@ -173,6 +174,14 @@ export function Prompt(props: PromptProps) {
extmarkToPartIndex: Map<number, number>
interrupt: number
placeholder: number
historySearch: {
active: boolean
query: string
matchIndex: number
originalPrompt: PromptInfo
originalMode: "normal" | "shell"
originalCursorOffset: number
}
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
prompt: {
Expand All @@ -182,6 +191,14 @@ export function Prompt(props: PromptProps) {
mode: "normal",
extmarkToPartIndex: new Map(),
interrupt: 0,
historySearch: {
active: false,
query: "",
matchIndex: 0,
originalPrompt: { input: "", parts: [] },
originalMode: "normal",
originalCursorOffset: 0,
},
})

command.register(() => {
Expand Down Expand Up @@ -635,6 +652,125 @@ export function Prompt(props: PromptProps) {
}
const exit = useExit()

const historySearchMatches = createMemo(() => {
if (!store.historySearch.active) return []
const query = store.historySearch.query.toLowerCase()
if (!query) return history.items.slice().reverse()
return history.items.filter((item) => item.input.toLowerCase().includes(query)).reverse()
})

function enterHistorySearch() {
command.keybinds(false)
setStore("historySearch", {
active: true,
query: "",
matchIndex: 0,
originalPrompt: clone(store.prompt),
originalMode: store.mode,
originalCursorOffset: input.cursorOffset,
})
}

function exitHistorySearch(restore: boolean) {
command.keybinds(true)
if (restore) {
const original = store.historySearch.originalPrompt
input.setText(original.input)
setStore("prompt", { input: original.input, parts: original.parts })
setStore("mode", store.historySearch.originalMode)
restoreExtmarksFromParts(original.parts)
input.cursorOffset = store.historySearch.originalCursorOffset
} else {
input.cursorOffset = input.plainText.length
}
setStore("historySearch", {
active: false,
query: "",
matchIndex: 0,
originalPrompt: { input: "", parts: [] },
originalMode: "normal",
originalCursorOffset: 0,
})
}

function selectHistoryMatch(match: PromptInfo) {
input.setText(match.input)
setStore("prompt", { input: match.input, parts: match.parts })
setStore("mode", match.mode ?? "normal")
restoreExtmarksFromParts(match.parts)
// Align history navigation cursor so Up/Down continues from this match
const matchIndexInHistory = history.items.findIndex((item) => item.input === match.input)
if (matchIndexInHistory !== -1) {
history.setIndex(-(history.items.length - matchIndexInHistory))
}
exitHistorySearch(false)
}

function updateHistorySearchPreview() {
const matches = historySearchMatches()
if (matches.length > 0) {
const match = matches[store.historySearch.matchIndex % matches.length]
if (match) {
input.setText(match.input)
setStore("prompt", { input: match.input, parts: match.parts })
restoreExtmarksFromParts(match.parts)
input.cursorOffset = input.plainText.length
}
}
}

function handleHistorySearchKey(e: ParsedKey & { preventDefault: () => void }) {
e.preventDefault()
if (e.name === "escape" || (e.ctrl && e.name === "g")) {
exitHistorySearch(true)
return true
}
if (e.name === "return") {
const matches = historySearchMatches()
if (matches.length > 0) {
const match = matches[store.historySearch.matchIndex % matches.length]
if (match) selectHistoryMatch(match)
} else {
exitHistorySearch(true)
}
return true
}
if (keybind.match("history_search", e) || (e.ctrl && e.name === "r") || e.name === "up") {
const matches = historySearchMatches()
if (matches.length > 1) {
setStore("historySearch", "matchIndex", (store.historySearch.matchIndex + 1) % matches.length)
updateHistorySearchPreview()
}
return true
}
if (e.name === "down") {
const matches = historySearchMatches()
if (store.historySearch.matchIndex === 0) {
exitHistorySearch(true)
} else if (matches.length > 1) {
setStore("historySearch", "matchIndex", store.historySearch.matchIndex - 1)
updateHistorySearchPreview()
}
return true
}
if (e.name === "backspace") {
if (store.historySearch.query.length > 0) {
setStore("historySearch", "query", store.historySearch.query.slice(0, -1))
setStore("historySearch", "matchIndex", 0)
updateHistorySearchPreview()
}
return true
}
const char = e.name === "space" ? " " : e.name
if (char && char.length === 1 && !e.ctrl && !e.meta) {
setStore("historySearch", "query", store.historySearch.query + char)
setStore("historySearch", "matchIndex", 0)
updateHistorySearchPreview()
return true
}
return true
}

function pasteText(text: string, virtualText: string) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
Expand Down Expand Up @@ -801,6 +937,10 @@ export function Prompt(props: PromptProps) {
// through bracketed paste, so we need to intercept the keypress and
// directly read from clipboard before the terminal handles it
if (keybind.match("input_paste", e)) {
if (store.historySearch.active) {
e.preventDefault()
return
}
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault()
Expand All @@ -813,7 +953,7 @@ export function Prompt(props: PromptProps) {
}
// If no image, let the default paste behavior continue
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
if (!store.historySearch.active && keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
input.extmarks.clear()
setStore("prompt", {
Expand All @@ -831,11 +971,15 @@ export function Prompt(props: PromptProps) {
return
}
}
if (e.name === "!" && input.visualCursor.offset === 0) {
if (!store.historySearch.active && e.name === "!" && input.visualCursor.offset === 0) {
setStore("mode", "shell")
e.preventDefault()
return
}
if (store.historySearch.active) {
handleHistorySearchKey(e)
return
}
if (store.mode === "shell") {
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
setStore("mode", "normal")
Expand All @@ -845,6 +989,14 @@ export function Prompt(props: PromptProps) {
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (keybind.match("history_search", e)) {
e.preventDefault()
if (history.items.length > 0) {
enterHistorySearch()
updateHistorySearchPreview()
}
return
}
if (
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
Expand Down Expand Up @@ -876,6 +1028,11 @@ export function Prompt(props: PromptProps) {
return
}

if (store.historySearch.active) {
event.preventDefault()
return
}

// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
Expand Down Expand Up @@ -1072,6 +1229,31 @@ export function Prompt(props: PromptProps) {
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.historySearch.active}>
<text fg={theme.primary}>
(reverse-i-search)`<span style={{ fg: theme.accent }}>{store.historySearch.query}</span>
':{" "}
{(() => {
const matches = historySearchMatches()
const count = matches.length
if (count === 0) return <span style={{ fg: theme.textMuted }}>no matches</span>
return (
<span style={{ fg: theme.textMuted }}>
{store.historySearch.matchIndex + 1}/{count}
</span>
)
})()}
</text>
<text fg={theme.textMuted}>
{keybind.print("history_search")} <span style={{ fg: theme.textMuted }}>next</span>
</text>
<text fg={theme.textMuted}>
esc <span style={{ fg: theme.textMuted }}>cancel</span>
</text>
<text fg={theme.textMuted}>
enter <span style={{ fg: theme.textMuted }}>select</span>
</text>
</Match>
<Match when={store.mode === "normal"}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,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("Reverse search 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
4 changes: 4 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,10 @@ export type KeybindsConfig = {
* Next history item
*/
history_next?: string
/**
* Reverse search history
*/
history_search?: string
/**
* Next child session
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7691,6 +7691,11 @@
"default": "down",
"type": "string"
},
"history_search": {
"description": "Reverse search history",
"default": "ctrl+r",
"type": "string"
},
"session_child_cycle": {
"description": "Next child session",
"default": "<leader>right",
Expand Down