@jonsoc/app 1.1.34

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 (139) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +51 -0
  3. package/bunfig.toml +2 -0
  4. package/e2e/context.spec.ts +45 -0
  5. package/e2e/file-open.spec.ts +23 -0
  6. package/e2e/file-viewer.spec.ts +35 -0
  7. package/e2e/fixtures.ts +40 -0
  8. package/e2e/home.spec.ts +21 -0
  9. package/e2e/model-picker.spec.ts +43 -0
  10. package/e2e/navigation.spec.ts +9 -0
  11. package/e2e/palette.spec.ts +15 -0
  12. package/e2e/prompt-mention.spec.ts +26 -0
  13. package/e2e/prompt-slash-open.spec.ts +22 -0
  14. package/e2e/prompt.spec.ts +62 -0
  15. package/e2e/session.spec.ts +21 -0
  16. package/e2e/settings.spec.ts +44 -0
  17. package/e2e/sidebar.spec.ts +21 -0
  18. package/e2e/terminal-init.spec.ts +25 -0
  19. package/e2e/terminal.spec.ts +16 -0
  20. package/e2e/tsconfig.json +8 -0
  21. package/e2e/utils.ts +38 -0
  22. package/happydom.ts +75 -0
  23. package/index.html +23 -0
  24. package/package.json +72 -0
  25. package/playwright.config.ts +43 -0
  26. package/public/_headers +17 -0
  27. package/public/apple-touch-icon-v3.png +1 -0
  28. package/public/apple-touch-icon.png +1 -0
  29. package/public/favicon-96x96-v3.png +1 -0
  30. package/public/favicon-96x96.png +1 -0
  31. package/public/favicon-v3.ico +1 -0
  32. package/public/favicon-v3.svg +1 -0
  33. package/public/favicon.ico +1 -0
  34. package/public/favicon.svg +1 -0
  35. package/public/oc-theme-preload.js +28 -0
  36. package/public/site.webmanifest +1 -0
  37. package/public/social-share-zen.png +1 -0
  38. package/public/social-share.png +1 -0
  39. package/public/web-app-manifest-192x192.png +1 -0
  40. package/public/web-app-manifest-512x512.png +1 -0
  41. package/script/e2e-local.ts +143 -0
  42. package/src/addons/serialize.test.ts +319 -0
  43. package/src/addons/serialize.ts +591 -0
  44. package/src/app.tsx +150 -0
  45. package/src/components/dialog-connect-provider.tsx +428 -0
  46. package/src/components/dialog-edit-project.tsx +259 -0
  47. package/src/components/dialog-fork.tsx +104 -0
  48. package/src/components/dialog-manage-models.tsx +59 -0
  49. package/src/components/dialog-select-directory.tsx +208 -0
  50. package/src/components/dialog-select-file.tsx +196 -0
  51. package/src/components/dialog-select-mcp.tsx +96 -0
  52. package/src/components/dialog-select-model-unpaid.tsx +130 -0
  53. package/src/components/dialog-select-model.tsx +162 -0
  54. package/src/components/dialog-select-provider.tsx +70 -0
  55. package/src/components/dialog-select-server.tsx +249 -0
  56. package/src/components/dialog-settings.tsx +112 -0
  57. package/src/components/file-tree.tsx +112 -0
  58. package/src/components/link.tsx +17 -0
  59. package/src/components/model-tooltip.tsx +91 -0
  60. package/src/components/prompt-input.tsx +2076 -0
  61. package/src/components/session/index.ts +5 -0
  62. package/src/components/session/session-context-tab.tsx +428 -0
  63. package/src/components/session/session-header.tsx +343 -0
  64. package/src/components/session/session-new-view.tsx +93 -0
  65. package/src/components/session/session-sortable-tab.tsx +56 -0
  66. package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
  67. package/src/components/session-context-usage.tsx +113 -0
  68. package/src/components/session-lsp-indicator.tsx +42 -0
  69. package/src/components/session-mcp-indicator.tsx +34 -0
  70. package/src/components/settings-agents.tsx +15 -0
  71. package/src/components/settings-commands.tsx +15 -0
  72. package/src/components/settings-general.tsx +306 -0
  73. package/src/components/settings-keybinds.tsx +437 -0
  74. package/src/components/settings-mcp.tsx +15 -0
  75. package/src/components/settings-models.tsx +15 -0
  76. package/src/components/settings-permissions.tsx +234 -0
  77. package/src/components/settings-providers.tsx +15 -0
  78. package/src/components/terminal.tsx +315 -0
  79. package/src/components/titlebar.tsx +156 -0
  80. package/src/context/command.tsx +308 -0
  81. package/src/context/comments.tsx +140 -0
  82. package/src/context/file.tsx +409 -0
  83. package/src/context/global-sdk.tsx +106 -0
  84. package/src/context/global-sync.tsx +898 -0
  85. package/src/context/language.tsx +161 -0
  86. package/src/context/layout-scroll.test.ts +73 -0
  87. package/src/context/layout-scroll.ts +118 -0
  88. package/src/context/layout.tsx +648 -0
  89. package/src/context/local.tsx +578 -0
  90. package/src/context/notification.tsx +173 -0
  91. package/src/context/permission.tsx +167 -0
  92. package/src/context/platform.tsx +59 -0
  93. package/src/context/prompt.tsx +245 -0
  94. package/src/context/sdk.tsx +48 -0
  95. package/src/context/server.tsx +214 -0
  96. package/src/context/settings.tsx +166 -0
  97. package/src/context/sync.tsx +320 -0
  98. package/src/context/terminal.tsx +267 -0
  99. package/src/custom-elements.d.ts +17 -0
  100. package/src/entry.tsx +76 -0
  101. package/src/env.d.ts +8 -0
  102. package/src/hooks/use-providers.ts +31 -0
  103. package/src/i18n/ar.ts +656 -0
  104. package/src/i18n/br.ts +667 -0
  105. package/src/i18n/da.ts +582 -0
  106. package/src/i18n/de.ts +591 -0
  107. package/src/i18n/en.ts +665 -0
  108. package/src/i18n/es.ts +585 -0
  109. package/src/i18n/fr.ts +592 -0
  110. package/src/i18n/ja.ts +579 -0
  111. package/src/i18n/ko.ts +580 -0
  112. package/src/i18n/no.ts +602 -0
  113. package/src/i18n/pl.ts +661 -0
  114. package/src/i18n/ru.ts +664 -0
  115. package/src/i18n/zh.ts +574 -0
  116. package/src/i18n/zht.ts +570 -0
  117. package/src/index.css +57 -0
  118. package/src/index.ts +2 -0
  119. package/src/pages/directory-layout.tsx +57 -0
  120. package/src/pages/error.tsx +290 -0
  121. package/src/pages/home.tsx +125 -0
  122. package/src/pages/layout.tsx +2599 -0
  123. package/src/pages/session.tsx +2505 -0
  124. package/src/sst-env.d.ts +10 -0
  125. package/src/utils/dom.ts +51 -0
  126. package/src/utils/id.ts +99 -0
  127. package/src/utils/index.ts +1 -0
  128. package/src/utils/perf.ts +135 -0
  129. package/src/utils/persist.ts +377 -0
  130. package/src/utils/prompt.ts +203 -0
  131. package/src/utils/same.ts +6 -0
  132. package/src/utils/solid-dnd.tsx +55 -0
  133. package/src/utils/sound.ts +110 -0
  134. package/src/utils/speech.ts +302 -0
  135. package/src/utils/worktree.ts +58 -0
  136. package/sst-env.d.ts +9 -0
  137. package/tsconfig.json +26 -0
  138. package/vite.config.ts +15 -0
  139. package/vite.js +26 -0
@@ -0,0 +1,203 @@
1
+ import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@jonsoc/sdk/v2"
2
+ import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
3
+
4
+ type Inline =
5
+ | {
6
+ type: "file"
7
+ start: number
8
+ end: number
9
+ value: string
10
+ path: string
11
+ selection?: {
12
+ startLine: number
13
+ endLine: number
14
+ startChar: number
15
+ endChar: number
16
+ }
17
+ }
18
+ | {
19
+ type: "agent"
20
+ start: number
21
+ end: number
22
+ value: string
23
+ name: string
24
+ }
25
+
26
+ function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
27
+ const queryIndex = url.indexOf("?")
28
+ if (queryIndex === -1) return undefined
29
+ const params = new URLSearchParams(url.slice(queryIndex + 1))
30
+ const startLine = Number(params.get("start"))
31
+ const endLine = Number(params.get("end"))
32
+ if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
33
+ return {
34
+ startLine,
35
+ endLine,
36
+ startChar: 0,
37
+ endChar: 0,
38
+ }
39
+ }
40
+
41
+ function textPartValue(parts: Part[]) {
42
+ const candidates = parts
43
+ .filter((part): part is TextPart => part.type === "text")
44
+ .filter((part) => !part.synthetic && !part.ignored)
45
+ return candidates.reduce((best: TextPart | undefined, part) => {
46
+ if (!best) return part
47
+ if (part.text.length > best.text.length) return part
48
+ return best
49
+ }, undefined)
50
+ }
51
+
52
+ /**
53
+ * Extract prompt content from message parts for restoring into the prompt input.
54
+ * This is used by undo to restore the original user prompt.
55
+ */
56
+ export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt {
57
+ const textPart = textPartValue(parts)
58
+ const text = textPart?.text ?? ""
59
+ const directory = opts?.directory
60
+ const attachmentName = opts?.attachmentName ?? "attachment"
61
+
62
+ const toRelative = (path: string) => {
63
+ if (!directory) return path
64
+
65
+ const prefix = directory.endsWith("/") ? directory : directory + "/"
66
+ if (path.startsWith(prefix)) return path.slice(prefix.length)
67
+
68
+ if (path.startsWith(directory)) {
69
+ const next = path.slice(directory.length)
70
+ if (next.startsWith("/")) return next.slice(1)
71
+ return next
72
+ }
73
+
74
+ return path
75
+ }
76
+
77
+ const inline: Inline[] = []
78
+ const images: ImageAttachmentPart[] = []
79
+
80
+ for (const part of parts) {
81
+ if (part.type === "file") {
82
+ const filePart = part as FilePart
83
+ const sourceText = filePart.source?.text
84
+ if (sourceText) {
85
+ const value = sourceText.value
86
+ const start = sourceText.start
87
+ const end = sourceText.end
88
+ let path = value
89
+ if (value.startsWith("@")) path = value.slice(1)
90
+ if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
91
+ path = filePart.source.path
92
+ }
93
+ inline.push({
94
+ type: "file",
95
+ start,
96
+ end,
97
+ value,
98
+ path: toRelative(path),
99
+ selection: selectionFromFileUrl(filePart.url),
100
+ })
101
+ continue
102
+ }
103
+
104
+ if (filePart.url.startsWith("data:")) {
105
+ images.push({
106
+ type: "image",
107
+ id: filePart.id,
108
+ filename: filePart.filename ?? attachmentName,
109
+ mime: filePart.mime,
110
+ dataUrl: filePart.url,
111
+ })
112
+ }
113
+ }
114
+
115
+ if (part.type === "agent") {
116
+ const agentPart = part as MessageAgentPart
117
+ const source = agentPart.source
118
+ if (!source) continue
119
+ inline.push({
120
+ type: "agent",
121
+ start: source.start,
122
+ end: source.end,
123
+ value: source.value,
124
+ name: agentPart.name,
125
+ })
126
+ }
127
+ }
128
+
129
+ inline.sort((a, b) => {
130
+ if (a.start !== b.start) return a.start - b.start
131
+ return a.end - b.end
132
+ })
133
+
134
+ const result: Prompt = []
135
+ let position = 0
136
+ let cursor = 0
137
+
138
+ const pushText = (content: string) => {
139
+ if (!content) return
140
+ result.push({
141
+ type: "text",
142
+ content,
143
+ start: position,
144
+ end: position + content.length,
145
+ })
146
+ position += content.length
147
+ }
148
+
149
+ const pushFile = (item: Extract<Inline, { type: "file" }>) => {
150
+ const content = item.value
151
+ const attachment: FileAttachmentPart = {
152
+ type: "file",
153
+ path: item.path,
154
+ content,
155
+ start: position,
156
+ end: position + content.length,
157
+ selection: item.selection,
158
+ }
159
+ result.push(attachment)
160
+ position += content.length
161
+ }
162
+
163
+ const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
164
+ const content = item.value
165
+ const mention: AgentPart = {
166
+ type: "agent",
167
+ name: item.name,
168
+ content,
169
+ start: position,
170
+ end: position + content.length,
171
+ }
172
+ result.push(mention)
173
+ position += content.length
174
+ }
175
+
176
+ for (const item of inline) {
177
+ if (item.start < 0 || item.end < item.start) continue
178
+
179
+ const expected = item.value
180
+ if (!expected) continue
181
+
182
+ const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
183
+ const start = mismatch ? text.indexOf(expected, cursor) : item.start
184
+ if (start === -1) continue
185
+ const end = mismatch ? start + expected.length : item.end
186
+
187
+ pushText(text.slice(cursor, start))
188
+
189
+ if (item.type === "file") pushFile(item)
190
+ if (item.type === "agent") pushAgent(item)
191
+
192
+ cursor = end
193
+ }
194
+
195
+ pushText(text.slice(cursor))
196
+
197
+ if (result.length === 0) {
198
+ result.push({ type: "text", content: "", start: 0, end: 0 })
199
+ }
200
+
201
+ if (images.length === 0) return result
202
+ return [...result, ...images]
203
+ }
@@ -0,0 +1,6 @@
1
+ export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
2
+ if (a === b) return true
3
+ if (!a || !b) return false
4
+ if (a.length !== b.length) return false
5
+ return a.every((x, i) => x === b[i])
6
+ }
@@ -0,0 +1,55 @@
1
+ import { useDragDropContext } from "@thisbeyond/solid-dnd"
2
+ import { JSXElement } from "solid-js"
3
+ import type { Transformer } from "@thisbeyond/solid-dnd"
4
+
5
+ export const getDraggableId = (event: unknown): string | undefined => {
6
+ if (typeof event !== "object" || event === null) return undefined
7
+ if (!("draggable" in event)) return undefined
8
+ const draggable = (event as { draggable?: { id?: unknown } }).draggable
9
+ if (!draggable) return undefined
10
+ return typeof draggable.id === "string" ? draggable.id : undefined
11
+ }
12
+
13
+ export const ConstrainDragXAxis = (): JSXElement => {
14
+ const context = useDragDropContext()
15
+ if (!context) return <></>
16
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
17
+ const transformer: Transformer = {
18
+ id: "constrain-x-axis",
19
+ order: 100,
20
+ callback: (transform) => ({ ...transform, x: 0 }),
21
+ }
22
+ onDragStart((event) => {
23
+ const id = getDraggableId(event)
24
+ if (!id) return
25
+ addTransformer("draggables", id, transformer)
26
+ })
27
+ onDragEnd((event) => {
28
+ const id = getDraggableId(event)
29
+ if (!id) return
30
+ removeTransformer("draggables", id, transformer.id)
31
+ })
32
+ return <></>
33
+ }
34
+
35
+ export const ConstrainDragYAxis = (): JSXElement => {
36
+ const context = useDragDropContext()
37
+ if (!context) return <></>
38
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
39
+ const transformer: Transformer = {
40
+ id: "constrain-y-axis",
41
+ order: 100,
42
+ callback: (transform) => ({ ...transform, y: 0 }),
43
+ }
44
+ onDragStart((event) => {
45
+ const id = getDraggableId(event)
46
+ if (!id) return
47
+ addTransformer("draggables", id, transformer)
48
+ })
49
+ onDragEnd((event) => {
50
+ const id = getDraggableId(event)
51
+ if (!id) return
52
+ removeTransformer("draggables", id, transformer.id)
53
+ })
54
+ return <></>
55
+ }
@@ -0,0 +1,110 @@
1
+ import alert01 from "@jonsoc/ui/audio/alert-01.aac"
2
+ import alert02 from "@jonsoc/ui/audio/alert-02.aac"
3
+ import alert03 from "@jonsoc/ui/audio/alert-03.aac"
4
+ import alert04 from "@jonsoc/ui/audio/alert-04.aac"
5
+ import alert05 from "@jonsoc/ui/audio/alert-05.aac"
6
+ import alert06 from "@jonsoc/ui/audio/alert-06.aac"
7
+ import alert07 from "@jonsoc/ui/audio/alert-07.aac"
8
+ import alert08 from "@jonsoc/ui/audio/alert-08.aac"
9
+ import alert09 from "@jonsoc/ui/audio/alert-09.aac"
10
+ import alert10 from "@jonsoc/ui/audio/alert-10.aac"
11
+ import bipbop01 from "@jonsoc/ui/audio/bip-bop-01.aac"
12
+ import bipbop02 from "@jonsoc/ui/audio/bip-bop-02.aac"
13
+ import bipbop03 from "@jonsoc/ui/audio/bip-bop-03.aac"
14
+ import bipbop04 from "@jonsoc/ui/audio/bip-bop-04.aac"
15
+ import bipbop05 from "@jonsoc/ui/audio/bip-bop-05.aac"
16
+ import bipbop06 from "@jonsoc/ui/audio/bip-bop-06.aac"
17
+ import bipbop07 from "@jonsoc/ui/audio/bip-bop-07.aac"
18
+ import bipbop08 from "@jonsoc/ui/audio/bip-bop-08.aac"
19
+ import bipbop09 from "@jonsoc/ui/audio/bip-bop-09.aac"
20
+ import bipbop10 from "@jonsoc/ui/audio/bip-bop-10.aac"
21
+ import nope01 from "@jonsoc/ui/audio/nope-01.aac"
22
+ import nope02 from "@jonsoc/ui/audio/nope-02.aac"
23
+ import nope03 from "@jonsoc/ui/audio/nope-03.aac"
24
+ import nope04 from "@jonsoc/ui/audio/nope-04.aac"
25
+ import nope05 from "@jonsoc/ui/audio/nope-05.aac"
26
+ import nope06 from "@jonsoc/ui/audio/nope-06.aac"
27
+ import nope07 from "@jonsoc/ui/audio/nope-07.aac"
28
+ import nope08 from "@jonsoc/ui/audio/nope-08.aac"
29
+ import nope09 from "@jonsoc/ui/audio/nope-09.aac"
30
+ import nope10 from "@jonsoc/ui/audio/nope-10.aac"
31
+ import nope11 from "@jonsoc/ui/audio/nope-11.aac"
32
+ import nope12 from "@jonsoc/ui/audio/nope-12.aac"
33
+ import staplebops01 from "@jonsoc/ui/audio/staplebops-01.aac"
34
+ import staplebops02 from "@jonsoc/ui/audio/staplebops-02.aac"
35
+ import staplebops03 from "@jonsoc/ui/audio/staplebops-03.aac"
36
+ import staplebops04 from "@jonsoc/ui/audio/staplebops-04.aac"
37
+ import staplebops05 from "@jonsoc/ui/audio/staplebops-05.aac"
38
+ import staplebops06 from "@jonsoc/ui/audio/staplebops-06.aac"
39
+ import staplebops07 from "@jonsoc/ui/audio/staplebops-07.aac"
40
+ import yup01 from "@jonsoc/ui/audio/yup-01.aac"
41
+ import yup02 from "@jonsoc/ui/audio/yup-02.aac"
42
+ import yup03 from "@jonsoc/ui/audio/yup-03.aac"
43
+ import yup04 from "@jonsoc/ui/audio/yup-04.aac"
44
+ import yup05 from "@jonsoc/ui/audio/yup-05.aac"
45
+ import yup06 from "@jonsoc/ui/audio/yup-06.aac"
46
+
47
+ export const SOUND_OPTIONS = [
48
+ { id: "alert-01", label: "sound.option.alert01", src: alert01 },
49
+ { id: "alert-02", label: "sound.option.alert02", src: alert02 },
50
+ { id: "alert-03", label: "sound.option.alert03", src: alert03 },
51
+ { id: "alert-04", label: "sound.option.alert04", src: alert04 },
52
+ { id: "alert-05", label: "sound.option.alert05", src: alert05 },
53
+ { id: "alert-06", label: "sound.option.alert06", src: alert06 },
54
+ { id: "alert-07", label: "sound.option.alert07", src: alert07 },
55
+ { id: "alert-08", label: "sound.option.alert08", src: alert08 },
56
+ { id: "alert-09", label: "sound.option.alert09", src: alert09 },
57
+ { id: "alert-10", label: "sound.option.alert10", src: alert10 },
58
+ { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
59
+ { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
60
+ { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
61
+ { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
62
+ { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
63
+ { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
64
+ { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
65
+ { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
66
+ { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
67
+ { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
68
+ { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
69
+ { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
70
+ { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
71
+ { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
72
+ { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
73
+ { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
74
+ { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
75
+ { id: "nope-01", label: "sound.option.nope01", src: nope01 },
76
+ { id: "nope-02", label: "sound.option.nope02", src: nope02 },
77
+ { id: "nope-03", label: "sound.option.nope03", src: nope03 },
78
+ { id: "nope-04", label: "sound.option.nope04", src: nope04 },
79
+ { id: "nope-05", label: "sound.option.nope05", src: nope05 },
80
+ { id: "nope-06", label: "sound.option.nope06", src: nope06 },
81
+ { id: "nope-07", label: "sound.option.nope07", src: nope07 },
82
+ { id: "nope-08", label: "sound.option.nope08", src: nope08 },
83
+ { id: "nope-09", label: "sound.option.nope09", src: nope09 },
84
+ { id: "nope-10", label: "sound.option.nope10", src: nope10 },
85
+ { id: "nope-11", label: "sound.option.nope11", src: nope11 },
86
+ { id: "nope-12", label: "sound.option.nope12", src: nope12 },
87
+ { id: "yup-01", label: "sound.option.yup01", src: yup01 },
88
+ { id: "yup-02", label: "sound.option.yup02", src: yup02 },
89
+ { id: "yup-03", label: "sound.option.yup03", src: yup03 },
90
+ { id: "yup-04", label: "sound.option.yup04", src: yup04 },
91
+ { id: "yup-05", label: "sound.option.yup05", src: yup05 },
92
+ { id: "yup-06", label: "sound.option.yup06", src: yup06 },
93
+ ] as const
94
+
95
+ export type SoundOption = (typeof SOUND_OPTIONS)[number]
96
+ export type SoundID = SoundOption["id"]
97
+
98
+ const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
99
+
100
+ export function soundSrc(id: string | undefined) {
101
+ if (!id) return
102
+ if (!(id in soundById)) return
103
+ return soundById[id as SoundID]
104
+ }
105
+
106
+ export function playSound(src: string | undefined) {
107
+ if (typeof Audio === "undefined") return
108
+ if (!src) return
109
+ void new Audio(src).play().catch(() => undefined)
110
+ }
@@ -0,0 +1,302 @@
1
+ import { createSignal, onCleanup } from "solid-js"
2
+
3
+ // Minimal types to avoid relying on non-standard DOM typings
4
+ type RecognitionResult = {
5
+ 0: { transcript: string }
6
+ isFinal: boolean
7
+ }
8
+
9
+ type RecognitionEvent = {
10
+ results: RecognitionResult[]
11
+ resultIndex: number
12
+ }
13
+
14
+ interface Recognition {
15
+ continuous: boolean
16
+ interimResults: boolean
17
+ lang: string
18
+ start: () => void
19
+ stop: () => void
20
+ onresult: ((e: RecognitionEvent) => void) | null
21
+ onerror: ((e: { error: string }) => void) | null
22
+ onend: (() => void) | null
23
+ onstart: (() => void) | null
24
+ }
25
+
26
+ const COMMIT_DELAY = 250
27
+
28
+ const appendSegment = (base: string, addition: string) => {
29
+ const trimmed = addition.trim()
30
+ if (!trimmed) return base
31
+ if (!base) return trimmed
32
+ const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
33
+ return `${base}${needsSpace ? " " : ""}${trimmed}`
34
+ }
35
+
36
+ const extractSuffix = (committed: string, hypothesis: string) => {
37
+ const cleanHypothesis = hypothesis.trim()
38
+ if (!cleanHypothesis) return ""
39
+ const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
40
+ const hypothesisTokens = cleanHypothesis.split(/\s+/)
41
+ let index = 0
42
+ while (
43
+ index < baseTokens.length &&
44
+ index < hypothesisTokens.length &&
45
+ baseTokens[index] === hypothesisTokens[index]
46
+ ) {
47
+ index += 1
48
+ }
49
+ if (index < baseTokens.length) return ""
50
+ return hypothesisTokens.slice(index).join(" ")
51
+ }
52
+
53
+ export function createSpeechRecognition(opts?: {
54
+ lang?: string
55
+ onFinal?: (text: string) => void
56
+ onInterim?: (text: string) => void
57
+ }) {
58
+ const hasSupport =
59
+ typeof window !== "undefined" &&
60
+ Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
61
+
62
+ const [isRecording, setIsRecording] = createSignal(false)
63
+ const [committed, setCommitted] = createSignal("")
64
+ const [interim, setInterim] = createSignal("")
65
+
66
+ let recognition: Recognition | undefined
67
+ let shouldContinue = false
68
+ let committedText = ""
69
+ let sessionCommitted = ""
70
+ let pendingHypothesis = ""
71
+ let lastInterimSuffix = ""
72
+ let shrinkCandidate: string | undefined
73
+ let commitTimer: number | undefined
74
+
75
+ const cancelPendingCommit = () => {
76
+ if (commitTimer === undefined) return
77
+ clearTimeout(commitTimer)
78
+ commitTimer = undefined
79
+ }
80
+
81
+ const commitSegment = (segment: string) => {
82
+ const nextCommitted = appendSegment(committedText, segment)
83
+ if (nextCommitted === committedText) return
84
+ committedText = nextCommitted
85
+ setCommitted(committedText)
86
+ if (opts?.onFinal) opts.onFinal(segment.trim())
87
+ }
88
+
89
+ const promotePending = () => {
90
+ if (!pendingHypothesis) return
91
+ const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
92
+ if (!suffix) {
93
+ pendingHypothesis = ""
94
+ return
95
+ }
96
+ sessionCommitted = appendSegment(sessionCommitted, suffix)
97
+ commitSegment(suffix)
98
+ pendingHypothesis = ""
99
+ lastInterimSuffix = ""
100
+ shrinkCandidate = undefined
101
+ setInterim("")
102
+ if (opts?.onInterim) opts.onInterim("")
103
+ }
104
+
105
+ const applyInterim = (suffix: string, hypothesis: string) => {
106
+ cancelPendingCommit()
107
+ pendingHypothesis = hypothesis
108
+ lastInterimSuffix = suffix
109
+ shrinkCandidate = undefined
110
+ setInterim(suffix)
111
+ if (opts?.onInterim) {
112
+ opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
113
+ }
114
+ if (!suffix) return
115
+ const snapshot = hypothesis
116
+ commitTimer = window.setTimeout(() => {
117
+ if (pendingHypothesis !== snapshot) return
118
+ const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
119
+ if (!currentSuffix) return
120
+ sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
121
+ commitSegment(currentSuffix)
122
+ pendingHypothesis = ""
123
+ lastInterimSuffix = ""
124
+ shrinkCandidate = undefined
125
+ setInterim("")
126
+ if (opts?.onInterim) opts.onInterim("")
127
+ }, COMMIT_DELAY)
128
+ }
129
+
130
+ if (hasSupport) {
131
+ const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
132
+
133
+ recognition = new Ctor()
134
+ recognition.continuous = false
135
+ recognition.interimResults = true
136
+ recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
137
+
138
+ recognition.onresult = (event: RecognitionEvent) => {
139
+ if (!event.results.length) return
140
+
141
+ let aggregatedFinal = ""
142
+ let latestHypothesis = ""
143
+
144
+ for (let i = 0; i < event.results.length; i += 1) {
145
+ const result = event.results[i]
146
+ const transcript = (result[0]?.transcript || "").trim()
147
+ if (!transcript) continue
148
+ if (result.isFinal) {
149
+ aggregatedFinal = appendSegment(aggregatedFinal, transcript)
150
+ } else {
151
+ latestHypothesis = transcript
152
+ }
153
+ }
154
+
155
+ if (aggregatedFinal) {
156
+ cancelPendingCommit()
157
+ const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
158
+ if (finalSuffix) {
159
+ sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
160
+ commitSegment(finalSuffix)
161
+ }
162
+ pendingHypothesis = ""
163
+ lastInterimSuffix = ""
164
+ shrinkCandidate = undefined
165
+ setInterim("")
166
+ if (opts?.onInterim) opts.onInterim("")
167
+ return
168
+ }
169
+
170
+ cancelPendingCommit()
171
+
172
+ if (!latestHypothesis) {
173
+ shrinkCandidate = undefined
174
+ applyInterim("", "")
175
+ return
176
+ }
177
+
178
+ const suffix = extractSuffix(sessionCommitted, latestHypothesis)
179
+
180
+ if (!suffix) {
181
+ if (!lastInterimSuffix) {
182
+ shrinkCandidate = undefined
183
+ applyInterim("", latestHypothesis)
184
+ return
185
+ }
186
+ if (shrinkCandidate === "") {
187
+ applyInterim("", latestHypothesis)
188
+ return
189
+ }
190
+ shrinkCandidate = ""
191
+ pendingHypothesis = latestHypothesis
192
+ return
193
+ }
194
+
195
+ if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
196
+ if (shrinkCandidate === suffix) {
197
+ applyInterim(suffix, latestHypothesis)
198
+ return
199
+ }
200
+ shrinkCandidate = suffix
201
+ pendingHypothesis = latestHypothesis
202
+ return
203
+ }
204
+
205
+ shrinkCandidate = undefined
206
+ applyInterim(suffix, latestHypothesis)
207
+ }
208
+
209
+ recognition.onerror = (e: { error: string }) => {
210
+ cancelPendingCommit()
211
+ lastInterimSuffix = ""
212
+ shrinkCandidate = undefined
213
+ if (e.error === "no-speech" && shouldContinue) {
214
+ setInterim("")
215
+ if (opts?.onInterim) opts.onInterim("")
216
+ setTimeout(() => {
217
+ try {
218
+ recognition?.start()
219
+ } catch {}
220
+ }, 150)
221
+ return
222
+ }
223
+ shouldContinue = false
224
+ setIsRecording(false)
225
+ }
226
+
227
+ recognition.onstart = () => {
228
+ sessionCommitted = ""
229
+ pendingHypothesis = ""
230
+ cancelPendingCommit()
231
+ lastInterimSuffix = ""
232
+ shrinkCandidate = undefined
233
+ setInterim("")
234
+ if (opts?.onInterim) opts.onInterim("")
235
+ setIsRecording(true)
236
+ }
237
+
238
+ recognition.onend = () => {
239
+ cancelPendingCommit()
240
+ lastInterimSuffix = ""
241
+ shrinkCandidate = undefined
242
+ setIsRecording(false)
243
+ if (shouldContinue) {
244
+ setTimeout(() => {
245
+ try {
246
+ recognition?.start()
247
+ } catch {}
248
+ }, 150)
249
+ }
250
+ }
251
+ }
252
+
253
+ const start = () => {
254
+ if (!recognition) return
255
+ shouldContinue = true
256
+ sessionCommitted = ""
257
+ pendingHypothesis = ""
258
+ cancelPendingCommit()
259
+ lastInterimSuffix = ""
260
+ shrinkCandidate = undefined
261
+ setInterim("")
262
+ try {
263
+ recognition.start()
264
+ } catch {}
265
+ }
266
+
267
+ const stop = () => {
268
+ if (!recognition) return
269
+ shouldContinue = false
270
+ promotePending()
271
+ cancelPendingCommit()
272
+ lastInterimSuffix = ""
273
+ shrinkCandidate = undefined
274
+ setInterim("")
275
+ if (opts?.onInterim) opts.onInterim("")
276
+ try {
277
+ recognition.stop()
278
+ } catch {}
279
+ }
280
+
281
+ onCleanup(() => {
282
+ shouldContinue = false
283
+ promotePending()
284
+ cancelPendingCommit()
285
+ lastInterimSuffix = ""
286
+ shrinkCandidate = undefined
287
+ setInterim("")
288
+ if (opts?.onInterim) opts.onInterim("")
289
+ try {
290
+ recognition?.stop()
291
+ } catch {}
292
+ })
293
+
294
+ return {
295
+ isSupported: () => hasSupport,
296
+ isRecording,
297
+ committed,
298
+ interim,
299
+ start,
300
+ stop,
301
+ }
302
+ }