termcast 1.3.53 → 1.4.0

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 (196) hide show
  1. package/dist/action-utils.d.ts.map +1 -1
  2. package/dist/action-utils.js +17 -132
  3. package/dist/action-utils.js.map +1 -1
  4. package/dist/apis/cache.d.ts +8 -30
  5. package/dist/apis/cache.d.ts.map +1 -1
  6. package/dist/apis/cache.js +9 -271
  7. package/dist/apis/cache.js.map +1 -1
  8. package/dist/apis/clipboard.d.ts +4 -2
  9. package/dist/apis/clipboard.d.ts.map +1 -1
  10. package/dist/apis/clipboard.js +18 -31
  11. package/dist/apis/clipboard.js.map +1 -1
  12. package/dist/apis/environment.d.ts.map +1 -1
  13. package/dist/apis/environment.js +14 -49
  14. package/dist/apis/environment.js.map +1 -1
  15. package/dist/apis/localstorage.d.ts +7 -12
  16. package/dist/apis/localstorage.d.ts.map +1 -1
  17. package/dist/apis/localstorage.js +7 -184
  18. package/dist/apis/localstorage.js.map +1 -1
  19. package/dist/app.d.ts.map +1 -1
  20. package/dist/app.js +46 -20
  21. package/dist/app.js.map +1 -1
  22. package/dist/cli.js +7 -6
  23. package/dist/cli.js.map +1 -1
  24. package/dist/components/actions.d.ts.map +1 -1
  25. package/dist/components/actions.js +13 -2
  26. package/dist/components/actions.js.map +1 -1
  27. package/dist/components/candle-chart.d.ts +110 -0
  28. package/dist/components/candle-chart.d.ts.map +1 -0
  29. package/dist/components/candle-chart.js +295 -0
  30. package/dist/components/candle-chart.js.map +1 -0
  31. package/dist/components/extension-preferences.d.ts.map +1 -1
  32. package/dist/components/extension-preferences.js +7 -8
  33. package/dist/components/extension-preferences.js.map +1 -1
  34. package/dist/components/form/file-autocomplete.js +2 -2
  35. package/dist/components/form/file-autocomplete.js.map +1 -1
  36. package/dist/components/list.d.ts.map +1 -1
  37. package/dist/components/list.js +242 -14
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/table.d.ts +2 -0
  40. package/dist/components/table.d.ts.map +1 -1
  41. package/dist/components/table.js +41 -4
  42. package/dist/components/table.js.map +1 -1
  43. package/dist/e2e-node.d.ts.map +1 -1
  44. package/dist/e2e-node.js +5 -4
  45. package/dist/e2e-node.js.map +1 -1
  46. package/dist/examples/simple-candle-chart-data.d.ts +9064 -0
  47. package/dist/examples/simple-candle-chart-data.d.ts.map +1 -0
  48. package/dist/examples/simple-candle-chart-data.js +12683 -0
  49. package/dist/examples/simple-candle-chart-data.js.map +1 -0
  50. package/dist/examples/simple-candle-chart.d.ts +2 -0
  51. package/dist/examples/simple-candle-chart.d.ts.map +1 -0
  52. package/dist/examples/simple-candle-chart.js +125 -0
  53. package/dist/examples/simple-candle-chart.js.map +1 -0
  54. package/dist/extensions/dev.d.ts.map +1 -1
  55. package/dist/extensions/dev.js +5 -2
  56. package/dist/extensions/dev.js.map +1 -1
  57. package/dist/globals.d.ts.map +1 -1
  58. package/dist/globals.js +2 -1
  59. package/dist/globals.js.map +1 -1
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +2 -0
  63. package/dist/index.js.map +1 -1
  64. package/dist/internal/error-handler.d.ts.map +1 -1
  65. package/dist/internal/error-handler.js +21 -19
  66. package/dist/internal/error-handler.js.map +1 -1
  67. package/dist/internal/providers.d.ts.map +1 -1
  68. package/dist/internal/providers.js +41 -1
  69. package/dist/internal/providers.js.map +1 -1
  70. package/dist/logger.d.ts.map +1 -1
  71. package/dist/logger.js +31 -29
  72. package/dist/logger.js.map +1 -1
  73. package/dist/platform/browser/cache.d.ts +41 -0
  74. package/dist/platform/browser/cache.d.ts.map +1 -0
  75. package/dist/platform/browser/cache.js +262 -0
  76. package/dist/platform/browser/cache.js.map +1 -0
  77. package/dist/platform/browser/localstorage.d.ts +20 -0
  78. package/dist/platform/browser/localstorage.d.ts.map +1 -0
  79. package/dist/platform/browser/localstorage.js +102 -0
  80. package/dist/platform/browser/localstorage.js.map +1 -0
  81. package/dist/platform/browser/runtime.d.ts +51 -0
  82. package/dist/platform/browser/runtime.d.ts.map +1 -0
  83. package/dist/platform/browser/runtime.js +164 -0
  84. package/dist/platform/browser/runtime.js.map +1 -0
  85. package/dist/platform/bun/sqlite.d.ts +17 -0
  86. package/dist/platform/bun/sqlite.d.ts.map +1 -0
  87. package/dist/platform/bun/sqlite.js +6 -0
  88. package/dist/platform/bun/sqlite.js.map +1 -0
  89. package/dist/platform/node/cache.d.ts +35 -0
  90. package/dist/platform/node/cache.d.ts.map +1 -0
  91. package/dist/platform/node/cache.js +269 -0
  92. package/dist/platform/node/cache.js.map +1 -0
  93. package/dist/platform/node/localstorage.d.ts +17 -0
  94. package/dist/platform/node/localstorage.d.ts.map +1 -0
  95. package/dist/platform/node/localstorage.js +186 -0
  96. package/dist/platform/node/localstorage.js.map +1 -0
  97. package/dist/platform/node/runtime.d.ts +52 -0
  98. package/dist/platform/node/runtime.d.ts.map +1 -0
  99. package/dist/platform/node/runtime.js +230 -0
  100. package/dist/platform/node/runtime.js.map +1 -0
  101. package/dist/platform/node/sqlite.d.ts +27 -0
  102. package/dist/platform/node/sqlite.d.ts.map +1 -0
  103. package/dist/platform/node/sqlite.js +21 -0
  104. package/dist/platform/node/sqlite.js.map +1 -0
  105. package/dist/state.d.ts +5 -0
  106. package/dist/state.d.ts.map +1 -1
  107. package/dist/state.js +6 -28
  108. package/dist/state.js.map +1 -1
  109. package/dist/utils/file-system.d.ts.map +1 -1
  110. package/dist/utils/file-system.js +17 -22
  111. package/dist/utils/file-system.js.map +1 -1
  112. package/dist/utils.d.ts +1 -1
  113. package/dist/utils.d.ts.map +1 -1
  114. package/dist/utils.js +42 -47
  115. package/dist/utils.js.map +1 -1
  116. package/dist/vim-mode.d.ts +40 -0
  117. package/dist/vim-mode.d.ts.map +1 -0
  118. package/dist/vim-mode.js +135 -0
  119. package/dist/vim-mode.js.map +1 -0
  120. package/fonts/Inconsolata.otf +0 -0
  121. package/fonts/SIL Open Font License.txt +41 -0
  122. package/package.json +60 -8
  123. package/src/action-utils.tsx +27 -124
  124. package/src/apis/cache.test.ts +1 -1
  125. package/src/apis/cache.tsx +9 -373
  126. package/src/apis/clipboard.tsx +29 -38
  127. package/src/apis/environment.tsx +25 -52
  128. package/src/apis/localstorage.tsx +8 -214
  129. package/src/app.tsx +51 -20
  130. package/src/cli.tsx +14 -15
  131. package/src/compile.vitest.tsx +2 -2
  132. package/src/components/actions.tsx +19 -1
  133. package/src/components/candle-chart.tsx +410 -0
  134. package/src/components/extension-preferences.tsx +7 -8
  135. package/src/components/form/file-autocomplete.tsx +2 -2
  136. package/src/components/list.tsx +279 -14
  137. package/src/components/table.tsx +46 -4
  138. package/src/e2e-node.tsx +7 -7
  139. package/src/examples/action-shortcut.vitest.tsx +2 -2
  140. package/src/examples/actions-context.vitest.tsx +1 -1
  141. package/src/examples/bar-graph-weekly.vitest.tsx +10 -36
  142. package/src/examples/detail-metadata-showcase.vitest.tsx +36 -36
  143. package/src/examples/form-basic.vitest.tsx +21 -17
  144. package/src/examples/github.vitest.tsx +4 -4
  145. package/src/examples/graph-bar-chart.vitest.tsx +13 -11
  146. package/src/examples/graph-polymarket.vitest.tsx +2 -2
  147. package/src/examples/graph-row.vitest.tsx +66 -66
  148. package/src/examples/graph-styles.vitest.tsx +12 -12
  149. package/src/examples/internal/simple-scrollbox.vitest.tsx +14 -48
  150. package/src/examples/list-detail-metadata.vitest.tsx +5 -5
  151. package/src/examples/list-fetch-data.vitest.tsx +3 -3
  152. package/src/examples/list-item-accessories.vitest.tsx +2 -2
  153. package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
  154. package/src/examples/list-no-actions.vitest.tsx +2 -2
  155. package/src/examples/list-scrollbox.vitest.tsx +5 -5
  156. package/src/examples/list-spacing-mode.vitest.tsx +3 -3
  157. package/src/examples/list-with-detail.vitest.tsx +68 -68
  158. package/src/examples/list-with-dropdown.vitest.tsx +5 -5
  159. package/src/examples/list-with-sections.vitest.tsx +27 -27
  160. package/src/examples/simple-candle-chart-data.ts +12683 -0
  161. package/src/examples/simple-candle-chart.tsx +363 -0
  162. package/src/examples/simple-candle-chart.vitest.tsx +269 -0
  163. package/src/examples/simple-detail-markdown.vitest.tsx +8 -8
  164. package/src/examples/simple-detail-table.vitest.tsx +10 -10
  165. package/src/examples/simple-graph.vitest.tsx +3 -3
  166. package/src/examples/simple-grid.vitest.tsx +14 -14
  167. package/src/examples/simple-heatmap.vitest.tsx +1 -1
  168. package/src/examples/simple-navigation.vitest.tsx +17 -17
  169. package/src/examples/simple-progress-bar.vitest.tsx +1 -1
  170. package/src/examples/simple-table-wrap.vitest.tsx +19 -19
  171. package/src/examples/store.vitest.tsx +1 -1
  172. package/src/examples/swift-extension.vitest.tsx +2 -2
  173. package/src/examples/table-edge-cases.vitest.tsx +18 -18
  174. package/src/examples/table-flex-grow.vitest.tsx +8 -8
  175. package/src/examples/toast-action.vitest.tsx +2 -2
  176. package/src/extensions/dev.tsx +5 -2
  177. package/src/extensions/dev.vitest.tsx +3 -3
  178. package/src/globals.ts +2 -1
  179. package/src/index.tsx +7 -0
  180. package/src/internal/error-handler.tsx +19 -21
  181. package/src/internal/providers.tsx +39 -0
  182. package/src/logger.tsx +38 -41
  183. package/src/platform/browser/cache.ts +327 -0
  184. package/src/platform/browser/localstorage.ts +119 -0
  185. package/src/platform/browser/runtime.ts +209 -0
  186. package/src/platform/bun/sqlite.ts +19 -0
  187. package/src/platform/node/cache.ts +372 -0
  188. package/src/platform/node/localstorage.ts +214 -0
  189. package/src/platform/node/runtime.ts +264 -0
  190. package/src/platform/node/sqlite.ts +43 -0
  191. package/src/state.tsx +17 -28
  192. package/src/utils/file-system.ts +17 -22
  193. package/src/utils.test.tsx +1 -1
  194. package/src/utils.tsx +56 -47
  195. package/src/vim-mode.tsx +153 -0
  196. package/src/apis/sqlite.ts +0 -14
@@ -40,18 +40,18 @@ test('flexGrow table fills remaining space next to fixed-width label', async ()
40
40
 
41
41
  With flexGrow=1 + wrapText (fills remaining space)
42
42
 
43
- Config: Key Value
44
- version 2.1.0
45
- license MIT
46
- author termcast
43
+ Config: Key Value
44
+ version 2.1.0
45
+ license MIT
46
+ author termcast
47
47
 
48
48
 
49
49
  Width=auto + wrapText (content-sized, no stretch)
50
50
 
51
- Config: KeVa
52
- ve2.
53
- liMI
54
- aute
51
+ Config: Key Value
52
+ version 2.1.0
53
+ license MIT
54
+ author termcast
55
55
 
56
56
 
57
57
  With flexGrow=1 no wrapText (column-based)
@@ -124,7 +124,7 @@ test('pressing escape hides the toast', async () => {
124
124
 
125
125
 
126
126
 
127
- ↵ show toast ↑↓ navigate ^k actions
127
+ ↵ show toast ↑↓ navigate ^k actions :vim
128
128
 
129
129
 
130
130
 
@@ -238,7 +238,7 @@ test('form toast: pressing enter triggers primary action (navigation)', async ()
238
238
 
239
239
 
240
240
 
241
- ↑↓ navigate ^k actions
241
+ ↑↓ navigate ^k actions :vim
242
242
 
243
243
 
244
244
 
@@ -15,6 +15,7 @@ import { TermcastProvider } from 'termcast/src/internal/providers'
15
15
  import { showToast, Toast } from 'termcast/src/apis/toast'
16
16
  import { Icon } from 'termcast'
17
17
  import { useTheme, initializeTheme } from 'termcast/src/theme'
18
+ import { initializeVimMode } from 'termcast/src/vim-mode'
18
19
  import { logger } from '../logger'
19
20
  import { getCommandsWithFiles, CommandWithFile, RaycastPackageJson } from '../package-json'
20
21
  import { buildExtensionCommands } from '../build'
@@ -226,8 +227,9 @@ export async function startDevMode({
226
227
  devRebuildCount: 1,
227
228
  })
228
229
 
229
- // Load theme after state reset — extensionPath is now set so it reads from the correct DB
230
+ // Load theme and vim mode after state reset — extensionPath is now set so it reads from the correct DB
230
231
  initializeTheme()
232
+ initializeVimMode()
231
233
 
232
234
  function App(): any {
233
235
  const devElement = useStore((state) => state.devElement)
@@ -299,8 +301,9 @@ export async function startCompiledExtension({
299
301
  devRebuildCount: 1,
300
302
  })
301
303
 
302
- // Load theme after state reset — extensionPath is now set so it reads from the correct DB
304
+ // Load theme and vim mode after state reset — extensionPath is now set so it reads from the correct DB
303
305
  initializeTheme()
306
+ initializeVimMode()
304
307
 
305
308
  function App(): any {
306
309
  const devElement = useStore((state) => state.devElement)
@@ -65,7 +65,7 @@ test('dev command shows extension commands list', async () => {
65
65
  Show State Shows the current application state in view
66
66
 
67
67
 
68
- ↵ run command ↑↓ navigate ^k actions
68
+ ↵ run command ↑↓ navigate ^k actions :vim
69
69
  "
70
70
  `)
71
71
  }, 30000)
@@ -101,7 +101,7 @@ test('selecting command with arguments shows arguments form', async () => {
101
101
 
102
102
 
103
103
 
104
- ↵ run command ↑↓ navigate ^k actions
104
+ ↵ run command ↑↓ navigate ^k actions :vim
105
105
  "
106
106
  `)
107
107
 
@@ -217,7 +217,7 @@ test('can fill arguments and run command', async () => {
217
217
 
218
218
 
219
219
 
220
- ↵ copy value ↑↓ navigate ^k actions
220
+ ↵ copy value ↑↓ navigate ^k actions :vim
221
221
  "
222
222
  `)
223
223
  }, 30000)
package/src/globals.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  // Set up global references for external packages
3
3
  //
4
4
  import { logger } from './logger'
5
+ import { setEnv } from '#platform/runtime'
5
6
 
6
- process.env.TERMCAST = 'true'
7
+ setEnv('TERMCAST', 'true')
7
8
 
8
9
  import * as opentuiCore from '@opentui/core'
9
10
  import * as opentuiReact from '@opentui/react'
package/src/index.tsx CHANGED
@@ -86,6 +86,13 @@ export type {
86
86
  BarGraphSeriesProps,
87
87
  } from 'termcast/src/components/bar-graph'
88
88
 
89
+ // Core UI Components - CandleChart
90
+ export { CandleChart } from 'termcast/src/components/candle-chart'
91
+ export type {
92
+ CandleChartProps,
93
+ CandleData,
94
+ } from 'termcast/src/components/candle-chart'
95
+
89
96
  // Core UI Components - CalendarHeatmap
90
97
  export { CalendarHeatmap, Heatmap } from 'termcast/src/components/heatmap'
91
98
  export type {
@@ -1,5 +1,6 @@
1
1
  import { showFailureToast } from 'termcast/src/apis/toast'
2
2
  import { logger } from 'termcast/src/logger'
3
+ import { setupErrorHandlers } from '#platform/runtime'
3
4
 
4
5
  let initialized = false
5
6
 
@@ -7,26 +8,23 @@ export function initializeErrorHandlers(): void {
7
8
  if (initialized) return
8
9
  initialized = true
9
10
 
10
- process.on('unhandledRejection', (reason) => {
11
- const err = reason instanceof Error ? reason : new Error(String(reason))
12
- logger.error('Unhandled rejection:', err)
13
- showFailureToast(err, {
14
- title: 'Unhandled Promise Rejection',
15
- }).catch((toastErr) => {
16
- logger.error('Failed to show toast for unhandled rejection:', toastErr)
17
- })
18
- })
19
-
20
- process.on('uncaughtException', (err) => {
21
- logger.error('Uncaught exception:', err)
22
- showFailureToast(err, {
23
- title: 'Uncaught Exception',
24
- }).catch((toastErr) => {
25
- logger.error('Failed to show toast for uncaught exception:', toastErr)
26
- })
27
- })
28
-
29
- process.on('uncaughtExceptionMonitor', (err, origin) => {
30
- logger.error(`Uncaught exception from ${origin}:`, err)
11
+ setupErrorHandlers((err, type) => {
12
+ if (type === 'unhandledRejection') {
13
+ logger.error('Unhandled rejection:', err)
14
+ showFailureToast(err, {
15
+ title: 'Unhandled Promise Rejection',
16
+ }).catch((toastErr) => {
17
+ logger.error('Failed to show toast for unhandled rejection:', toastErr)
18
+ })
19
+ } else if (type === 'uncaughtException') {
20
+ logger.error('Uncaught exception:', err)
21
+ showFailureToast(err, {
22
+ title: 'Uncaught Exception',
23
+ }).catch((toastErr) => {
24
+ logger.error('Failed to show toast for uncaught exception:', toastErr)
25
+ })
26
+ } else {
27
+ logger.error(`Uncaught exception from ${type}:`, err)
28
+ }
31
29
  })
32
30
  }
@@ -15,6 +15,7 @@ import { useTheme } from 'termcast/src/theme'
15
15
  import { useStore } from 'termcast/src/state'
16
16
  import { useKeyboard, useRenderer } from '@opentui/react'
17
17
  import { initializeErrorHandlers } from 'termcast/src/internal/error-handler'
18
+ import { stdoutWrite } from '#platform/runtime'
18
19
 
19
20
  import { InFocus } from './focus-context'
20
21
  import { Clipboard } from '../apis/clipboard'
@@ -150,6 +151,44 @@ export function TermcastProvider(props: ProvidersProps): any {
150
151
  const theme = useTheme()
151
152
  const renderer = useRenderer()
152
153
 
154
+ // TODO: Remove this when opentui adds { name: "backspace", super: true, action: "delete-to-line-start" }
155
+ // to defaultTextareaKeybindings in packages/core/src/renderables/Textarea.ts
156
+ // Translate Cmd+Backspace (kitty CSI \x1b[127;9u) to Ctrl+U (\x15) so opentui's
157
+ // textarea keybinding for delete-to-line-start handles it. opentui doesn't have a
158
+ // super+backspace binding, so we remap at the input level before key dispatch.
159
+ React.useLayoutEffect(() => {
160
+ if (!renderer) return
161
+ const handler = (sequence: string) => {
162
+ if (sequence === '\x1b[127;9u') {
163
+ renderer.stdin.emit('data', '\x15')
164
+ return true
165
+ }
166
+ return false
167
+ }
168
+ renderer.prependInputHandler(handler)
169
+ return () => {
170
+ renderer.removeInputHandler(handler)
171
+ }
172
+ }, [renderer])
173
+
174
+ // Sync terminal background with the active termcast theme via OSC 11 (standard escape
175
+ // sequence to set terminal background color). This works on WezTerm, iTerm2, kitty, etc.
176
+ // WezTerm's set_config_overrides for colors has a bug (#5451) where it only hot-reloads
177
+ // non-focused windows, so we use OSC 11 instead which updates immediately.
178
+ // Uses renderer's realStdoutWrite to bypass opentui's stdout interception.
179
+ React.useLayoutEffect(() => {
180
+ if (!renderer) return
181
+ // OSC 11 ; color ST — sets terminal default background color
182
+ const sequence = `\x1b]11;${theme.background}\x07`
183
+ const realWrite = (renderer as any).realStdoutWrite as typeof process.stdout.write | undefined
184
+ if (realWrite) {
185
+ // realStdoutWrite needs process.stdout as `this` context
186
+ realWrite.call(process.stdout, sequence)
187
+ } else {
188
+ stdoutWrite(sequence)
189
+ }
190
+ }, [renderer, theme.background])
191
+
153
192
  useKeyboard((key) => {
154
193
  if (!renderer) return
155
194
  if (key.ctrl && key.name === 'd') {
package/src/logger.tsx CHANGED
@@ -1,14 +1,19 @@
1
- import * as fs from 'fs'
2
- import * as path from 'path'
3
- import util from 'node:util'
1
+ import {
2
+ joinPath,
3
+ cwd,
4
+ unlinkIfExists,
5
+ appendToFile,
6
+ inspectValue,
7
+ getEnv,
8
+ exit,
9
+ setupErrorHandlers,
10
+ } from '#platform/runtime'
4
11
  import { useEffect } from 'react'
5
12
 
6
- const LOG_FILE = path.join(process.cwd(), 'app.log')
13
+ const LOG_FILE = joinPath(cwd(), 'app.log')
7
14
 
8
15
  // Delete log file on process start
9
- if (fs.existsSync(LOG_FILE)) {
10
- fs.unlinkSync(LOG_FILE)
11
- }
16
+ unlinkIfExists(LOG_FILE)
12
17
 
13
18
  function serialize(msg: any): string {
14
19
  if (msg instanceof Error) {
@@ -17,7 +22,7 @@ function serialize(msg: any): string {
17
22
  if (typeof msg === 'string') {
18
23
  return msg
19
24
  }
20
- return util.inspect(msg, { depth: 3 })
25
+ return inspectValue(msg, 3)
21
26
  }
22
27
 
23
28
  export const logger = {
@@ -25,21 +30,21 @@ export const logger = {
25
30
  const timestamp = new Date().toISOString()
26
31
  const formattedMessages = messages.map(serialize).join(' ')
27
32
  const logEntry = `[${timestamp}] ${formattedMessages}\n`
28
- fs.appendFileSync(LOG_FILE, logEntry)
33
+ appendToFile(LOG_FILE, logEntry)
29
34
  console.log(...messages)
30
35
  },
31
36
  error: (...messages: any[]) => {
32
37
  const timestamp = new Date().toISOString()
33
38
  const formattedMessages = messages.map(serialize).join(' ')
34
39
  const logEntry = `[${timestamp}] ERROR: ${formattedMessages}\n`
35
- fs.appendFileSync(LOG_FILE, logEntry)
40
+ appendToFile(LOG_FILE, logEntry)
36
41
  console.error(...messages)
37
42
  },
38
43
  warn: (...messages: any[]) => {
39
44
  const timestamp = new Date().toISOString()
40
45
  const formattedMessages = messages.map(serialize).join(' ')
41
46
  const logEntry = `[${timestamp}] WARN: ${formattedMessages}\n`
42
- fs.appendFileSync(LOG_FILE, logEntry)
47
+ appendToFile(LOG_FILE, logEntry)
43
48
  console.warn(...messages)
44
49
  },
45
50
  trace: (...messages: any[]) => {
@@ -54,41 +59,33 @@ export const logger = {
54
59
  }
55
60
  const formattedMessages = messages.map(serialize).join(' ')
56
61
  const logEntry = `[${timestamp}] TRACE: ${formattedMessages}\n${stack}\n`
57
- fs.appendFileSync(LOG_FILE, logEntry)
62
+ appendToFile(LOG_FILE, logEntry)
58
63
  console.trace(...messages)
59
64
  },
60
65
  }
61
66
 
62
67
  // Catch unhandled errors and exceptions
63
- process.on('uncaughtException', (error: Error) => {
64
- if (error instanceof Error) {
65
- logger.error('Uncaught Exception:', error.message, error.stack)
66
- } else {
67
- logger.error('Uncaught Exception:', serialize(error))
68
- }
69
- // In app mode, don't exit on uncaught exceptions — the error boundary
70
- // will catch React errors, and crashing the whole app is worse than
71
- // a broken screen the user can recover from.
72
- if (process.env.TERMCAST_APP_MODE !== '1') {
73
- process.exit(1)
74
- }
75
- })
76
-
77
- process.on('unhandledRejection', async (reason: any, promise: Promise<any>) => {
78
- if (reason instanceof Error) {
79
- logger.error(
80
- 'Unhandled Rejection at:',
81
- promise,
82
- 'reason:',
83
- reason.message,
84
- reason.stack,
85
- )
68
+ setupErrorHandlers((error, type) => {
69
+ if (type === 'uncaughtException') {
70
+ if (error instanceof Error) {
71
+ logger.error('Uncaught Exception:', error.message, error.stack)
72
+ } else {
73
+ logger.error('Uncaught Exception:', serialize(error))
74
+ }
75
+ // In app mode, don't exit on uncaught exceptions the error boundary
76
+ // will catch React errors, and crashing the whole app is worse than
77
+ // a broken screen the user can recover from.
78
+ if (getEnv('TERMCAST_APP_MODE') !== '1') {
79
+ exit(1)
80
+ }
81
+ } else if (type === 'unhandledRejection') {
82
+ if (error instanceof Error) {
83
+ logger.error('Unhandled Rejection:', error.message, error.stack)
84
+ } else {
85
+ logger.error('Unhandled Rejection:', serialize(error))
86
+ }
86
87
  } else {
87
- logger.error(
88
- 'Unhandled Rejection at:',
89
- promise,
90
- 'reason:',
91
- serialize(reason),
92
- )
88
+ // uncaughtExceptionMonitor
89
+ logger.error(`Uncaught exception from ${type}:`, error)
93
90
  }
94
91
  })
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Browser Cache implementation backed by IndexedDB with in-memory Map for sync reads.
3
+ *
4
+ * On construction, all entries for the namespace are loaded into a Map.
5
+ * Sync reads (get, has, isEmpty) hit the Map.
6
+ * Writes update both the Map and IndexedDB (fire-and-forget).
7
+ * LRU eviction is done in-memory.
8
+ */
9
+
10
+ import { byteLength } from '#platform/runtime'
11
+
12
+ const DB_NAME = 'termcast-cache'
13
+ const STORE_NAME = 'cache_entries'
14
+ const DB_VERSION = 1
15
+ const DEFAULT_NAMESPACE = '__default__'
16
+
17
+ interface CacheEntry {
18
+ namespace: string
19
+ key: string
20
+ data: string
21
+ size: number
22
+ last_accessed_at: number
23
+ updated_at: number
24
+ }
25
+
26
+ // Shared IDB connection — lazily opened, reused across Cache instances
27
+ let dbPromise: Promise<IDBDatabase> | null = null
28
+
29
+ function openDB(): Promise<IDBDatabase> {
30
+ if (dbPromise) return dbPromise
31
+
32
+ dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
33
+ const request = indexedDB.open(DB_NAME, DB_VERSION)
34
+
35
+ request.onupgradeneeded = () => {
36
+ const db = request.result
37
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
38
+ const store = db.createObjectStore(STORE_NAME, {
39
+ keyPath: ['namespace', 'key'],
40
+ })
41
+ store.createIndex('by_namespace', 'namespace', { unique: false })
42
+ store.createIndex('by_lru', ['namespace', 'last_accessed_at'], {
43
+ unique: false,
44
+ })
45
+ }
46
+ }
47
+
48
+ request.onsuccess = () => {
49
+ resolve(request.result)
50
+ }
51
+ request.onerror = () => {
52
+ reject(request.error)
53
+ }
54
+ })
55
+
56
+ return dbPromise
57
+ }
58
+
59
+ // Load all entries for a namespace into memory (called once per Cache instance)
60
+ async function loadNamespace(namespace: string): Promise<Map<string, CacheEntry>> {
61
+ const db = await openDB()
62
+ return new Promise((resolve, reject) => {
63
+ const tx = db.transaction(STORE_NAME, 'readonly')
64
+ const store = tx.objectStore(STORE_NAME)
65
+ const index = store.index('by_namespace')
66
+ const request = index.getAll(namespace)
67
+
68
+ request.onsuccess = () => {
69
+ const map = new Map<string, CacheEntry>()
70
+ for (const entry of request.result as CacheEntry[]) {
71
+ map.set(entry.key, entry)
72
+ }
73
+ resolve(map)
74
+ }
75
+ request.onerror = () => {
76
+ reject(request.error)
77
+ }
78
+ })
79
+ }
80
+
81
+ async function idbPut(entry: CacheEntry): Promise<void> {
82
+ const db = await openDB()
83
+ return new Promise((resolve, reject) => {
84
+ const tx = db.transaction(STORE_NAME, 'readwrite')
85
+ const store = tx.objectStore(STORE_NAME)
86
+ const request = store.put(entry)
87
+ request.onsuccess = () => {
88
+ resolve()
89
+ }
90
+ request.onerror = () => {
91
+ reject(request.error)
92
+ }
93
+ })
94
+ }
95
+
96
+ async function idbDelete(namespace: string, key: string): Promise<void> {
97
+ const db = await openDB()
98
+ return new Promise((resolve, reject) => {
99
+ const tx = db.transaction(STORE_NAME, 'readwrite')
100
+ const store = tx.objectStore(STORE_NAME)
101
+ const request = store.delete([namespace, key])
102
+ request.onsuccess = () => {
103
+ resolve()
104
+ }
105
+ request.onerror = () => {
106
+ reject(request.error)
107
+ }
108
+ })
109
+ }
110
+
111
+ async function idbClearNamespace(namespace: string): Promise<void> {
112
+ const db = await openDB()
113
+ return new Promise((resolve, reject) => {
114
+ const tx = db.transaction(STORE_NAME, 'readwrite')
115
+ const store = tx.objectStore(STORE_NAME)
116
+ const index = store.index('by_namespace')
117
+ const request = index.openCursor(namespace)
118
+
119
+ request.onsuccess = () => {
120
+ const cursor = request.result
121
+ if (cursor) {
122
+ cursor.delete()
123
+ cursor.continue()
124
+ } else {
125
+ resolve()
126
+ }
127
+ }
128
+ request.onerror = () => {
129
+ reject(request.error)
130
+ }
131
+ })
132
+ }
133
+
134
+ let logicalTimestamp = Date.now()
135
+ function nextTimestamp(): number {
136
+ logicalTimestamp += 1
137
+ return logicalTimestamp
138
+ }
139
+
140
+ export class Cache {
141
+ static get STORAGE_DIRECTORY_NAME(): string {
142
+ return 'cache'
143
+ }
144
+
145
+ static get DEFAULT_CAPACITY(): number {
146
+ return 10 * 1024 * 1024 // 10 MB
147
+ }
148
+
149
+ private entries: Map<string, CacheEntry> = new Map()
150
+ private capacity: number
151
+ private namespace: string
152
+ private subscribers: Cache.Subscriber[] = []
153
+ private currentSize: number = 0
154
+ private initialized: boolean = false
155
+
156
+ constructor(options?: Cache.Options) {
157
+ this.capacity = options?.capacity || Cache.DEFAULT_CAPACITY
158
+ this.namespace = options?.namespace || DEFAULT_NAMESPACE
159
+
160
+ // Kick off async load — sync reads return undefined until loaded
161
+ this.init()
162
+
163
+ // Bind all methods
164
+ this.get = this.get.bind(this)
165
+ this.has = this.has.bind(this)
166
+ this.set = this.set.bind(this)
167
+ this.remove = this.remove.bind(this)
168
+ this.clear = this.clear.bind(this)
169
+ this.subscribe = this.subscribe.bind(this)
170
+ }
171
+
172
+ private async init(): Promise<void> {
173
+ try {
174
+ const loaded = await loadNamespace(this.namespace)
175
+ // Merge: keep in-memory writes that happened during async load,
176
+ // preferring the newer entry (by updated_at) on key conflicts.
177
+ for (const [key, inMemoryEntry] of this.entries) {
178
+ const loadedEntry = loaded.get(key)
179
+ if (!loadedEntry || inMemoryEntry.updated_at >= loadedEntry.updated_at) {
180
+ loaded.set(key, inMemoryEntry)
181
+ }
182
+ }
183
+ this.entries = loaded
184
+ this.currentSize = 0
185
+ for (const entry of this.entries.values()) {
186
+ this.currentSize += entry.size
187
+ }
188
+ this.initialized = true
189
+ } catch {
190
+ // IndexedDB might not be available (e.g. incognito with restrictions)
191
+ this.initialized = true
192
+ }
193
+ }
194
+
195
+ get storageDirectory(): string {
196
+ return `/termcast/cache/${this.namespace}`
197
+ }
198
+
199
+ get(key: string): string | undefined {
200
+ const entry = this.entries.get(key)
201
+ if (!entry) return undefined
202
+
203
+ // Update LRU timestamp in-memory and async in IDB
204
+ const now = nextTimestamp()
205
+ entry.last_accessed_at = now
206
+ idbPut(entry).catch(() => {})
207
+
208
+ return entry.data
209
+ }
210
+
211
+ has(key: string): boolean {
212
+ return this.entries.has(key)
213
+ }
214
+
215
+ get isEmpty(): boolean {
216
+ return this.entries.size === 0
217
+ }
218
+
219
+ set(key: string, data: string): void {
220
+ const now = nextTimestamp()
221
+ const dataSize = byteLength(data)
222
+
223
+ const existing = this.entries.get(key)
224
+ const oldSize = existing?.size || 0
225
+ const newTotalSize = this.currentSize - oldSize + dataSize
226
+
227
+ if (newTotalSize > this.capacity) {
228
+ this.maintainCapacity(newTotalSize - this.capacity)
229
+ }
230
+
231
+ const entry: CacheEntry = {
232
+ namespace: this.namespace,
233
+ key,
234
+ data,
235
+ size: dataSize,
236
+ last_accessed_at: now,
237
+ updated_at: now,
238
+ }
239
+
240
+ this.entries.set(key, entry)
241
+ this.currentSize = this.currentSize - oldSize + dataSize
242
+
243
+ // Persist async
244
+ idbPut(entry).catch(() => {})
245
+
246
+ this.notifySubscribers(key, data)
247
+ }
248
+
249
+ remove(key: string): boolean {
250
+ const entry = this.entries.get(key)
251
+ if (!entry) return false
252
+
253
+ this.entries.delete(key)
254
+ this.currentSize -= entry.size
255
+
256
+ // Persist async
257
+ idbDelete(this.namespace, key).catch(() => {})
258
+
259
+ this.notifySubscribers(key, undefined)
260
+ return true
261
+ }
262
+
263
+ clear(options?: { notifySubscribers: boolean }): void {
264
+ this.entries.clear()
265
+ this.currentSize = 0
266
+
267
+ // Persist async
268
+ idbClearNamespace(this.namespace).catch(() => {})
269
+
270
+ if (options?.notifySubscribers !== false) {
271
+ this.notifySubscribers(undefined, undefined)
272
+ }
273
+ }
274
+
275
+ subscribe(subscriber: Cache.Subscriber): Cache.Subscription {
276
+ this.subscribers.push(subscriber)
277
+ return () => {
278
+ const index = this.subscribers.indexOf(subscriber)
279
+ if (index > -1) {
280
+ this.subscribers.splice(index, 1)
281
+ }
282
+ }
283
+ }
284
+
285
+ private maintainCapacity(bytesToFree: number): void {
286
+ // Sort by LRU — oldest first
287
+ const sorted = [...this.entries.values()].sort(
288
+ (a, b) => a.last_accessed_at - b.last_accessed_at,
289
+ )
290
+
291
+ let freedBytes = 0
292
+ for (const entry of sorted) {
293
+ if (freedBytes >= bytesToFree) break
294
+ this.entries.delete(entry.key)
295
+ freedBytes += entry.size
296
+ // Persist async
297
+ idbDelete(this.namespace, entry.key).catch(() => {})
298
+ }
299
+ this.currentSize -= freedBytes
300
+ }
301
+
302
+ private notifySubscribers(
303
+ key: string | undefined,
304
+ data: string | undefined,
305
+ ): void {
306
+ for (const subscriber of this.subscribers) {
307
+ try {
308
+ subscriber(key, data)
309
+ } catch {
310
+ // Ignore subscriber errors in browser
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ export namespace Cache {
317
+ export interface Options {
318
+ namespace?: string
319
+ capacity?: number
320
+ }
321
+
322
+ export type Subscriber = (
323
+ key: string | undefined,
324
+ data: string | undefined,
325
+ ) => void
326
+ export type Subscription = () => void
327
+ }