mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 23:04:55 +00:00
257 lines
7.3 KiB
TypeScript
257 lines
7.3 KiB
TypeScript
import type { Prompt } from "@/context/prompt"
|
|
import type { SelectedLineRange } from "@/context/file"
|
|
|
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
|
|
|
export const MAX_HISTORY = 100
|
|
|
|
export type PromptHistoryComment = {
|
|
id: string
|
|
path: string
|
|
selection: SelectedLineRange
|
|
comment: string
|
|
time: number
|
|
origin?: "review" | "file"
|
|
preview?: string
|
|
}
|
|
|
|
export type PromptHistoryEntry = {
|
|
prompt: Prompt
|
|
comments: PromptHistoryComment[]
|
|
}
|
|
|
|
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
|
|
|
|
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
|
const position = Math.max(0, Math.min(cursor, text.length))
|
|
const atStart = position === 0
|
|
const atEnd = position === text.length
|
|
if (inHistory) return atStart || atEnd
|
|
if (direction === "up") return position === 0 && text.length === 0
|
|
return position === text.length
|
|
}
|
|
|
|
export function clonePromptParts(prompt: Prompt): Prompt {
|
|
return prompt.map((part) => {
|
|
if (part.type === "text") return { ...part }
|
|
if (part.type === "image") return { ...part }
|
|
if (part.type === "agent") return { ...part }
|
|
return {
|
|
...part,
|
|
selection: part.selection ? { ...part.selection } : undefined,
|
|
}
|
|
})
|
|
}
|
|
|
|
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
|
return {
|
|
start: selection.start,
|
|
end: selection.end,
|
|
...(selection.side ? { side: selection.side } : {}),
|
|
...(selection.endSide ? { endSide: selection.endSide } : {}),
|
|
}
|
|
}
|
|
|
|
export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
|
return comments.map((comment) => ({
|
|
...comment,
|
|
selection: cloneSelection(comment.selection),
|
|
}))
|
|
}
|
|
|
|
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
|
if (Array.isArray(entry)) {
|
|
return {
|
|
prompt: clonePromptParts(entry),
|
|
comments: [],
|
|
}
|
|
}
|
|
return {
|
|
prompt: clonePromptParts(entry.prompt),
|
|
comments: clonePromptHistoryComments(entry.comments),
|
|
}
|
|
}
|
|
|
|
export function promptLength(prompt: Prompt) {
|
|
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
|
}
|
|
|
|
export function prependHistoryEntry(
|
|
entries: PromptHistoryStoredEntry[],
|
|
prompt: Prompt,
|
|
comments: PromptHistoryComment[] = [],
|
|
max = MAX_HISTORY,
|
|
) {
|
|
const text = prompt
|
|
.map((part) => ("content" in part ? part.content : ""))
|
|
.join("")
|
|
.trim()
|
|
const hasImages = prompt.some((part) => part.type === "image")
|
|
const hasComments = comments.some((comment) => !!comment.comment.trim())
|
|
if (!text && !hasImages && !hasComments) return entries
|
|
|
|
const entry = {
|
|
prompt: clonePromptParts(prompt),
|
|
comments: clonePromptHistoryComments(comments),
|
|
} satisfies PromptHistoryEntry
|
|
const last = entries[0]
|
|
if (last && isPromptEqual(last, entry)) return entries
|
|
return [entry, ...entries].slice(0, max)
|
|
}
|
|
|
|
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
|
|
return (
|
|
commentA.path === commentB.path &&
|
|
commentA.comment === commentB.comment &&
|
|
commentA.origin === commentB.origin &&
|
|
commentA.preview === commentB.preview &&
|
|
commentA.selection.start === commentB.selection.start &&
|
|
commentA.selection.end === commentB.selection.end &&
|
|
commentA.selection.side === commentB.selection.side &&
|
|
commentA.selection.endSide === commentB.selection.endSide
|
|
)
|
|
}
|
|
|
|
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
|
|
const entryA = normalizePromptHistoryEntry(promptA)
|
|
const entryB = normalizePromptHistoryEntry(promptB)
|
|
if (entryA.prompt.length !== entryB.prompt.length) return false
|
|
for (let i = 0; i < entryA.prompt.length; i++) {
|
|
const partA = entryA.prompt[i]
|
|
const partB = entryB.prompt[i]
|
|
if (partA.type !== partB.type) return false
|
|
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
|
|
if (partA.type === "file") {
|
|
if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
|
|
const a = partA.selection
|
|
const b = partB.type === "file" ? partB.selection : undefined
|
|
const sameSelection =
|
|
(!a && !b) ||
|
|
(!!a &&
|
|
!!b &&
|
|
a.startLine === b.startLine &&
|
|
a.startChar === b.startChar &&
|
|
a.endLine === b.endLine &&
|
|
a.endChar === b.endChar)
|
|
if (!sameSelection) return false
|
|
}
|
|
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
|
|
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
|
}
|
|
if (entryA.comments.length !== entryB.comments.length) return false
|
|
for (let i = 0; i < entryA.comments.length; i++) {
|
|
const commentA = entryA.comments[i]
|
|
const commentB = entryB.comments[i]
|
|
if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
type HistoryNavInput = {
|
|
direction: "up" | "down"
|
|
entries: PromptHistoryStoredEntry[]
|
|
historyIndex: number
|
|
currentPrompt: Prompt
|
|
currentComments: PromptHistoryComment[]
|
|
savedPrompt: PromptHistoryEntry | null
|
|
}
|
|
|
|
type HistoryNavResult =
|
|
| {
|
|
handled: false
|
|
historyIndex: number
|
|
savedPrompt: PromptHistoryEntry | null
|
|
}
|
|
| {
|
|
handled: true
|
|
historyIndex: number
|
|
savedPrompt: PromptHistoryEntry | null
|
|
entry: PromptHistoryEntry
|
|
cursor: "start" | "end"
|
|
}
|
|
|
|
export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
|
|
if (input.direction === "up") {
|
|
if (input.entries.length === 0) {
|
|
return {
|
|
handled: false,
|
|
historyIndex: input.historyIndex,
|
|
savedPrompt: input.savedPrompt,
|
|
}
|
|
}
|
|
|
|
if (input.historyIndex === -1) {
|
|
const entry = normalizePromptHistoryEntry(input.entries[0])
|
|
return {
|
|
handled: true,
|
|
historyIndex: 0,
|
|
savedPrompt: {
|
|
prompt: clonePromptParts(input.currentPrompt),
|
|
comments: clonePromptHistoryComments(input.currentComments),
|
|
},
|
|
entry,
|
|
cursor: "start",
|
|
}
|
|
}
|
|
|
|
if (input.historyIndex < input.entries.length - 1) {
|
|
const next = input.historyIndex + 1
|
|
const entry = normalizePromptHistoryEntry(input.entries[next])
|
|
return {
|
|
handled: true,
|
|
historyIndex: next,
|
|
savedPrompt: input.savedPrompt,
|
|
entry,
|
|
cursor: "start",
|
|
}
|
|
}
|
|
|
|
return {
|
|
handled: false,
|
|
historyIndex: input.historyIndex,
|
|
savedPrompt: input.savedPrompt,
|
|
}
|
|
}
|
|
|
|
if (input.historyIndex > 0) {
|
|
const next = input.historyIndex - 1
|
|
const entry = normalizePromptHistoryEntry(input.entries[next])
|
|
return {
|
|
handled: true,
|
|
historyIndex: next,
|
|
savedPrompt: input.savedPrompt,
|
|
entry,
|
|
cursor: "end",
|
|
}
|
|
}
|
|
|
|
if (input.historyIndex === 0) {
|
|
if (input.savedPrompt) {
|
|
return {
|
|
handled: true,
|
|
historyIndex: -1,
|
|
savedPrompt: null,
|
|
entry: input.savedPrompt,
|
|
cursor: "end",
|
|
}
|
|
}
|
|
|
|
return {
|
|
handled: true,
|
|
historyIndex: -1,
|
|
savedPrompt: null,
|
|
entry: {
|
|
prompt: DEFAULT_PROMPT,
|
|
comments: [],
|
|
},
|
|
cursor: "end",
|
|
}
|
|
}
|
|
|
|
return {
|
|
handled: false,
|
|
historyIndex: input.historyIndex,
|
|
savedPrompt: input.savedPrompt,
|
|
}
|
|
}
|