fix(tui): scope prompt state to sessions
This commit is contained in:
parent
e872bd3c8f
commit
a631ce4f47
3 changed files with 100 additions and 54 deletions
|
|
@ -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`
|
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[]) {
|
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
|
||||||
const head = parseSlashHead(input, /\s/)
|
const head = parseSlashHead(input, /\s/)
|
||||||
|
|
@ -172,6 +172,20 @@ export function Prompt(props: PromptProps) {
|
||||||
const exit = useExit()
|
const exit = useExit()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const theme = useTheme()
|
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 { currentSyntax: syntax } = useThemes()
|
||||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||||
|
|
@ -561,6 +575,7 @@ export function Prompt(props: PromptProps) {
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("prompt", emptyPrompt())
|
setStore("prompt", emptyPrompt())
|
||||||
setStore("extmarkToPart", new Map())
|
setStore("extmarkToPart", new Map())
|
||||||
|
drafts.delete(draftKey(props.sessionID))
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
void submit()
|
void submit()
|
||||||
|
|
@ -568,10 +583,10 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const saved = stashed
|
void history.load(props.sessionID)
|
||||||
stashed = undefined
|
const saved = drafts.get(draftKey(props.sessionID))
|
||||||
if (store.prompt.text) return
|
if (store.prompt.text) return
|
||||||
if (saved && saved.prompt.text) {
|
if (saved) {
|
||||||
input.setText(saved.prompt.text)
|
input.setText(saved.prompt.text)
|
||||||
setStore("prompt", saved.prompt)
|
setStore("prompt", saved.prompt)
|
||||||
restoreExtmarksFromPrompt(saved.prompt)
|
restoreExtmarksFromPrompt(saved.prompt)
|
||||||
|
|
@ -579,10 +594,25 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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(() => {
|
onCleanup(() => {
|
||||||
if (store.prompt.text) {
|
saveDraft(props.sessionID)
|
||||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
|
||||||
}
|
|
||||||
setInputTarget(undefined)
|
setInputTarget(undefined)
|
||||||
props.ref?.(undefined)
|
props.ref?.(undefined)
|
||||||
})
|
})
|
||||||
|
|
@ -854,7 +884,7 @@ export function Prompt(props: PromptProps) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = history.move(-1, input.plainText)
|
const item = history.move(props.sessionID, -1, input.plainText)
|
||||||
if (!item) return false
|
if (!item) return false
|
||||||
input.setText(item.text)
|
input.setText(item.text)
|
||||||
setStore("prompt", item)
|
setStore("prompt", item)
|
||||||
|
|
@ -893,7 +923,7 @@ export function Prompt(props: PromptProps) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = history.move(1, input.plainText)
|
const item = history.move(props.sessionID, 1, input.plainText)
|
||||||
if (!item) return false
|
if (!item) return false
|
||||||
input.setText(item.text)
|
input.setText(item.text)
|
||||||
setStore("prompt", item)
|
setStore("prompt", item)
|
||||||
|
|
@ -1117,13 +1147,14 @@ export function Prompt(props: PromptProps) {
|
||||||
}
|
}
|
||||||
if (pendingEditorSelection) editor.markSelectionSent()
|
if (pendingEditorSelection) editor.markSelectionSent()
|
||||||
}
|
}
|
||||||
history.append({
|
history.append(sessionID, {
|
||||||
...store.prompt,
|
...store.prompt,
|
||||||
mode: currentMode,
|
mode: currentMode,
|
||||||
})
|
})
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("prompt", emptyPrompt())
|
setStore("prompt", emptyPrompt())
|
||||||
setStore("extmarkToPart", new Map())
|
setStore("extmarkToPart", new Map())
|
||||||
|
drafts.delete(draftKey(sessionID))
|
||||||
props.onSubmit?.()
|
props.onSubmit?.()
|
||||||
|
|
||||||
// temporary hack to make sure the message is sent
|
// temporary hack to make sure the message is sent
|
||||||
|
|
@ -1273,7 +1304,7 @@ export function Prompt(props: PromptProps) {
|
||||||
(store.prompt.files?.length ?? 0) > 0 ||
|
(store.prompt.files?.length ?? 0) > 0 ||
|
||||||
(store.prompt.agents?.length ?? 0) > 0
|
(store.prompt.agents?.length ?? 0) > 0
|
||||||
) {
|
) {
|
||||||
history.append({
|
history.append(props.sessionID, {
|
||||||
...store.prompt,
|
...store.prompt,
|
||||||
mode: store.mode,
|
mode: store.mode,
|
||||||
})
|
})
|
||||||
|
|
@ -1282,6 +1313,7 @@ export function Prompt(props: PromptProps) {
|
||||||
input.extmarks.clear()
|
input.extmarks.clear()
|
||||||
setStore("prompt", emptyPrompt())
|
setStore("prompt", emptyPrompt())
|
||||||
setStore("extmarkToPart", new Map())
|
setStore("extmarkToPart", new Map())
|
||||||
|
drafts.delete(draftKey(props.sessionID))
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlight = createMemo(() => {
|
const highlight = createMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { onMount } from "solid-js"
|
import { unwrap } from "solid-js/store"
|
||||||
import { createStore, produce, unwrap } from "solid-js/store"
|
|
||||||
import type { SessionPromptInput } from "@opencode-ai/client"
|
import type { SessionPromptInput } from "@opencode-ai/client"
|
||||||
import type { Types } from "effect"
|
import type { Types } from "effect"
|
||||||
import { createSimpleContext } from "../context/helper"
|
import { createSimpleContext } from "../context/helper"
|
||||||
|
|
@ -61,56 +60,67 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||||
name: "PromptHistory",
|
name: "PromptHistory",
|
||||||
init: () => {
|
init: () => {
|
||||||
const paths = useTuiPaths()
|
const paths = useTuiPaths()
|
||||||
const historyPath = path.join(paths.state, "prompt-history.jsonl")
|
const stores = new Map<string, { index: number; history: PromptInfo[] }>()
|
||||||
onMount(async () => {
|
const loaded = new Set<string>()
|
||||||
const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
|
const key = (sessionID?: string) => sessionID ?? "new"
|
||||||
setStore("history", lines)
|
const historyPath = (sessionID?: string) =>
|
||||||
|
path.join(paths.state, "prompt-history", encodeURIComponent(key(sessionID)) + ".jsonl")
|
||||||
// Rewrite valid retained entries to self-heal corruption and enforce the limit.
|
const store = (sessionID?: string) => {
|
||||||
if (lines.length > 0)
|
const id = key(sessionID)
|
||||||
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
const current = stores.get(id)
|
||||||
})
|
if (current) return current
|
||||||
|
const next = { index: 0, history: [] as PromptInfo[] }
|
||||||
const [store, setStore] = createStore({
|
stores.set(id, next)
|
||||||
index: 0,
|
return next
|
||||||
history: [] as PromptInfo[],
|
}
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
move(direction: 1 | -1, input: string) {
|
async load(sessionID?: string) {
|
||||||
if (!store.history.length) return undefined
|
const id = key(sessionID)
|
||||||
const current = store.history.at(store.index)
|
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) return undefined
|
||||||
if (current.text !== input && input.length) return
|
if (current.text !== input && input.length) return
|
||||||
const next = store.index + direction
|
const next = state.index + direction
|
||||||
if (Math.abs(next) > store.history.length || next > 0) return
|
if (Math.abs(next) > state.history.length || next > 0) return
|
||||||
setStore("index", next)
|
state.index = next
|
||||||
if (next === 0) return emptyPrompt()
|
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))
|
const entry = structuredClone(unwrap(item))
|
||||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
if (isDuplicateEntry(state.history.at(-1), entry)) {
|
||||||
setStore("index", 0)
|
state.index = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let trimmed = false
|
state.history.push(entry)
|
||||||
setStore(
|
const trimmed = state.history.length > MAX_HISTORY_ENTRIES
|
||||||
produce((draft) => {
|
if (trimmed) state.history = state.history.slice(-MAX_HISTORY_ENTRIES)
|
||||||
draft.history.push(entry)
|
state.index = 0
|
||||||
if (draft.history.length > MAX_HISTORY_ENTRIES) {
|
|
||||||
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
|
|
||||||
trimmed = true
|
|
||||||
}
|
|
||||||
draft.index = 0
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (trimmed) {
|
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
|
return
|
||||||
}
|
}
|
||||||
appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
|
appendText(historyPath(sessionID), JSON.stringify(entry) + "\n").catch(() => {})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,15 @@ test("down rejects at the newest history item with an empty prompt", async () =>
|
||||||
))
|
))
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
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("ses_one", 1, "")).toBeUndefined()
|
||||||
expect(history!.move(-1, "")?.text).toBe("previous")
|
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
|
||||||
expect(history!.move(1, "previous")?.text).toBe("")
|
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 {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue