@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,437 @@
1
+ import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
2
+ import { Button } from "@jonsoc/ui/button"
3
+ import { Icon } from "@jonsoc/ui/icon"
4
+ import { IconButton } from "@jonsoc/ui/icon-button"
5
+ import { TextField } from "@jonsoc/ui/text-field"
6
+ import { showToast } from "@jonsoc/ui/toast"
7
+ import fuzzysort from "fuzzysort"
8
+ import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
9
+ import { useLanguage } from "@/context/language"
10
+ import { useSettings } from "@/context/settings"
11
+
12
+ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
13
+ const PALETTE_ID = "command.palette"
14
+ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
15
+
16
+ type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
17
+
18
+ type KeybindMeta = {
19
+ title: string
20
+ group: KeybindGroup
21
+ }
22
+
23
+ const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
24
+
25
+ type GroupKey =
26
+ | "settings.shortcuts.group.general"
27
+ | "settings.shortcuts.group.session"
28
+ | "settings.shortcuts.group.navigation"
29
+ | "settings.shortcuts.group.modelAndAgent"
30
+ | "settings.shortcuts.group.terminal"
31
+ | "settings.shortcuts.group.prompt"
32
+
33
+ const groupKey: Record<KeybindGroup, GroupKey> = {
34
+ General: "settings.shortcuts.group.general",
35
+ Session: "settings.shortcuts.group.session",
36
+ Navigation: "settings.shortcuts.group.navigation",
37
+ "Model and agent": "settings.shortcuts.group.modelAndAgent",
38
+ Terminal: "settings.shortcuts.group.terminal",
39
+ Prompt: "settings.shortcuts.group.prompt",
40
+ }
41
+
42
+ function groupFor(id: string): KeybindGroup {
43
+ if (id === PALETTE_ID) return "General"
44
+ if (id.startsWith("terminal.")) return "Terminal"
45
+ if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
46
+ if (id.startsWith("file.")) return "Navigation"
47
+ if (id.startsWith("prompt.")) return "Prompt"
48
+ if (
49
+ id.startsWith("session.") ||
50
+ id.startsWith("message.") ||
51
+ id.startsWith("permissions.") ||
52
+ id.startsWith("steps.") ||
53
+ id.startsWith("review.")
54
+ )
55
+ return "Session"
56
+
57
+ return "General"
58
+ }
59
+
60
+ function isModifier(key: string) {
61
+ return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
62
+ }
63
+
64
+ function normalizeKey(key: string) {
65
+ if (key === ",") return "comma"
66
+ if (key === "+") return "plus"
67
+ if (key === " ") return "space"
68
+ return key.toLowerCase()
69
+ }
70
+
71
+ function recordKeybind(event: KeyboardEvent) {
72
+ if (isModifier(event.key)) return
73
+
74
+ const parts: string[] = []
75
+
76
+ const mod = IS_MAC ? event.metaKey : event.ctrlKey
77
+ if (mod) parts.push("mod")
78
+
79
+ if (IS_MAC && event.ctrlKey) parts.push("ctrl")
80
+ if (!IS_MAC && event.metaKey) parts.push("meta")
81
+ if (event.altKey) parts.push("alt")
82
+ if (event.shiftKey) parts.push("shift")
83
+
84
+ const key = normalizeKey(event.key)
85
+ if (!key) return
86
+ parts.push(key)
87
+
88
+ return parts.join("+")
89
+ }
90
+
91
+ function signatures(config: string | undefined) {
92
+ if (!config) return []
93
+ const sigs: string[] = []
94
+
95
+ for (const kb of parseKeybind(config)) {
96
+ const parts: string[] = []
97
+ if (kb.ctrl) parts.push("ctrl")
98
+ if (kb.alt) parts.push("alt")
99
+ if (kb.shift) parts.push("shift")
100
+ if (kb.meta) parts.push("meta")
101
+ if (kb.key) parts.push(kb.key)
102
+ if (parts.length === 0) continue
103
+ sigs.push(parts.join("+"))
104
+ }
105
+
106
+ return sigs
107
+ }
108
+
109
+ export const SettingsKeybinds: Component = () => {
110
+ const command = useCommand()
111
+ const language = useLanguage()
112
+ const settings = useSettings()
113
+
114
+ const [active, setActive] = createSignal<string | null>(null)
115
+ const [filter, setFilter] = createSignal("")
116
+
117
+ const stop = () => {
118
+ if (!active()) return
119
+ setActive(null)
120
+ command.keybinds(true)
121
+ }
122
+
123
+ const start = (id: string) => {
124
+ if (active() === id) {
125
+ stop()
126
+ return
127
+ }
128
+
129
+ if (active()) stop()
130
+
131
+ setActive(id)
132
+ command.keybinds(false)
133
+ }
134
+
135
+ const hasOverrides = createMemo(() => {
136
+ const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
137
+ if (!keybinds) return false
138
+ return Object.values(keybinds).some((x) => typeof x === "string")
139
+ })
140
+
141
+ const resetAll = () => {
142
+ stop()
143
+ settings.keybinds.resetAll()
144
+ showToast({
145
+ title: language.t("settings.shortcuts.reset.toast.title"),
146
+ description: language.t("settings.shortcuts.reset.toast.description"),
147
+ })
148
+ }
149
+
150
+ const list = createMemo(() => {
151
+ language.locale()
152
+ const out = new Map<string, KeybindMeta>()
153
+ out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" })
154
+
155
+ for (const opt of command.catalog) {
156
+ if (opt.id.startsWith("suggested.")) continue
157
+ out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
158
+ }
159
+
160
+ for (const opt of command.options) {
161
+ if (opt.id.startsWith("suggested.")) continue
162
+ out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
163
+ }
164
+
165
+ const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
166
+ if (keybinds) {
167
+ for (const [id, value] of Object.entries(keybinds)) {
168
+ if (typeof value !== "string") continue
169
+ if (out.has(id)) continue
170
+ out.set(id, { title: id, group: groupFor(id) })
171
+ }
172
+ }
173
+
174
+ return out
175
+ })
176
+
177
+ const title = (id: string) => list().get(id)?.title ?? ""
178
+
179
+ const grouped = createMemo(() => {
180
+ const map = list()
181
+ const out = new Map<KeybindGroup, string[]>()
182
+
183
+ for (const group of GROUPS) out.set(group, [])
184
+
185
+ for (const [id, item] of map) {
186
+ const ids = out.get(item.group)
187
+ if (!ids) continue
188
+ ids.push(id)
189
+ }
190
+
191
+ for (const group of GROUPS) {
192
+ const ids = out.get(group)
193
+ if (!ids) continue
194
+
195
+ ids.sort((a, b) => {
196
+ const at = map.get(a)?.title ?? ""
197
+ const bt = map.get(b)?.title ?? ""
198
+ return at.localeCompare(bt)
199
+ })
200
+ }
201
+
202
+ return out
203
+ })
204
+
205
+ const filtered = createMemo(() => {
206
+ const query = filter().toLowerCase().trim()
207
+ if (!query) return grouped()
208
+
209
+ const map = list()
210
+ const out = new Map<KeybindGroup, string[]>()
211
+
212
+ for (const group of GROUPS) out.set(group, [])
213
+
214
+ const items = Array.from(map.entries()).map(([id, meta]) => ({
215
+ id,
216
+ title: meta.title,
217
+ group: meta.group,
218
+ keybind: command.keybind(id) || "",
219
+ }))
220
+
221
+ const results = fuzzysort.go(query, items, {
222
+ keys: ["title", "keybind"],
223
+ threshold: -10000,
224
+ })
225
+
226
+ for (const result of results) {
227
+ const item = result.obj
228
+ const ids = out.get(item.group)
229
+ if (!ids) continue
230
+ ids.push(item.id)
231
+ }
232
+
233
+ return out
234
+ })
235
+
236
+ const hasResults = createMemo(() => {
237
+ for (const group of GROUPS) {
238
+ const ids = filtered().get(group) ?? []
239
+ if (ids.length > 0) return true
240
+ }
241
+ return false
242
+ })
243
+
244
+ const used = createMemo(() => {
245
+ const map = new Map<string, { id: string; title: string }[]>()
246
+
247
+ const add = (key: string, value: { id: string; title: string }) => {
248
+ const list = map.get(key)
249
+ if (!list) {
250
+ map.set(key, [value])
251
+ return
252
+ }
253
+ list.push(value)
254
+ }
255
+
256
+ const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
257
+ for (const sig of signatures(palette)) {
258
+ add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) })
259
+ }
260
+
261
+ const valueFor = (id: string) => {
262
+ const custom = settings.keybinds.get(id)
263
+ if (typeof custom === "string") return custom
264
+
265
+ const live = command.options.find((x) => x.id === id)
266
+ if (live?.keybind) return live.keybind
267
+
268
+ const meta = command.catalog.find((x) => x.id === id)
269
+ return meta?.keybind
270
+ }
271
+
272
+ for (const id of list().keys()) {
273
+ if (id === PALETTE_ID) continue
274
+ for (const sig of signatures(valueFor(id))) {
275
+ add(sig, { id, title: title(id) })
276
+ }
277
+ }
278
+
279
+ return map
280
+ })
281
+
282
+ const setKeybind = (id: string, keybind: string) => {
283
+ settings.keybinds.set(id, keybind)
284
+ }
285
+
286
+ onMount(() => {
287
+ const handle = (event: KeyboardEvent) => {
288
+ const id = active()
289
+ if (!id) return
290
+
291
+ event.preventDefault()
292
+ event.stopPropagation()
293
+ event.stopImmediatePropagation()
294
+
295
+ if (event.key === "Escape") {
296
+ stop()
297
+ return
298
+ }
299
+
300
+ const clear =
301
+ (event.key === "Backspace" || event.key === "Delete") &&
302
+ !event.ctrlKey &&
303
+ !event.metaKey &&
304
+ !event.altKey &&
305
+ !event.shiftKey
306
+ if (clear) {
307
+ setKeybind(id, "none")
308
+ stop()
309
+ return
310
+ }
311
+
312
+ const next = recordKeybind(event)
313
+ if (!next) return
314
+
315
+ const map = used()
316
+ const conflicts = new Map<string, string>()
317
+
318
+ for (const sig of signatures(next)) {
319
+ const list = map.get(sig) ?? []
320
+ for (const item of list) {
321
+ if (item.id === id) continue
322
+ conflicts.set(item.id, item.title)
323
+ }
324
+ }
325
+
326
+ if (conflicts.size > 0) {
327
+ showToast({
328
+ title: language.t("settings.shortcuts.conflict.title"),
329
+ description: language.t("settings.shortcuts.conflict.description", {
330
+ keybind: formatKeybind(next),
331
+ titles: [...conflicts.values()].join(", "),
332
+ }),
333
+ })
334
+ return
335
+ }
336
+
337
+ setKeybind(id, next)
338
+ stop()
339
+ }
340
+
341
+ document.addEventListener("keydown", handle, true)
342
+ onCleanup(() => {
343
+ document.removeEventListener("keydown", handle, true)
344
+ })
345
+ })
346
+
347
+ onCleanup(() => {
348
+ if (active()) command.keybinds(true)
349
+ })
350
+
351
+ return (
352
+ <div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
353
+ <div
354
+ class="sticky top-0 z-10"
355
+ style={{
356
+ background:
357
+ "linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
358
+ }}
359
+ >
360
+ <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
361
+ <div class="flex items-center justify-between gap-4">
362
+ <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
363
+ <Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
364
+ {language.t("settings.shortcuts.reset.button")}
365
+ </Button>
366
+ </div>
367
+
368
+ <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
369
+ <Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
370
+ <TextField
371
+ variant="ghost"
372
+ type="text"
373
+ value={filter()}
374
+ onChange={setFilter}
375
+ placeholder={language.t("settings.shortcuts.search.placeholder")}
376
+ spellcheck={false}
377
+ autocorrect="off"
378
+ autocomplete="off"
379
+ autocapitalize="off"
380
+ class="flex-1"
381
+ />
382
+ <Show when={filter()}>
383
+ <IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
384
+ </Show>
385
+ </div>
386
+ </div>
387
+ </div>
388
+
389
+ <div class="flex flex-col gap-8 max-w-[720px]">
390
+ <For each={GROUPS}>
391
+ {(group) => (
392
+ <Show when={(filtered().get(group) ?? []).length > 0}>
393
+ <div class="flex flex-col gap-1">
394
+ <h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
395
+ <div class="bg-surface-raised-base px-4 rounded-lg">
396
+ <For each={filtered().get(group) ?? []}>
397
+ {(id) => (
398
+ <div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
399
+ <span class="text-14-regular text-text-strong">{title(id)}</span>
400
+ <button
401
+ type="button"
402
+ classList={{
403
+ "h-8 px-3 rounded-md text-12-regular": true,
404
+ "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
405
+ active() !== id,
406
+ "border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id,
407
+ }}
408
+ onClick={() => start(id)}
409
+ >
410
+ <Show
411
+ when={active() === id}
412
+ fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
413
+ >
414
+ {language.t("settings.shortcuts.pressKeys")}
415
+ </Show>
416
+ </button>
417
+ </div>
418
+ )}
419
+ </For>
420
+ </div>
421
+ </div>
422
+ </Show>
423
+ )}
424
+ </For>
425
+
426
+ <Show when={filter() && !hasResults()}>
427
+ <div class="flex flex-col items-center justify-center py-12 text-center">
428
+ <span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
429
+ <Show when={filter()}>
430
+ <span class="text-14-regular text-text-strong mt-1">"{filter()}"</span>
431
+ </Show>
432
+ </div>
433
+ </Show>
434
+ </div>
435
+ </div>
436
+ )
437
+ }
@@ -0,0 +1,15 @@
1
+ import { Component } from "solid-js"
2
+ import { useLanguage } from "@/context/language"
3
+
4
+ export const SettingsMcp: Component = () => {
5
+ const language = useLanguage()
6
+
7
+ return (
8
+ <div class="flex flex-col h-full overflow-y-auto">
9
+ <div class="flex flex-col gap-6 p-6 max-w-[600px]">
10
+ <h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
11
+ <p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
12
+ </div>
13
+ </div>
14
+ )
15
+ }
@@ -0,0 +1,15 @@
1
+ import { Component } from "solid-js"
2
+ import { useLanguage } from "@/context/language"
3
+
4
+ export const SettingsModels: Component = () => {
5
+ const language = useLanguage()
6
+
7
+ return (
8
+ <div class="flex flex-col h-full overflow-y-auto">
9
+ <div class="flex flex-col gap-6 p-6 max-w-[600px]">
10
+ <h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
11
+ <p class="text-14-regular text-text-weak">{language.t("settings.models.description")}</p>
12
+ </div>
13
+ </div>
14
+ )
15
+ }
@@ -0,0 +1,234 @@
1
+ import { Select } from "@jonsoc/ui/select"
2
+ import { showToast } from "@jonsoc/ui/toast"
3
+ import { Component, For, createMemo, type JSX } from "solid-js"
4
+ import { useGlobalSync } from "@/context/global-sync"
5
+ import { useLanguage } from "@/context/language"
6
+
7
+ type PermissionAction = "allow" | "ask" | "deny"
8
+
9
+ type PermissionObject = Record<string, PermissionAction>
10
+ type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
11
+ type PermissionMap = Record<string, PermissionValue>
12
+
13
+ type PermissionItem = {
14
+ id: string
15
+ title: string
16
+ description: string
17
+ }
18
+
19
+ const ACTIONS = [
20
+ { value: "allow", label: "settings.permissions.action.allow" },
21
+ { value: "ask", label: "settings.permissions.action.ask" },
22
+ { value: "deny", label: "settings.permissions.action.deny" },
23
+ ] as const
24
+
25
+ const ITEMS = [
26
+ {
27
+ id: "read",
28
+ title: "settings.permissions.tool.read.title",
29
+ description: "settings.permissions.tool.read.description",
30
+ },
31
+ {
32
+ id: "edit",
33
+ title: "settings.permissions.tool.edit.title",
34
+ description: "settings.permissions.tool.edit.description",
35
+ },
36
+ {
37
+ id: "glob",
38
+ title: "settings.permissions.tool.glob.title",
39
+ description: "settings.permissions.tool.glob.description",
40
+ },
41
+ {
42
+ id: "grep",
43
+ title: "settings.permissions.tool.grep.title",
44
+ description: "settings.permissions.tool.grep.description",
45
+ },
46
+ {
47
+ id: "list",
48
+ title: "settings.permissions.tool.list.title",
49
+ description: "settings.permissions.tool.list.description",
50
+ },
51
+ {
52
+ id: "bash",
53
+ title: "settings.permissions.tool.bash.title",
54
+ description: "settings.permissions.tool.bash.description",
55
+ },
56
+ {
57
+ id: "task",
58
+ title: "settings.permissions.tool.task.title",
59
+ description: "settings.permissions.tool.task.description",
60
+ },
61
+ {
62
+ id: "skill",
63
+ title: "settings.permissions.tool.skill.title",
64
+ description: "settings.permissions.tool.skill.description",
65
+ },
66
+ {
67
+ id: "lsp",
68
+ title: "settings.permissions.tool.lsp.title",
69
+ description: "settings.permissions.tool.lsp.description",
70
+ },
71
+ {
72
+ id: "todoread",
73
+ title: "settings.permissions.tool.todoread.title",
74
+ description: "settings.permissions.tool.todoread.description",
75
+ },
76
+ {
77
+ id: "todowrite",
78
+ title: "settings.permissions.tool.todowrite.title",
79
+ description: "settings.permissions.tool.todowrite.description",
80
+ },
81
+ {
82
+ id: "webfetch",
83
+ title: "settings.permissions.tool.webfetch.title",
84
+ description: "settings.permissions.tool.webfetch.description",
85
+ },
86
+ {
87
+ id: "websearch",
88
+ title: "settings.permissions.tool.websearch.title",
89
+ description: "settings.permissions.tool.websearch.description",
90
+ },
91
+ {
92
+ id: "codesearch",
93
+ title: "settings.permissions.tool.codesearch.title",
94
+ description: "settings.permissions.tool.codesearch.description",
95
+ },
96
+ {
97
+ id: "external_directory",
98
+ title: "settings.permissions.tool.external_directory.title",
99
+ description: "settings.permissions.tool.external_directory.description",
100
+ },
101
+ {
102
+ id: "doom_loop",
103
+ title: "settings.permissions.tool.doom_loop.title",
104
+ description: "settings.permissions.tool.doom_loop.description",
105
+ },
106
+ ] as const
107
+
108
+ const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
109
+
110
+ function toMap(value: unknown): PermissionMap {
111
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
112
+
113
+ const action = getAction(value)
114
+ if (action) return { "*": action }
115
+
116
+ return {}
117
+ }
118
+
119
+ function getAction(value: unknown): PermissionAction | undefined {
120
+ if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
121
+ return
122
+ }
123
+
124
+ function getRuleDefault(value: unknown): PermissionAction | undefined {
125
+ const action = getAction(value)
126
+ if (action) return action
127
+
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
129
+
130
+ return getAction((value as Record<string, unknown>)["*"])
131
+ }
132
+
133
+ export const SettingsPermissions: Component = () => {
134
+ const globalSync = useGlobalSync()
135
+ const language = useLanguage()
136
+
137
+ const actions = createMemo(
138
+ (): Array<{ value: PermissionAction; label: string }> =>
139
+ ACTIONS.map((action) => ({
140
+ value: action.value,
141
+ label: language.t(action.label),
142
+ })),
143
+ )
144
+
145
+ const permission = createMemo(() => {
146
+ return toMap(globalSync.data.config.permission)
147
+ })
148
+
149
+ const actionFor = (id: string): PermissionAction => {
150
+ const value = permission()[id]
151
+ const direct = getRuleDefault(value)
152
+ if (direct) return direct
153
+
154
+ const wildcard = getRuleDefault(permission()["*"])
155
+ if (wildcard) return wildcard
156
+
157
+ return "allow"
158
+ }
159
+
160
+ const setPermission = async (id: string, action: PermissionAction) => {
161
+ const before = globalSync.data.config.permission
162
+ const map = toMap(before)
163
+ const existing = map[id]
164
+
165
+ const nextValue =
166
+ existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
167
+
168
+ globalSync.set("config", "permission", { ...map, [id]: nextValue })
169
+ globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
170
+ globalSync.set("config", "permission", before)
171
+ const message = err instanceof Error ? err.message : String(err)
172
+ showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
173
+ })
174
+ }
175
+
176
+ return (
177
+ <div class="flex flex-col h-full overflow-y-auto no-scrollbar">
178
+ <div
179
+ class="sticky top-0 z-10"
180
+ style={{
181
+ background:
182
+ "linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
183
+ }}
184
+ >
185
+ <div class="flex flex-col gap-1 p-8 max-w-[720px]">
186
+ <h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
187
+ <p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]">
192
+ <div class="flex flex-col gap-2">
193
+ <h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
194
+ <div class="border border-border-weak-base rounded-lg overflow-hidden">
195
+ <For each={ITEMS}>
196
+ {(item) => (
197
+ <SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
198
+ <Select
199
+ options={actions()}
200
+ current={actions().find((o) => o.value === actionFor(item.id))}
201
+ value={(o) => o.value}
202
+ label={(o) => o.label}
203
+ onSelect={(option) => option && setPermission(item.id, option.value)}
204
+ variant="secondary"
205
+ size="small"
206
+ triggerVariant="settings"
207
+ />
208
+ </SettingsRow>
209
+ )}
210
+ </For>
211
+ </div>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ )
216
+ }
217
+
218
+ interface SettingsRowProps {
219
+ title: string
220
+ description: string
221
+ children: JSX.Element
222
+ }
223
+
224
+ const SettingsRow: Component<SettingsRowProps> = (props) => {
225
+ return (
226
+ <div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
227
+ <div class="flex flex-col gap-0.5">
228
+ <span class="text-14-medium text-text-strong">{props.title}</span>
229
+ <span class="text-12-regular text-text-weak">{props.description}</span>
230
+ </div>
231
+ <div class="flex-shrink-0">{props.children}</div>
232
+ </div>
233
+ )
234
+ }