| 1 | // composerHistory provides ↑/↓ prompt-history navigation by reading a lazy |
| 2 | // backend "tape" of user prompts. The tape starts with the active session and |
| 3 | // then continues through the same session order used by the history sidebar. |
| 4 | // |
| 5 | // Unlike the old localStorage ring buffer, the session JSONL files are the |
| 6 | // canonical source of truth — we read them through the Go bound method |
| 7 | // ScanPromptHistory, which exposes cursor pages and invalidates the tape when |
| 8 | // sessions are created, deleted, or renamed. |
| 9 | // |
| 10 | // We don't try to do prefix-search navigation (Ctrl-R in bash) — that needs a |
| 11 | // search UI and a keybinding the OS doesn't already take. The arrow |
| 12 | // navigation is the common case and is the smallest useful addition. |
| 13 | |
| 14 | import { app } from "./bridge"; |
| 15 | import type { PromptHistoryEntry, PromptHistoryResult } from "./types"; |
| 16 | |
| 17 | // currentNonce identifies the backend tape. olderCursor points to the next |
| 18 | // unread segment; cachedEntries are only the entries the user has reached. |
| 19 | let currentNonce = ""; |
| 20 | let olderCursor = ""; |
| 21 | let hasOlder = true; |
| 22 | let cachedEntries: PromptHistoryEntry[] = []; |
| 23 | let generation = 0; |
| 24 | const PROMPT_HISTORY_PAGE_LIMIT = 50; |
| 25 | |
| 26 | type ScanPromptHistoryResultTuple = [PromptHistoryEntry[] | null, string]; |
| 27 | type ScanPromptHistoryResultTupleWithErr = [PromptHistoryEntry[] | null, string, unknown]; |
| 28 | type ScanPromptHistoryResult = |
| 29 | | PromptHistoryEntry[] |
| 30 | | PromptHistoryResult |
| 31 | | ScanPromptHistoryResultTuple |
| 32 | | ScanPromptHistoryResultTupleWithErr |
| 33 | | Record<string, unknown>; |
| 34 | |
| 35 | interface NormalizedPromptHistoryPage { |
| 36 | entries: PromptHistoryEntry[] | null; |
| 37 | nonce: string; |
| 38 | olderCursor: string; |
| 39 | hasOlder: boolean; |
| 40 | } |
| 41 | |
| 42 | function asEntries(value: unknown): PromptHistoryEntry[] { |
| 43 | return Array.isArray(value) ? (value as PromptHistoryEntry[]) : []; |
| 44 | } |
| 45 | |
| 46 | function maybeTupleResult(result: unknown): { entries: PromptHistoryEntry[] | null; nonce: string } | null { |
| 47 | if (!Array.isArray(result)) return null; |
| 48 | if (result.length >= 2 && typeof result[1] === "string") { |
| 49 | return { entries: result[0] === null ? null : asEntries(result[0]), nonce: result[1] }; |
| 50 | } |
| 51 | return null; |
| 52 | } |
| 53 | |
| 54 | function maybeTupleMapResult(result: unknown): { entries: PromptHistoryEntry[] | null; nonce: string } | null { |
| 55 | if (typeof result !== "object" || result === null) return null; |
| 56 | const map = result as Record<string, unknown>; |
| 57 | if (!("0" in map) || !("1" in map)) return null; |
| 58 | if (typeof map["1"] !== "string") return null; |
| 59 | return { entries: map["0"] === null ? null : asEntries(map["0"]), nonce: map["1"] }; |
| 60 | } |
| 61 | |
| 62 | function normalizePageResult(result: ScanPromptHistoryResult): NormalizedPromptHistoryPage { |
| 63 | const tuple = maybeTupleResult(result) ?? maybeTupleMapResult(result); |
| 64 | if (tuple !== null) { |
| 65 | return { entries: tuple.entries, nonce: tuple.nonce, olderCursor: "", hasOlder: false }; |
| 66 | } |
| 67 | |
| 68 | if (Array.isArray(result)) { |
| 69 | return { entries: asEntries(result), nonce: currentNonce, olderCursor: "", hasOlder: false }; |
| 70 | } |
| 71 | |
| 72 | if (typeof result === "object" && result !== null) { |
| 73 | if ("entries" in result) { |
| 74 | const rawEntries = (result as { entries?: unknown }).entries; |
| 75 | const nextNonce = typeof (result as { nonce?: unknown }).nonce === "string" |
| 76 | ? (result as { nonce: string }).nonce |
| 77 | : currentNonce; |
| 78 | const nextOlderCursor = typeof (result as { olderCursor?: unknown }).olderCursor === "string" |
| 79 | ? (result as { olderCursor: string }).olderCursor |
| 80 | : ""; |
| 81 | const nextHasOlder = typeof (result as { hasOlder?: unknown }).hasOlder === "boolean" |
| 82 | ? (result as { hasOlder: boolean }).hasOlder |
| 83 | : nextOlderCursor !== ""; |
| 84 | return { |
| 85 | entries: rawEntries === null || rawEntries === undefined ? null : asEntries(rawEntries), |
| 86 | nonce: nextNonce, |
| 87 | olderCursor: nextOlderCursor, |
| 88 | hasOlder: nextHasOlder, |
| 89 | }; |
| 90 | } |
| 91 | |
| 92 | if (typeof (result as { nonce?: unknown }).nonce === "string") { |
| 93 | return { entries: null, nonce: (result as { nonce: string }).nonce, olderCursor: "", hasOlder: false }; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | if (result === null || result === undefined) { |
| 98 | return { entries: [], nonce: currentNonce, olderCursor: "", hasOlder: false }; |
| 99 | } |
| 100 | return { entries: asEntries(result), nonce: currentNonce, olderCursor: "", hasOlder: false }; |
| 101 | } |
| 102 | |
| 103 | // invalidateCache resets the tape so the next loadOlder() call starts from the |
| 104 | // current active session. Call this after any session mutation. |
| 105 | export function invalidateCache(): void { |
| 106 | currentNonce = ""; |
| 107 | olderCursor = ""; |
| 108 | hasOlder = true; |
| 109 | cachedEntries = []; |
| 110 | generation++; |
| 111 | } |
| 112 | |
| 113 | export function cacheGeneration(): number { |
| 114 | return generation; |
| 115 | } |
| 116 | |
| 117 | export async function loadOlder(): Promise<PromptHistoryEntry[]> { |
| 118 | if (!hasOlder && olderCursor === "") return []; |
| 119 | try { |
| 120 | const request = JSON.stringify({ |
| 121 | nonce: currentNonce, |
| 122 | cursor: olderCursor, |
| 123 | limit: PROMPT_HISTORY_PAGE_LIMIT, |
| 124 | }); |
| 125 | const result = await app.ScanPromptHistory(request); |
| 126 | const page = normalizePageResult(result as ScanPromptHistoryResult); |
| 127 | currentNonce = page.nonce; |
| 128 | olderCursor = page.olderCursor; |
| 129 | hasOlder = page.hasOlder; |
| 130 | const entries = page.entries ?? []; |
| 131 | if (entries.length > 0) { |
| 132 | cachedEntries = cachedEntries.concat(entries); |
| 133 | } |
| 134 | return entries.slice(); |
| 135 | } catch { |
| 136 | return []; |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | export function hasMoreOlder(): boolean { |
| 141 | return hasOlder || olderCursor !== ""; |
| 142 | } |
| 143 | |
| 144 | // snapshot returns a defensive copy of the entries loaded so far. If nothing has |
| 145 | // been loaded yet, it fetches the first tape page for compatibility with older |
| 146 | // callers and tests. |
| 147 | export async function snapshot(): Promise<PromptHistoryEntry[]> { |
| 148 | if (cachedEntries.length === 0 && hasMoreOlder()) { |
| 149 | await loadOlder(); |
| 150 | } |
| 151 | return cachedEntries.slice(); |
| 152 | } |
| 153 | |
| 154 | // pushHistory is a no-op — prompts are persisted by the Go kernel as session |
| 155 | // JSONL files, so there's nothing local to append. |
| 156 | export function pushHistory(_text: string): void { |
| 157 | // no-op: prompts are recorded by the kernel |
| 158 | } |
| 159 | |
| 160 | // clearHistory is a no-op — session logs are the canonical store; there's |
| 161 | // nothing local to clear. A "clear prompt history" button would need to delete |
| 162 | // session files, which is a bigger operation. |
| 163 | export function clearHistory(): void { |
| 164 | // no-op: session logs are the canonical store |
| 165 | } |
| 166 |