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
54 changes: 43 additions & 11 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}

let stashed: { prompt: PromptInfo; cursor: number } | undefined
const drafts = new Map<string, { prompt: PromptInfo; cursor: number }>()

function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
const head = parseSlashHead(input, /\s/)
Expand Down Expand Up @@ -172,6 +172,20 @@ export function Prompt(props: PromptProps) {
const exit = useExit()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const draftKey = (sessionID?: string) => sessionID ?? "new"
const saveDraft = (sessionID?: string) => {
const key = draftKey(sessionID)
if (
!store.prompt.text &&
store.prompt.pasted.length === 0 &&
(store.prompt.files?.length ?? 0) === 0 &&
(store.prompt.agents?.length ?? 0) === 0
) {
drafts.delete(key)
return
}
drafts.set(key, { prompt: structuredClone(unwrap(store.prompt)), cursor: input.cursorOffset })
}
const { currentSyntax: syntax } = useThemes()
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
Expand Down Expand Up @@ -561,28 +575,44 @@ export function Prompt(props: PromptProps) {
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
drafts.delete(draftKey(props.sessionID))
},
submit() {
void submit()
},
}

onMount(() => {
const saved = stashed
stashed = undefined
void history.load(props.sessionID)
const saved = drafts.get(draftKey(props.sessionID))
if (store.prompt.text) return
if (saved && saved.prompt.text) {
if (saved) {
input.setText(saved.prompt.text)
setStore("prompt", saved.prompt)
restoreExtmarksFromPrompt(saved.prompt)
input.cursorOffset = saved.cursor
}
})

createEffect(
on(
() => props.sessionID,
(sessionID, previous) => {
saveDraft(previous)
const saved = drafts.get(draftKey(sessionID))
input.clear()
input.extmarks.clear()
setStore("prompt", saved?.prompt ?? emptyPrompt())
restoreExtmarksFromPrompt(saved?.prompt ?? emptyPrompt())
input.cursorOffset = saved?.cursor ?? 0
void history.load(sessionID)
},
{ defer: true },
),
)

onCleanup(() => {
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
saveDraft(props.sessionID)
setInputTarget(undefined)
props.ref?.(undefined)
})
Expand Down Expand Up @@ -854,7 +884,7 @@ export function Prompt(props: PromptProps) {
return
}

const item = history.move(-1, input.plainText)
const item = history.move(props.sessionID, -1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
Expand Down Expand Up @@ -893,7 +923,7 @@ export function Prompt(props: PromptProps) {
return
}

const item = history.move(1, input.plainText)
const item = history.move(props.sessionID, 1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
Expand Down Expand Up @@ -1117,13 +1147,14 @@ export function Prompt(props: PromptProps) {
}
if (pendingEditorSelection) editor.markSelectionSent()
}
history.append({
history.append(sessionID, {
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
drafts.delete(draftKey(sessionID))
props.onSubmit?.()

// temporary hack to make sure the message is sent
Expand Down Expand Up @@ -1273,7 +1304,7 @@ export function Prompt(props: PromptProps) {
(store.prompt.files?.length ?? 0) > 0 ||
(store.prompt.agents?.length ?? 0) > 0
) {
history.append({
history.append(props.sessionID, {
...store.prompt,
mode: store.mode,
})
Expand All @@ -1282,6 +1313,7 @@ export function Prompt(props: PromptProps) {
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
drafts.delete(draftKey(props.sessionID))
}

const highlight = createMemo(() => {
Expand Down
88 changes: 49 additions & 39 deletions packages/tui/src/prompt/history.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import path from "path"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import { unwrap } from "solid-js/store"
import type { SessionPromptInput } from "@opencode-ai/client"
import type { Types } from "effect"
import { createSimpleContext } from "../context/helper"
Expand Down Expand Up @@ -61,56 +60,67 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
name: "PromptHistory",
init: () => {
const paths = useTuiPaths()
const historyPath = path.join(paths.state, "prompt-history.jsonl")
onMount(async () => {
const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
setStore("history", lines)

// Rewrite valid retained entries to self-heal corruption and enforce the limit.
if (lines.length > 0)
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
})

const [store, setStore] = createStore({
index: 0,
history: [] as PromptInfo[],
})
const stores = new Map<string, { index: number; history: PromptInfo[] }>()
const loaded = new Set<string>()
const key = (sessionID?: string) => sessionID ?? "new"
const historyPath = (sessionID?: string) =>
path.join(paths.state, "prompt-history", encodeURIComponent(key(sessionID)) + ".jsonl")
const store = (sessionID?: string) => {
const id = key(sessionID)
const current = stores.get(id)
if (current) return current
const next = { index: 0, history: [] as PromptInfo[] }
stores.set(id, next)
return next
}

return {
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
async load(sessionID?: string) {
const id = key(sessionID)
if (loaded.has(id)) return
loaded.add(id)
const lines = parsePromptHistory(await readText(historyPath(sessionID)).catch(() => ""))
const current = stores.get(id)
const history = [...lines, ...(current?.history ?? [])]
.filter((entry, index, entries) => !isDuplicateEntry(entries[index - 1], entry))
.slice(-MAX_HISTORY_ENTRIES)
stores.set(id, { index: current?.index ?? 0, history })
if (lines.length > 0)
writeText(historyPath(sessionID), history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(
() => {},
)
},
move(sessionID: string | undefined, direction: 1 | -1, input: string) {
const state = store(sessionID)
if (!state.history.length) return undefined
const current = state.history.at(state.index)
if (!current) return undefined
if (current.text !== input && input.length) return
const next = store.index + direction
if (Math.abs(next) > store.history.length || next > 0) return
setStore("index", next)
const next = state.index + direction
if (Math.abs(next) > state.history.length || next > 0) return
state.index = next
if (next === 0) return emptyPrompt()
return store.history.at(next)
return state.history.at(next)
},
append(item: PromptInfo) {
append(sessionID: string | undefined, item: PromptInfo) {
const state = store(sessionID)
const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(store.history.at(-1), entry)) {
setStore("index", 0)
if (isDuplicateEntry(state.history.at(-1), entry)) {
state.index = 0
return
}
let trimmed = false
setStore(
produce((draft) => {
draft.history.push(entry)
if (draft.history.length > MAX_HISTORY_ENTRIES) {
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
trimmed = true
}
draft.index = 0
}),
)
state.history.push(entry)
const trimmed = state.history.length > MAX_HISTORY_ENTRIES
if (trimmed) state.history = state.history.slice(-MAX_HISTORY_ENTRIES)
state.index = 0

if (trimmed) {
writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
writeText(historyPath(sessionID), state.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(
() => {},
)
return
}
appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
appendText(historyPath(sessionID), JSON.stringify(entry) + "\n").catch(() => {})
},
}
},
Expand Down
12 changes: 8 additions & 4 deletions packages/tui/test/prompt/history-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ test("down rejects at the newest history item with an empty prompt", async () =>
))
try {
await app.renderOnce()
history!.append({ text: "previous", files: [], agents: [], pasted: [] })
history!.append("ses_one", { text: "previous", files: [], agents: [], pasted: [] })

expect(history!.move(1, "")).toBeUndefined()
expect(history!.move(-1, "")?.text).toBe("previous")
expect(history!.move(1, "previous")?.text).toBe("")
expect(history!.move("ses_one", 1, "")).toBeUndefined()
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
expect(history!.move("ses_one", 1, "previous")?.text).toBe("")

history!.append("ses_two", { text: "other", files: [], agents: [], pasted: [] })
expect(history!.move("ses_two", -1, "")?.text).toBe("other")
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
} finally {
app.renderer.destroy()
}
Expand Down
Loading