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
@@ -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
},
}
}
46 changes: 45 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down Expand Up @@ -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
Expand Down
123 changes: 112 additions & 11 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>(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)
Expand Down Expand Up @@ -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)
})
) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -825,29 +858,94 @@ 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)
setStore("prompt", item)
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
}
}}
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/opencode/test/cli/prompt/history.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})