@oh-my-pi/pi-tui 18.4.0 → 18.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +24 -3
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/rewind-selector.d.ts +4 -3
  8. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/composer-cache.d.ts +34 -16
  11. package/dist/types/prompt/composer.d.ts +15 -18
  12. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  13. package/dist/types/prompt/welcome.d.ts +5 -2
  14. package/dist/types/render/width-aware-text.d.ts +6 -0
  15. package/dist/types/status-line/component.d.ts +13 -4
  16. package/dist/types/status-line/metrics.d.ts +0 -1
  17. package/dist/types/status-line/startup.d.ts +38 -0
  18. package/dist/types/status-line/types.d.ts +0 -2
  19. package/dist/types/terminal-capabilities.d.ts +10 -6
  20. package/dist/types/terminal.d.ts +7 -0
  21. package/dist/types/theme/color.d.ts +1 -0
  22. package/dist/types/theme/session-color.d.ts +3 -3
  23. package/dist/types/theme/shimmer.d.ts +1 -1
  24. package/dist/types/theme/theme-class.d.ts +1 -1
  25. package/dist/types/tools/output-meta.d.ts +2 -1
  26. package/dist/types/tools/vibe.d.ts +4 -0
  27. package/dist/types/tui.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/chat/assistant-message.ts +27 -21
  30. package/src/chat/image-loading.ts +81 -0
  31. package/src/chat/tool-execution.ts +53 -17
  32. package/src/chat/transcript-entry.ts +16 -0
  33. package/src/chrome/transcript-container.ts +282 -86
  34. package/src/components/editor.ts +28 -6
  35. package/src/components/image.ts +25 -9
  36. package/src/components/loader.ts +37 -17
  37. package/src/overlays/agent-hub.ts +16 -6
  38. package/src/overlays/agents-hub.ts +7 -18
  39. package/src/overlays/copy-selector.ts +8 -31
  40. package/src/overlays/model-browser.ts +106 -25
  41. package/src/overlays/model-hub.ts +49 -16
  42. package/src/overlays/rewind-selector.ts +60 -13
  43. package/src/overlays/usage-dashboard.ts +47 -6
  44. package/src/prompt/composer-attachments.ts +54 -7
  45. package/src/prompt/composer-cache.ts +217 -196
  46. package/src/prompt/composer.ts +45 -72
  47. package/src/prompt/custom-editor.ts +24 -6
  48. package/src/prompt/model-mention-autocomplete.ts +22 -10
  49. package/src/prompt/welcome.ts +31 -28
  50. package/src/render/width-aware-text.ts +9 -0
  51. package/src/status-line/component.ts +286 -182
  52. package/src/status-line/metrics.ts +3 -21
  53. package/src/status-line/segments.ts +68 -79
  54. package/src/status-line/startup.ts +181 -0
  55. package/src/status-line/types.ts +0 -2
  56. package/src/terminal-capabilities.ts +12 -6
  57. package/src/terminal.ts +75 -20
  58. package/src/theme/color.ts +15 -0
  59. package/src/theme/session-color.ts +26 -4
  60. package/src/theme/shimmer.ts +55 -76
  61. package/src/theme/theme-class.ts +29 -12
  62. package/src/tools/output-meta.ts +11 -3
  63. package/src/tools/vibe.ts +15 -2
  64. package/src/tui.ts +208 -154
  65. package/src/utils.ts +15 -0
@@ -1,14 +1,57 @@
1
+ /**
2
+ * Speculative composer state for the next first frame, kept in one SQLite store
3
+ * (`~/.omp/agent/cache/composer.db`).
4
+ *
5
+ * Each row is one JSON payload keyed by project (the resolved cwd) and kind.
6
+ * Settings-derived kinds (theme/composer preferences, welcome model labels,
7
+ * status-bar inputs) are also written under the empty project, so a folder that
8
+ * never ran omp still paints with the user's theme and status bar: those are
9
+ * rarely project-specific, and path/branch render live. Recent sessions and LSP
10
+ * rows stay per project.
11
+ *
12
+ * ```text
13
+ * entries (project TEXT, kind TEXT, value TEXT JSON, PRIMARY KEY (project, kind))
14
+ * ```
15
+ *
16
+ * Payload formats are versioned by `PRAGMA user_version`; a mismatch clears the
17
+ * store, which only ever holds speculation.
18
+ */
19
+ import type { Database, Statement } from "bun:sqlite";
1
20
  import * as fs from "node:fs";
2
21
  import * as path from "node:path";
3
- import "@oh-my-pi/pi-utils/env";
4
- import { getComposerCacheDir } from "@oh-my-pi/pi-utils/dirs";
22
+ import { getComposerCacheDbPath } from "@oh-my-pi/pi-utils/dirs";
23
+ import { isBunTestRuntime } from "@oh-my-pi/pi-utils/env";
24
+ import * as logger from "@oh-my-pi/pi-utils/logger";
25
+ import * as postmortem from "@oh-my-pi/pi-utils/postmortem";
26
+ import { openSqliteDatabaseSync } from "@oh-my-pi/pi-utils/sqlite";
27
+ import { isRecord } from "@oh-my-pi/pi-utils/type-guards";
5
28
  import type { LspServerInfo, RecentSession } from "./welcome";
6
- import type { ComposerPreferences, ComposerStatusSnapshot } from "./composer";
29
+ import type { ComposerPreferences, ComposerStatusCache } from "./composer";
30
+ import { readStatusLineStartupData } from "../status-line/startup";
7
31
  import type { SymbolPreset } from "../theme/theme";
8
32
  import { isWordCompletionMethod } from "./word-completion";
9
33
 
10
- const CACHE_VERSION = 1;
11
- const STATUS_CACHE_VERSION = 3;
34
+ /** Bump whenever any payload format changes; older stores are cleared on open. */
35
+ const FORMAT_VERSION = 1;
36
+ /** Project key of rows that serve every project lacking its own. */
37
+ const ANY_PROJECT = "";
38
+ const RECENT_SESSION_LIMIT = 4;
39
+
40
+ const SCHEMA = `
41
+ PRAGMA journal_mode=WAL;
42
+ PRAGMA synchronous=NORMAL;
43
+ CREATE TABLE IF NOT EXISTS entries (
44
+ project TEXT NOT NULL,
45
+ kind TEXT NOT NULL,
46
+ value TEXT NOT NULL,
47
+ PRIMARY KEY (project, kind)
48
+ ) WITHOUT ROWID;
49
+ `;
50
+
51
+ type EntryKind = "ui" | "welcome" | "recent-sessions" | "lsp-servers" | "status";
52
+ /** Kinds mirrored under {@link ANY_PROJECT} as the fallback for projects without their own row. */
53
+ type SharedEntryKind = "ui" | "welcome" | "status";
54
+
12
55
  /** Theme inputs cached from the last resolved settings load for stable prepaint colors. */
13
56
  export interface ComposerThemePreferences {
14
57
  readonly symbolPreset?: SymbolPreset;
@@ -29,72 +72,44 @@ export interface ComposerStartupCache {
29
72
  readonly theme?: ComposerThemePreferences;
30
73
  readonly welcome?: ComposerWelcomeCache;
31
74
  readonly recentSessions: RecentSession[];
32
- readonly lspServers: LspServerInfo[];
33
- readonly status?: ComposerStatusSnapshot;
34
- }
35
-
36
- function projectCacheDir(cwd: string): string {
37
- const key = Bun.hash.wyhash(path.resolve(cwd)).toString(16).padStart(16, "0");
38
- return path.join(getComposerCacheDir(), key);
75
+ /** `null` when the last run had LSP disabled. */
76
+ readonly lspServers: LspServerInfo[] | null;
77
+ readonly status?: ComposerStatusCache;
39
78
  }
40
79
 
41
- function readFile(file: string): string | undefined {
80
+ function parseJson(value: string | undefined): unknown {
81
+ if (value === undefined) return undefined;
42
82
  try {
43
- return fs.readFileSync(file, "utf8");
83
+ return JSON.parse(value);
44
84
  } catch {
45
85
  return undefined;
46
86
  }
47
87
  }
48
88
 
49
- function field(value: object, key: string): unknown {
50
- return Reflect.get(value, key);
51
- }
52
-
53
- function readRecentSessions(file: string): RecentSession[] {
54
- const content = readFile(file);
55
- if (!content) return [];
56
- let parsed: unknown;
57
- try {
58
- parsed = Bun.JSONL.parse(content);
59
- } catch {
60
- return [];
61
- }
62
- if (!Array.isArray(parsed)) return [];
89
+ function parseRecentSessions(value: unknown): RecentSession[] {
90
+ if (!Array.isArray(value)) return [];
63
91
  const sessions: RecentSession[] = [];
64
- for (const value of parsed) {
65
- if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
66
- const name = field(value, "name");
67
- const timeAgo = field(value, "timeAgo");
92
+ for (const item of value) {
93
+ if (!isRecord(item)) continue;
94
+ const { name, timeAgo } = item;
68
95
  if (typeof name === "string" && typeof timeAgo === "string") sessions.push({ name, timeAgo });
69
- if (sessions.length === 4) break;
96
+ if (sessions.length === RECENT_SESSION_LIMIT) break;
70
97
  }
71
98
  return sessions;
72
99
  }
73
100
 
74
- function readLspServers(file: string): LspServerInfo[] {
75
- const content = readFile(file);
76
- if (!content) return [];
77
- let parsed: unknown;
78
- try {
79
- parsed = JSON.parse(content);
80
- } catch {
81
- return [];
82
- }
83
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
84
- if (field(parsed, "version") !== CACHE_VERSION) return [];
85
- const values = field(parsed, "servers");
86
- if (!Array.isArray(values)) return [];
101
+ function parseLspServers(value: unknown): LspServerInfo[] | null {
102
+ if (value === null) return null;
103
+ if (!Array.isArray(value)) return [];
87
104
  const servers: LspServerInfo[] = [];
88
- for (const value of values) {
89
- if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
90
- const name = field(value, "name");
91
- const status = field(value, "status");
92
- const fileTypes = field(value, "fileTypes");
105
+ for (const item of value) {
106
+ if (!isRecord(item)) continue;
107
+ const { name, status, fileTypes } = item;
93
108
  if (
94
109
  typeof name !== "string" ||
95
110
  (status !== "ready" && status !== "error" && status !== "connecting" && status !== "available") ||
96
111
  !Array.isArray(fileTypes) ||
97
- !fileTypes.every(item => typeof item === "string")
112
+ !fileTypes.every(fileType => typeof fileType === "string")
98
113
  ) {
99
114
  continue;
100
115
  }
@@ -103,96 +118,40 @@ function readLspServers(file: string): LspServerInfo[] {
103
118
  return servers;
104
119
  }
105
120
 
106
- function readWelcome(file: string): ComposerWelcomeCache | undefined {
107
- const content = readFile(file);
108
- if (!content) return undefined;
109
- let parsed: unknown;
110
- try {
111
- parsed = JSON.parse(content);
112
- } catch {
113
- return undefined;
114
- }
115
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
116
- if (field(parsed, "version") !== CACHE_VERSION) return undefined;
117
- const modelName = field(parsed, "modelName");
118
- const providerName = field(parsed, "providerName");
121
+ function parseWelcome(value: unknown): ComposerWelcomeCache | undefined {
122
+ if (!isRecord(value)) return undefined;
123
+ const { modelName, providerName } = value;
119
124
  return typeof modelName === "string" && typeof providerName === "string" ? { modelName, providerName } : undefined;
120
125
  }
121
126
 
122
- function readStatus(file: string): ComposerStatusSnapshot | undefined {
123
- const content = readFile(file);
124
- if (!content) return undefined;
125
- let parsed: unknown;
126
- try {
127
- parsed = JSON.parse(content);
128
- } catch {
129
- return undefined;
130
- }
131
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
132
- if (field(parsed, "version") !== CACHE_VERSION) return undefined;
133
- if (field(parsed, "statusVersion") !== STATUS_CACHE_VERSION) return undefined;
134
- const shape = field(parsed, "shape");
135
- const rawBorderColor = field(parsed, "borderColor");
136
- const rawTopBorder = field(parsed, "topBorder");
137
- const bottomLines = field(parsed, "bottomLines");
138
- if (
139
- typeof shape !== "string" ||
140
- !Array.isArray(bottomLines) ||
141
- !bottomLines.every(line => typeof line === "string")
142
- ) {
143
- return undefined;
144
- }
145
- let borderColor: ComposerStatusSnapshot["borderColor"];
146
- if (rawBorderColor !== undefined) {
147
- if (typeof rawBorderColor !== "object" || rawBorderColor === null || Array.isArray(rawBorderColor)) {
148
- return undefined;
149
- }
150
- const prefix = field(rawBorderColor, "prefix");
151
- const suffix = field(rawBorderColor, "suffix");
152
- if (typeof prefix !== "string" || typeof suffix !== "string") return undefined;
153
- borderColor = { prefix, suffix };
154
- }
155
- if (rawTopBorder === undefined) return { shape, borderColor, bottomLines };
156
- if (typeof rawTopBorder !== "object" || rawTopBorder === null || Array.isArray(rawTopBorder)) return undefined;
157
- const borderContent = field(rawTopBorder, "content");
158
- const borderWidth = field(rawTopBorder, "width");
159
- if (typeof borderContent !== "string" || typeof borderWidth !== "number") return undefined;
160
- return { shape, borderColor, topBorder: { content: borderContent, width: borderWidth }, bottomLines };
127
+ function parseStatus(value: unknown): ComposerStatusCache | undefined {
128
+ if (!isRecord(value)) return undefined;
129
+ const statusLine = readStatusLineStartupData(value.statusLine);
130
+ if (!statusLine) return undefined;
131
+ const rawBorderColor = value.borderColor;
132
+ if (rawBorderColor === undefined) return { statusLine };
133
+ if (!isRecord(rawBorderColor)) return undefined;
134
+ const { prefix, suffix } = rawBorderColor;
135
+ if (typeof prefix !== "string" || typeof suffix !== "string") return undefined;
136
+ return { borderColor: { prefix, suffix }, statusLine };
161
137
  }
162
138
 
163
- function readUiState(file: string): { preferences: ComposerPreferences; theme: ComposerThemePreferences } | undefined {
164
- const content = readFile(file);
165
- if (!content) return undefined;
166
- let parsed: unknown;
167
- try {
168
- parsed = JSON.parse(content);
169
- } catch {
170
- return undefined;
171
- }
172
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
173
- if (field(parsed, "version") !== CACHE_VERSION) return undefined;
174
- const rawPreferences = field(parsed, "preferences");
175
- const rawTheme = field(parsed, "theme");
176
- if (
177
- typeof rawPreferences !== "object" ||
178
- rawPreferences === null ||
179
- Array.isArray(rawPreferences) ||
180
- typeof rawTheme !== "object" ||
181
- rawTheme === null ||
182
- Array.isArray(rawTheme)
183
- ) {
184
- return undefined;
185
- }
186
- const quiet = field(rawPreferences, "quiet");
187
- const composerShape = field(rawPreferences, "composerShape");
188
- const showHardwareCursor = field(rawPreferences, "showHardwareCursor");
189
- const maxInlineImages = field(rawPreferences, "maxInlineImages");
190
- const resizeScrollback = field(rawPreferences, "resizeScrollback");
191
- const imeSafeCursor = field(rawPreferences, "imeSafeCursor");
192
- const autocompleteMaxVisible = field(rawPreferences, "autocompleteMaxVisible");
193
- const spellingTypoDetection = field(rawPreferences, "spellingTypoDetection");
194
- const spellingAutocomplete = field(rawPreferences, "spellingAutocomplete");
195
- const spellingAutocorrect = field(rawPreferences, "spellingAutocorrect");
139
+ function parseUiState(
140
+ value: unknown,
141
+ ): { preferences: ComposerPreferences; theme: ComposerThemePreferences } | undefined {
142
+ if (!isRecord(value) || !isRecord(value.preferences) || !isRecord(value.theme)) return undefined;
143
+ const {
144
+ quiet,
145
+ composerShape,
146
+ showHardwareCursor,
147
+ maxInlineImages,
148
+ resizeScrollback,
149
+ imeSafeCursor,
150
+ autocompleteMaxVisible,
151
+ spellingTypoDetection,
152
+ spellingAutocomplete,
153
+ spellingAutocorrect,
154
+ } = value.preferences;
196
155
  if (
197
156
  typeof quiet !== "boolean" ||
198
157
  typeof composerShape !== "string" ||
@@ -210,10 +169,7 @@ function readUiState(file: string): { preferences: ComposerPreferences; theme: C
210
169
  ) {
211
170
  return undefined;
212
171
  }
213
- const symbolPreset = field(rawTheme, "symbolPreset");
214
- const colorBlindMode = field(rawTheme, "colorBlindMode");
215
- const darkTheme = field(rawTheme, "darkTheme");
216
- const lightTheme = field(rawTheme, "lightTheme");
172
+ const { symbolPreset, colorBlindMode, darkTheme, lightTheme } = value.theme;
217
173
  if (
218
174
  (symbolPreset !== undefined &&
219
175
  symbolPreset !== "unicode" &&
@@ -231,10 +187,7 @@ function readUiState(file: string): { preferences: ComposerPreferences; theme: C
231
187
  composerShape,
232
188
  showHardwareCursor,
233
189
  maxInlineImages,
234
- resizeScrollback:
235
- resizeScrollback === "append" || resizeScrollback === "rebuild" || resizeScrollback === "preserve"
236
- ? resizeScrollback
237
- : "rebuild",
190
+ resizeScrollback: resizeScrollback ?? "rebuild",
238
191
  imeSafeCursor,
239
192
  autocompleteMaxVisible,
240
193
  spellingTypoDetection,
@@ -245,61 +198,129 @@ function readUiState(file: string): { preferences: ComposerPreferences; theme: C
245
198
  };
246
199
  }
247
200
 
248
- /** Read all speculative composer caches synchronously before the first terminal paint. */
249
- export function readComposerStartupCache(cwd: string): ComposerStartupCache {
250
- const dir = projectCacheDir(cwd);
251
- const ui = readUiState(path.join(dir, "ui.json"));
252
- return {
253
- preferences: ui?.preferences,
254
- theme: ui?.theme,
255
- welcome: readWelcome(path.join(dir, "welcome.json")),
256
- recentSessions: readRecentSessions(path.join(dir, "recent-sessions.jsonl")),
257
- lspServers: readLspServers(path.join(dir, "lsp-servers.json")),
258
- status: readStatus(path.join(dir, "status.json")),
259
- };
260
- }
201
+ let shared: ComposerCache | null | undefined;
261
202
 
262
- /** Persist resolved theme and composer settings for the next prepaint. */
263
- export async function writeComposerUiCache(
264
- cwd: string,
265
- preferences: ComposerPreferences,
266
- theme: ComposerThemePreferences,
267
- ): Promise<void> {
268
- await Bun.write(
269
- path.join(projectCacheDir(cwd), "ui.json"),
270
- JSON.stringify({ version: CACHE_VERSION, preferences, theme }),
271
- );
203
+ /**
204
+ * Process-wide store at {@link getComposerCacheDbPath}, opened on first use and
205
+ * closed at exit. `undefined` when it cannot be opened (logged once; startup
206
+ * paints without speculation) and under the test runner, so tests never read
207
+ * or clobber the user's cache; tests open {@link ComposerCache.open} explicitly.
208
+ */
209
+ export function sharedComposerCache(): ComposerCache | undefined {
210
+ if (isBunTestRuntime()) return undefined;
211
+ if (shared === undefined) {
212
+ try {
213
+ const cache = ComposerCache.open();
214
+ postmortem.register("composer-cache", () => cache.close(), { exitOnly: true });
215
+ shared = cache;
216
+ } catch (error) {
217
+ logger.debug("composer cache unavailable", { error: String(error) });
218
+ shared = null;
219
+ }
220
+ }
221
+ return shared ?? undefined;
272
222
  }
273
223
 
274
- /** Persist authoritative model/provider labels for the next welcome prepaint. */
275
- export async function writeComposerWelcomeCache(cwd: string, welcome: ComposerWelcomeCache): Promise<void> {
276
- await Bun.write(
277
- path.join(projectCacheDir(cwd), "welcome.json"),
278
- JSON.stringify({ version: CACHE_VERSION, ...welcome }),
279
- );
280
- }
224
+ /** SQLite store of the composer state the next launch paints before its session exists. */
225
+ export class ComposerCache {
226
+ readonly #db: Database;
227
+ readonly #select: Statement<{ project: string; kind: EntryKind; value: string }, [string, string]>;
228
+ readonly #upsert: Statement<unknown, [string, EntryKind, string]>;
281
229
 
282
- /** Persist placeholder-only status chrome for speculative first-frame rendering. */
283
- export async function writeComposerStatusCache(cwd: string, status: ComposerStatusSnapshot): Promise<void> {
284
- await Bun.write(
285
- path.join(projectCacheDir(cwd), "status.json"),
286
- JSON.stringify({ version: CACHE_VERSION, statusVersion: STATUS_CACHE_VERSION, ...status }),
287
- );
288
- }
230
+ private constructor(db: Database) {
231
+ this.#db = db;
232
+ db.run(SCHEMA);
233
+ const version = db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version;
234
+ if (version !== FORMAT_VERSION) {
235
+ db.run("DELETE FROM entries");
236
+ db.run(`PRAGMA user_version = ${FORMAT_VERSION}`);
237
+ }
238
+ this.#select = db.prepare("SELECT project, kind, value FROM entries WHERE project IN (?, ?)");
239
+ this.#upsert = db.prepare(
240
+ "INSERT INTO entries (project, kind, value) VALUES (?, ?, ?) ON CONFLICT (project, kind) DO UPDATE SET value = excluded.value",
241
+ );
242
+ }
289
243
 
290
- /** Persist the latest recent-session rows as a compact JSONL speculation cache. */
291
- export async function writeComposerRecentSessionsCache(cwd: string, sessions: readonly RecentSession[]): Promise<void> {
292
- const content = sessions
293
- .slice(0, 4)
294
- .map(session => JSON.stringify(session))
295
- .join("\n");
296
- await Bun.write(path.join(projectCacheDir(cwd), "recent-sessions.jsonl"), content ? `${content}\n` : "");
297
- }
244
+ /**
245
+ * Open (creating if needed) the store at `dbPath`, quarantining a corrupt one once.
246
+ * @throws when the directory or database cannot be created.
247
+ */
248
+ static open(dbPath: string = getComposerCacheDbPath()): ComposerCache {
249
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
250
+ return openSqliteDatabaseSync(dbPath, db => new ComposerCache(db), { recoverCorruption: true });
251
+ }
298
252
 
299
- /** Persist the latest detected project LSP rows for the next prepaint. */
300
- export async function writeComposerLspCache(cwd: string, servers: readonly LspServerInfo[]): Promise<void> {
301
- await Bun.write(
302
- path.join(projectCacheDir(cwd), "lsp-servers.json"),
303
- JSON.stringify({ version: CACHE_VERSION, servers }),
304
- );
253
+ /** Everything cached for `cwd`, with any-project rows as fallback for shared kinds. Never throws. */
254
+ read(cwd: string): ComposerStartupCache {
255
+ const project = path.resolve(cwd);
256
+ const own: Partial<Record<EntryKind, string>> = {};
257
+ const anyProject: Partial<Record<EntryKind, string>> = {};
258
+ try {
259
+ for (const row of this.#select.all(project, ANY_PROJECT)) {
260
+ (row.project === project ? own : anyProject)[row.kind] = row.value;
261
+ }
262
+ } catch (error) {
263
+ logger.debug("composer cache read failed", { error: String(error) });
264
+ }
265
+ const ui = parseUiState(parseJson(own.ui)) ?? parseUiState(parseJson(anyProject.ui));
266
+ return {
267
+ preferences: ui?.preferences,
268
+ theme: ui?.theme,
269
+ welcome: parseWelcome(parseJson(own.welcome)) ?? parseWelcome(parseJson(anyProject.welcome)),
270
+ recentSessions: parseRecentSessions(parseJson(own["recent-sessions"])),
271
+ lspServers: own["lsp-servers"] === undefined ? [] : parseLspServers(parseJson(own["lsp-servers"])),
272
+ status: parseStatus(parseJson(own.status)) ?? parseStatus(parseJson(anyProject.status)),
273
+ };
274
+ }
275
+
276
+ /** Resolved theme and composer settings for the next prepaint. */
277
+ writeUi(cwd: string, preferences: ComposerPreferences, theme: ComposerThemePreferences): void {
278
+ this.#putShared(cwd, "ui", { preferences, theme });
279
+ }
280
+
281
+ /** Authoritative model/provider labels for the next welcome prepaint. */
282
+ writeWelcome(cwd: string, welcome: ComposerWelcomeCache): void {
283
+ this.#putShared(cwd, "welcome", welcome);
284
+ }
285
+
286
+ /** The latest recent-session rows (first four). */
287
+ writeRecentSessions(cwd: string, sessions: readonly RecentSession[]): void {
288
+ this.#put(cwd, "recent-sessions", sessions.slice(0, RECENT_SESSION_LIMIT));
289
+ }
290
+
291
+ /** The latest detected project LSP rows; `null` records that LSP is disabled. */
292
+ writeLspServers(cwd: string, servers: readonly LspServerInfo[] | null): void {
293
+ this.#put(cwd, "lsp-servers", servers);
294
+ }
295
+
296
+ /** Status-bar inputs for the next prepaint's startup status line. */
297
+ writeStatus(cwd: string, status: ComposerStatusCache): void {
298
+ this.#putShared(cwd, "status", status);
299
+ }
300
+
301
+ close(): void {
302
+ this.#db.close();
303
+ }
304
+
305
+ /** Best-effort upsert: a failed write only costs the next launch its speculation. */
306
+ #put(cwd: string, kind: EntryKind, value: unknown): void {
307
+ try {
308
+ this.#upsert.run(path.resolve(cwd), kind, JSON.stringify(value));
309
+ } catch (error) {
310
+ logger.debug("composer cache write failed", { kind, error: String(error) });
311
+ }
312
+ }
313
+
314
+ /** {@link #put} for this project plus the any-project fallback row, atomically. */
315
+ #putShared(cwd: string, kind: SharedEntryKind, value: unknown): void {
316
+ const json = JSON.stringify(value);
317
+ try {
318
+ this.#db.transaction(() => {
319
+ this.#upsert.run(path.resolve(cwd), kind, json);
320
+ this.#upsert.run(ANY_PROJECT, kind, json);
321
+ })();
322
+ } catch (error) {
323
+ logger.debug("composer cache write failed", { kind, error: String(error) });
324
+ }
325
+ }
305
326
  }