diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history-helper.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/history-helper.ts new file mode 100644 index 00000000000..5b2a769e7ad --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history-helper.ts @@ -0,0 +1,73 @@ +export type PromptInfo = { + input: string + mode?: "normal" | "shell" + parts: any[] +} + +const MAX_HISTORY_ENTRIES = 50 + +export function createPromptHistoryStoreForTest(initialHistory: PromptInfo[] = []) { + const store: { index: number; history: PromptInfo[] } = { + index: 0, + history: initialHistory.slice(), + } + + return { + resetIndex() { + store.index = 0 + }, + + move(direction: 1 | -1, input: string) { + if (!store.history.length) return undefined + + if (input && input.length) { + let idx = store.index + while (true) { + const next = idx + direction + if (Math.abs(next) > store.history.length) break + // When going down and reaching index 0, return the prefix as the "last" item + if (next >= 0) { + if (direction === 1) { + store.index = 0 + return { input: input, parts: [] } + } + break + } + const candidate = store.history.at(next) + if (!candidate) { + idx = next + continue + } + if (candidate.input.startsWith(input)) { + store.index = next + return candidate + } + idx = next + } + return + } + + const current = store.history.at(store.index) + if (!current) return undefined + const next = store.index + direction + if (Math.abs(next) <= store.history.length && next <= 0) { + store.index = next + } + if (store.index === 0) + return { + input: "", + parts: [], + } + return store.history.at(store.index) + }, + + append(item: PromptInfo) { + const entry = JSON.parse(JSON.stringify(item)) + store.history.push(entry) + if (store.history.length > MAX_HISTORY_ENTRIES) { + store.history = store.history.slice(-MAX_HISTORY_ENTRIES) + } + store.index = 0 + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx index e90503e9f52..54c9843ed27 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx @@ -27,6 +27,11 @@ export type PromptInfo = { const MAX_HISTORY_ENTRIES = 50 +export function createPromptHistoryStoreForTest(initialHistory: PromptInfo[] = []) { + // Use a lightweight helper implementation to avoid Solid/Bun runtime imports in tests + return require("./history-helper").createPromptHistoryStoreForTest(initialHistory) +} + export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({ name: "PromptHistory", init: () => { @@ -61,11 +66,50 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create }) return { + resetIndex() { + setStore("index", 0) + }, move(direction: 1 | -1, input: string) { if (!store.history.length) return undefined + // If a prefix is provided, search the next matching entry that startsWith(prefix) + if (input && input.length) { + let idx = store.index + while (true) { + const next = idx + direction + if (Math.abs(next) > store.history.length) break + // When going down and reaching index 0, return the prefix as the "last" item + if (next >= 0) { + if (direction === 1) { + setStore( + produce((draft) => { + draft.index = 0 + }), + ) + return { input: input, parts: [] } + } + break + } + const candidate = store.history.at(next) + if (!candidate) { + idx = next + continue + } + if (candidate.input.startsWith(input)) { + // update index and return the matching candidate directly + setStore( + produce((draft) => { + draft.index = next + }), + ) + return candidate + } + idx = next + } + return + } + const current = store.history.at(store.index) if (!current) return undefined - if (current.input !== input && input.length) return setStore( produce((draft) => { const next = store.index + direction diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 145fa9da0c3..13c1b242ca0 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -87,10 +87,36 @@ export function Prompt(props: PromptProps) { const textareaKeybindings = useTextareaKeybindings() + // Filtered history mode state + const [filteredActive, setFilteredActive] = createSignal(false) + const [filteredPrefix, setFilteredPrefix] = createSignal(undefined) + let prefixExtmarkId: number | undefined + const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! const pasteStyleId = syntax().getStyleId("extmark.paste")! let promptPartTypeId = 0 + let prefixExtmarkTypeId = 0 + + // Helper to update prefix highlight extmark in filtered mode + function updatePrefixHighlight(prefix: string | undefined) { + if (!prefixExtmarkTypeId) return + // Remove existing prefix extmark + if (prefixExtmarkId !== undefined) { + input.extmarks.delete(prefixExtmarkId) + prefixExtmarkId = undefined + } + // Create new extmark if prefix is provided + if (prefix && prefix.length > 0) { + prefixExtmarkId = input.extmarks.create({ + start: 0, + end: prefix.length, + virtual: false, + styleId: fileStyleId, // Uses warning (gold) color + bold + typeId: prefixExtmarkTypeId, + }) + } + } sdk.event.on(TuiEvent.PromptAppend.type, (evt) => { input.insertText(evt.properties.text) @@ -543,7 +569,7 @@ export function Prompt(props: PromptProps) { inputText.startsWith("/") && iife(() => { const command = inputText.split(" ")[0].slice(1) - console.log(command) + return sync.data.command.some((x) => x.name === command) }) ) { @@ -803,6 +829,13 @@ export function Prompt(props: PromptProps) { parts: [], }) setStore("extmarkToPartIndex", new Map()) + // Clear filtered history mode when input is cleared + if (filteredActive()) { + setFilteredActive(false) + setFilteredPrefix(undefined) + updatePrefixHighlight(undefined) + history.resetIndex() + } return } if (keybind.match("app_exit", e)) { @@ -825,14 +858,73 @@ export function Prompt(props: PromptProps) { return } } + + // Clear filtered history mode when user edits (non-navigation keys) + const isNavKey = e.name === "up" || e.name === "down" + const hasModifier = e.ctrl || e.meta + if (!isNavKey && filteredActive()) { + setFilteredActive(false) + setFilteredPrefix(undefined) + updatePrefixHighlight(undefined) + history.resetIndex() // Reset so normal navigation starts fresh + } + + // Handle Ctrl/Cmd+Up/Down for filtered history mode + if (isNavKey && hasModifier) { + const direction = e.name === "up" ? -1 : 1 + const currentText = input.plainText + + // Determine the search prefix: + // - If already in filtered mode and current text starts with saved prefix, keep using saved prefix + // - Otherwise use current text as new prefix + const savedPrefix = filteredPrefix() + const continueFiltered = filteredActive() && savedPrefix && currentText.startsWith(savedPrefix) + const searchPrefix = continueFiltered ? savedPrefix : currentText + const usePrefix = searchPrefix.length > 0 + + if (usePrefix) { + // If starting a new filtered search, reset history index + if (!continueFiltered) { + history.resetIndex() + } + setFilteredActive(true) + setFilteredPrefix(searchPrefix) + } + + const item = history.move(direction, usePrefix ? searchPrefix : "") + + if (item) { + input.setText(item.input) + setStore("prompt", item) + setStore("mode", item.mode ?? "normal") + restoreExtmarksFromParts(item.parts) + e.preventDefault() + // Keep cursor at the end of the search prefix so user sees where they are + if (usePrefix) { + input.cursorOffset = searchPrefix.length + updatePrefixHighlight(searchPrefix) + } else { + if (direction === -1) input.cursorOffset = 0 + else input.cursorOffset = input.plainText.length + } + } + return + } + if (store.mode === "normal") autocomplete.onKeyDown(e) if (!autocomplete.visible) { - if ( - (keybind.match("history_previous", e) && input.cursorOffset === 0) || - (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length) - ) { - const direction = keybind.match("history_previous", e) ? -1 : 1 - const item = history.move(direction, input.plainText) + // Check if navigating history (at cursor edges OR in filtered mode) + const isHistoryPrev = keybind.match("history_previous", e) + const isHistoryNext = keybind.match("history_next", e) + const atStart = input.cursorOffset === 0 + const atEnd = input.cursorOffset === input.plainText.length + const inFilteredMode = filteredActive() && filteredPrefix() + + if ((isHistoryPrev && (atStart || inFilteredMode)) || (isHistoryNext && (atEnd || inFilteredMode))) { + const direction = isHistoryPrev ? -1 : 1 + // When in filtered mode, use the saved prefix; otherwise use empty string + const prefix = inFilteredMode ? filteredPrefix()! : "" + const item = history.move(direction, prefix) if (item) { input.setText(item.input) @@ -840,14 +932,20 @@ export function Prompt(props: PromptProps) { setStore("mode", item.mode ?? "normal") restoreExtmarksFromParts(item.parts) e.preventDefault() - if (direction === -1) input.cursorOffset = 0 - if (direction === 1) input.cursorOffset = input.plainText.length + // In filtered mode, keep cursor at prefix position and highlight prefix + if (inFilteredMode) { + input.cursorOffset = prefix.length + updatePrefixHighlight(prefix) + } else { + if (direction === -1) input.cursorOffset = 0 + if (direction === 1) input.cursorOffset = input.plainText.length + } } return } - 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) + if (isHistoryPrev && input.visualCursor.visualRow === 0) input.cursorOffset = 0 + if (isHistoryNext && input.visualCursor.visualRow === input.height - 1) input.cursorOffset = input.plainText.length } }} @@ -923,6 +1021,9 @@ export function Prompt(props: PromptProps) { if (promptPartTypeId === 0) { promptPartTypeId = input.extmarks.registerType("prompt-part") } + if (prefixExtmarkTypeId === 0) { + prefixExtmarkTypeId = input.extmarks.registerType("prefix-highlight") + } props.ref?.(ref) setTimeout(() => { input.cursorColor = theme.text diff --git a/packages/opencode/test/cli/prompt/history.test.ts b/packages/opencode/test/cli/prompt/history.test.ts new file mode 100644 index 00000000000..f48f3c0179a --- /dev/null +++ b/packages/opencode/test/cli/prompt/history.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { createPromptHistoryStoreForTest } from "../../../src/cli/cmd/tui/component/prompt/history-helper" + +describe("PromptHistory (unit)", () => { + test("filtered move finds matching entry and returns prefix when moving down", () => { + const store = createPromptHistoryStoreForTest([ + { input: "abc1", parts: [] }, + { input: "prefix-match", parts: [] }, + { input: "prefix-other", parts: [] }, + { input: "another", parts: [] }, + ]) + + const up = store.move(-1, "prefix") + expect(up).not.toBeUndefined() + expect(up!.input).toBe("prefix-other") + + const down = store.move(1, "prefix") + expect(down).not.toBeUndefined() + expect(down!.input).toBe("prefix") + }) + + test("non-filter navigation returns last item and resetIndex works", () => { + const store = createPromptHistoryStoreForTest([ + { input: "old", parts: [] }, + { input: "newer", parts: [] }, + ]) + + store.resetIndex() + const res = store.move(-1, "") + expect(res).not.toBeUndefined() + expect(res!.input).toBe("newer") + }) + + test("append adds entries and latest is returned", () => { + const store = createPromptHistoryStoreForTest([]) + for (let i = 0; i < 55; i++) { + store.append({ input: String(i), parts: [] }) + } + const res = store.move(-1, "") + expect(res).not.toBeUndefined() + expect(res!.input).toBe("54") + }) +})