kajji 0.1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/bin/kajji.js +2 -0
  4. package/package.json +56 -0
  5. package/src/App.tsx +229 -0
  6. package/src/commander/bookmarks.ts +129 -0
  7. package/src/commander/diff.ts +186 -0
  8. package/src/commander/executor.ts +285 -0
  9. package/src/commander/files.ts +87 -0
  10. package/src/commander/log.ts +99 -0
  11. package/src/commander/operations.ts +313 -0
  12. package/src/commander/types.ts +21 -0
  13. package/src/components/AnsiText.tsx +77 -0
  14. package/src/components/BorderBox.tsx +124 -0
  15. package/src/components/FileTreeList.tsx +105 -0
  16. package/src/components/Layout.tsx +48 -0
  17. package/src/components/Panel.tsx +143 -0
  18. package/src/components/RevisionPicker.tsx +165 -0
  19. package/src/components/StatusBar.tsx +158 -0
  20. package/src/components/modals/BookmarkNameModal.tsx +170 -0
  21. package/src/components/modals/DescribeModal.tsx +124 -0
  22. package/src/components/modals/HelpModal.tsx +372 -0
  23. package/src/components/modals/RevisionPickerModal.tsx +70 -0
  24. package/src/components/modals/UndoModal.tsx +75 -0
  25. package/src/components/panels/BookmarksPanel.tsx +768 -0
  26. package/src/components/panels/CommandLogPanel.tsx +40 -0
  27. package/src/components/panels/LogPanel.tsx +774 -0
  28. package/src/components/panels/MainArea.tsx +354 -0
  29. package/src/context/command.tsx +106 -0
  30. package/src/context/commandlog.tsx +45 -0
  31. package/src/context/dialog.tsx +217 -0
  32. package/src/context/focus.tsx +63 -0
  33. package/src/context/helper.tsx +24 -0
  34. package/src/context/keybind.tsx +51 -0
  35. package/src/context/loading.tsx +68 -0
  36. package/src/context/sync.tsx +868 -0
  37. package/src/context/theme.tsx +90 -0
  38. package/src/context/types.ts +51 -0
  39. package/src/index.tsx +15 -0
  40. package/src/keybind/index.ts +2 -0
  41. package/src/keybind/parser.ts +88 -0
  42. package/src/keybind/types.ts +83 -0
  43. package/src/theme/index.ts +3 -0
  44. package/src/theme/presets/lazygit.ts +45 -0
  45. package/src/theme/presets/opencode.ts +45 -0
  46. package/src/theme/types.ts +47 -0
  47. package/src/utils/double-click.ts +59 -0
  48. package/src/utils/file-tree.ts +154 -0
@@ -0,0 +1,354 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core"
2
+ import {
3
+ For,
4
+ Show,
5
+ createEffect,
6
+ createMemo,
7
+ createSignal,
8
+ onCleanup,
9
+ onMount,
10
+ } from "solid-js"
11
+ import type { DiffStats } from "../../commander/operations"
12
+ import type { Commit } from "../../commander/types"
13
+ import { useCommand } from "../../context/command"
14
+ import { useFocus } from "../../context/focus"
15
+ import { type CommitDetails, useSync } from "../../context/sync"
16
+ import { useTheme } from "../../context/theme"
17
+ import { AnsiText } from "../AnsiText"
18
+ import { Panel } from "../Panel"
19
+
20
+ const INITIAL_LIMIT = 1000
21
+ const LIMIT_INCREMENT = 200
22
+ const LOAD_THRESHOLD = 200
23
+
24
+ function formatTimestamp(timestamp: string): string {
25
+ // Input: "2026-01-02 14:30:45 -0800"
26
+ // Output: "Thu Jan 2 14:30:45 2026 -0800"
27
+ const match = timestamp.match(
28
+ /(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2}) (.+)/,
29
+ )
30
+ if (!match) return timestamp
31
+
32
+ const [, year, month, day, time, tz] = match
33
+ const date = new Date(`${year}-${month}-${day}T${time}`)
34
+ const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
35
+ const monthNames = [
36
+ "Jan",
37
+ "Feb",
38
+ "Mar",
39
+ "Apr",
40
+ "May",
41
+ "Jun",
42
+ "Jul",
43
+ "Aug",
44
+ "Sep",
45
+ "Oct",
46
+ "Nov",
47
+ "Dec",
48
+ ]
49
+ const dayName = dayNames[date.getDay()]
50
+ const monthName = monthNames[date.getMonth()]
51
+ const dayNum = date.getDate()
52
+
53
+ return `${dayName} ${monthName} ${dayNum} ${time} ${year} ${tz}`
54
+ }
55
+
56
+ function FileStats(props: { stats: DiffStats; maxWidth: number }) {
57
+ const { colors } = useTheme()
58
+ const s = () => props.stats
59
+
60
+ const separatorWidth = 3 // " | "
61
+ const barMargin = 2 // margin on right side
62
+
63
+ // Scale +/- counts to fit within available width while preserving ratio
64
+ const scaleBar = (
65
+ insertions: number,
66
+ deletions: number,
67
+ availableWidth: number,
68
+ ) => {
69
+ const total = insertions + deletions
70
+ if (total === 0) return { plus: 0, minus: 0 }
71
+ if (total <= availableWidth) return { plus: insertions, minus: deletions }
72
+
73
+ // Scale down proportionally
74
+ const scale = availableWidth / total
75
+ const scaledPlus = Math.round(insertions * scale)
76
+ const scaledMinus = Math.round(deletions * scale)
77
+
78
+ // Ensure at least 1 char if there were any changes
79
+ const plus = insertions > 0 ? Math.max(1, scaledPlus) : 0
80
+ const minus = deletions > 0 ? Math.max(1, scaledMinus) : 0
81
+
82
+ return { plus, minus }
83
+ }
84
+
85
+ return (
86
+ <>
87
+ <text> </text>
88
+ <For each={s().files}>
89
+ {(file) => {
90
+ // Calculate available width for bar based on actual path length
91
+ const pathLen = file.path.length
92
+ const availableBarWidth = Math.max(
93
+ 1,
94
+ props.maxWidth - pathLen - separatorWidth - barMargin,
95
+ )
96
+ const bar = scaleBar(
97
+ file.insertions,
98
+ file.deletions,
99
+ availableBarWidth,
100
+ )
101
+ return (
102
+ <text wrapMode="none">
103
+ {file.path}
104
+ {" | "}
105
+ <span style={{ fg: colors().success }}>
106
+ {"+".repeat(bar.plus)}
107
+ </span>
108
+ <span style={{ fg: colors().error }}>
109
+ {"-".repeat(bar.minus)}
110
+ </span>
111
+ </text>
112
+ )
113
+ }}
114
+ </For>
115
+ <text>
116
+ {s().totalFiles} file{s().totalFiles !== 1 ? "s" : ""} changed
117
+ <Show when={s().totalInsertions > 0}>
118
+ {", "}
119
+ <span style={{ fg: colors().success }}>
120
+ {s().totalInsertions} insertion
121
+ {s().totalInsertions !== 1 ? "s" : ""}(+)
122
+ </span>
123
+ </Show>
124
+ <Show when={s().totalDeletions > 0}>
125
+ {", "}
126
+ <span style={{ fg: colors().error }}>
127
+ {s().totalDeletions} deletion
128
+ {s().totalDeletions !== 1 ? "s" : ""}(-)
129
+ </span>
130
+ </Show>
131
+ </text>
132
+ <text fg={colors().textMuted}>{"─".repeat(props.maxWidth)}</text>
133
+ </>
134
+ )
135
+ }
136
+
137
+ function CommitHeader(props: {
138
+ commit: Commit
139
+ details: CommitDetails | null
140
+ maxWidth: number
141
+ }) {
142
+ const { colors } = useTheme()
143
+
144
+ // Stale-while-revalidate: show details as-is (may be stale from previous commit)
145
+ // until new details arrive. This prevents flash during navigation.
146
+ const subject = () => props.details?.subject || props.commit.description
147
+ const stats = () => props.details?.stats
148
+
149
+ const bodyLines = createMemo(() => {
150
+ const b = props.details?.body
151
+ return b ? b.split("\n") : null
152
+ })
153
+
154
+ return (
155
+ <box flexDirection="column" flexShrink={0}>
156
+ <text>
157
+ <span style={{ fg: colors().warning }}>{props.commit.changeId}</span>{" "}
158
+ <span style={{ fg: colors().textMuted }}>{props.commit.commitId}</span>
159
+ </text>
160
+ <text>
161
+ {"Author: "}
162
+ <span style={{ fg: colors().secondary }}>
163
+ {`${props.commit.author} <${props.commit.authorEmail}>`}
164
+ </span>
165
+ </text>
166
+ <text>
167
+ {"Date: "}
168
+ <span style={{ fg: colors().secondary }}>
169
+ {formatTimestamp(props.commit.timestamp)}
170
+ </span>
171
+ </text>
172
+ <text> </text>
173
+ <box flexDirection="row">
174
+ <text>{" "}</text>
175
+ <AnsiText content={subject()} wrapMode="none" />
176
+ </box>
177
+ <Show when={bodyLines()}>
178
+ {(lines: () => string[]) => (
179
+ <box flexDirection="column">
180
+ <text> </text>
181
+ <For each={lines()}>
182
+ {(line) => (
183
+ <text>
184
+ {" "}
185
+ {line}
186
+ </text>
187
+ )}
188
+ </For>
189
+ </box>
190
+ )}
191
+ </Show>
192
+ <Show when={stats()?.totalFiles ? stats() : undefined}>
193
+ {(s: () => DiffStats) => (
194
+ <box flexDirection="column">
195
+ <FileStats stats={s()} maxWidth={props.maxWidth} />
196
+ </box>
197
+ )}
198
+ </Show>
199
+ <text> </text>
200
+ </box>
201
+ )
202
+ }
203
+
204
+ export function MainArea() {
205
+ const {
206
+ activeCommit,
207
+ commitDetails,
208
+ diff,
209
+ diffLoading,
210
+ diffError,
211
+ diffLineCount,
212
+ mainAreaWidth,
213
+ } = useSync()
214
+ const { colors } = useTheme()
215
+ const focus = useFocus()
216
+ const command = useCommand()
217
+
218
+ let scrollRef: ScrollBoxRenderable | undefined
219
+
220
+ const [scrollTop, setScrollTop] = createSignal(0)
221
+ const [limit, setLimit] = createSignal(INITIAL_LIMIT)
222
+ const [currentCommitId, setCurrentCommitId] = createSignal<string | null>(
223
+ null,
224
+ )
225
+
226
+ createEffect(() => {
227
+ const commit = activeCommit()
228
+ if (commit && commit.changeId !== currentCommitId()) {
229
+ setCurrentCommitId(commit.changeId)
230
+ setScrollTop(0)
231
+ setLimit(INITIAL_LIMIT)
232
+ scrollRef?.scrollTo(0)
233
+ }
234
+ })
235
+
236
+ const loadMoreIfNeeded = () => {
237
+ if (!scrollRef) return
238
+ const viewportHeight = scrollRef.viewport?.height ?? 30
239
+ const scrollHeight = scrollRef.scrollHeight ?? 0
240
+ const currentScroll = scrollRef.scrollTop ?? 0
241
+
242
+ const distanceFromBottom = scrollHeight - (currentScroll + viewportHeight)
243
+ if (distanceFromBottom < LOAD_THRESHOLD && limit() < diffLineCount()) {
244
+ setLimit((l) => Math.min(l + LIMIT_INCREMENT, diffLineCount()))
245
+ }
246
+ }
247
+
248
+ onMount(() => {
249
+ const pollInterval = setInterval(() => {
250
+ if (scrollRef) {
251
+ const currentScroll = scrollRef.scrollTop ?? 0
252
+ if (currentScroll !== scrollTop()) {
253
+ setScrollTop(currentScroll)
254
+ loadMoreIfNeeded()
255
+ }
256
+ }
257
+ }, 100)
258
+ onCleanup(() => clearInterval(pollInterval))
259
+ })
260
+
261
+ const isFocused = () => focus.isPanel("detail")
262
+
263
+ command.register(() => [
264
+ {
265
+ id: "detail.page_up",
266
+ title: "Page up",
267
+ keybind: "nav_page_up",
268
+ context: "detail",
269
+ type: "navigation",
270
+ onSelect: () => scrollRef?.scrollBy(-0.5, "viewport"),
271
+ },
272
+ {
273
+ id: "detail.page_down",
274
+ title: "Page down",
275
+ keybind: "nav_page_down",
276
+ context: "detail",
277
+ type: "navigation",
278
+ onSelect: () => {
279
+ scrollRef?.scrollBy(0.5, "viewport")
280
+ loadMoreIfNeeded()
281
+ },
282
+ },
283
+ {
284
+ id: "detail.scroll_down",
285
+ title: "scroll down",
286
+ keybind: "nav_down",
287
+ context: "detail",
288
+ type: "navigation",
289
+ visibility: "help-only",
290
+ onSelect: () => {
291
+ scrollRef?.scrollTo((scrollTop() || 0) + 1)
292
+ setScrollTop((scrollTop() || 0) + 1)
293
+ loadMoreIfNeeded()
294
+ },
295
+ },
296
+ {
297
+ id: "detail.scroll_up",
298
+ title: "scroll up",
299
+ keybind: "nav_up",
300
+ context: "detail",
301
+ type: "navigation",
302
+ visibility: "help-only",
303
+ onSelect: () => {
304
+ const newPos = Math.max(0, (scrollTop() || 0) - 1)
305
+ scrollRef?.scrollTo(newPos)
306
+ setScrollTop(newPos)
307
+ },
308
+ },
309
+ ])
310
+
311
+ return (
312
+ <Panel title="Detail" hotkey="3" panelId="detail" focused={isFocused()}>
313
+ <Show when={diffLoading() && !diff()}>
314
+ <text>Loading diff...</text>
315
+ </Show>
316
+ <Show when={diffError()}>
317
+ <text>Error: {diffError()}</text>
318
+ </Show>
319
+ <Show when={diff() || (!diffLoading() && !diffError())}>
320
+ <scrollbox
321
+ ref={scrollRef}
322
+ focused={isFocused()}
323
+ flexGrow={1}
324
+ scrollbarOptions={{
325
+ trackOptions: {
326
+ backgroundColor: colors().scrollbarTrack,
327
+ foregroundColor: colors().scrollbarThumb,
328
+ },
329
+ }}
330
+ >
331
+ <Show when={activeCommit()}>
332
+ {(commit: () => Commit) => (
333
+ <CommitHeader
334
+ commit={commit()}
335
+ details={commitDetails()}
336
+ maxWidth={mainAreaWidth()}
337
+ />
338
+ )}
339
+ </Show>
340
+ <Show when={diff()}>
341
+ <ghostty-terminal
342
+ ansi={diff() ?? ""}
343
+ cols={mainAreaWidth()}
344
+ limit={limit()}
345
+ />
346
+ </Show>
347
+ <Show when={!diff()}>
348
+ <text>No changes in this commit.</text>
349
+ </Show>
350
+ </scrollbox>
351
+ </Show>
352
+ </Panel>
353
+ )
354
+ }
@@ -0,0 +1,106 @@
1
+ import { useKeyboard } from "@opentui/solid"
2
+ import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js"
3
+ import type { KeybindConfigKey } from "../keybind"
4
+ import { useDialog } from "./dialog"
5
+ import { useFocus } from "./focus"
6
+ import { createSimpleContext } from "./helper"
7
+ import { useKeybind } from "./keybind"
8
+ import type { CommandType, CommandVisibility, Context, Panel } from "./types"
9
+
10
+ export type { CommandType, CommandVisibility, Context }
11
+
12
+ export type CommandOption = {
13
+ id: string
14
+ title: string
15
+ keybind?: KeybindConfigKey
16
+ context: Context
17
+ type: CommandType
18
+ panel?: Panel
19
+ visibility?: CommandVisibility
20
+ onSelect: () => void
21
+ }
22
+
23
+ function contextMatches(
24
+ commandContext: Context,
25
+ activeContext: Context,
26
+ ): boolean {
27
+ if (commandContext === "global") return true
28
+ if (commandContext === activeContext) return true
29
+ return activeContext.startsWith(`${commandContext}.`)
30
+ }
31
+
32
+ export const { use: useCommand, provider: CommandProvider } =
33
+ createSimpleContext({
34
+ name: "Command",
35
+ init: () => {
36
+ const [registrations, setRegistrations] = createSignal<
37
+ Accessor<CommandOption[]>[]
38
+ >([])
39
+ const keybind = useKeybind()
40
+ const focus = useFocus()
41
+ const dialog = useDialog()
42
+
43
+ const allCommands = createMemo(() => {
44
+ return registrations().flatMap((r) => r())
45
+ })
46
+
47
+ useKeyboard((evt) => {
48
+ if (evt.defaultPrevented) return
49
+
50
+ const dialogOpen = dialog.isOpen()
51
+ const activeCtx = focus.activeContext()
52
+ const activePanel = focus.panel()
53
+
54
+ let mostSpecificMatch: CommandOption | null = null
55
+ let highestContextSpecificity = -1
56
+
57
+ for (const cmd of allCommands()) {
58
+ if (dialogOpen && cmd.keybind !== "help") {
59
+ continue
60
+ }
61
+
62
+ if (!dialogOpen) {
63
+ if (!contextMatches(cmd.context, activeCtx)) {
64
+ continue
65
+ }
66
+ if (cmd.panel && cmd.panel !== activePanel) {
67
+ continue
68
+ }
69
+ }
70
+
71
+ if (cmd.keybind && keybind.match(cmd.keybind, evt)) {
72
+ const contextSpecificity =
73
+ cmd.context === activeCtx
74
+ ? Number.MAX_SAFE_INTEGER
75
+ : cmd.context.length
76
+ if (contextSpecificity > highestContextSpecificity) {
77
+ mostSpecificMatch = cmd
78
+ highestContextSpecificity = contextSpecificity
79
+ }
80
+ }
81
+ }
82
+
83
+ if (mostSpecificMatch) {
84
+ evt.preventDefault()
85
+ mostSpecificMatch.onSelect()
86
+ }
87
+ })
88
+
89
+ return {
90
+ register: (cb: () => CommandOption[]) => {
91
+ const accessor = createMemo(cb)
92
+ setRegistrations((arr) => [...arr, accessor])
93
+ onCleanup(() => {
94
+ setRegistrations((arr) => arr.filter((r) => r !== accessor))
95
+ })
96
+ },
97
+
98
+ trigger: (id: string) => {
99
+ const cmd = allCommands().find((c) => c.id === id)
100
+ cmd?.onSelect()
101
+ },
102
+
103
+ all: allCommands,
104
+ }
105
+ },
106
+ })
@@ -0,0 +1,45 @@
1
+ import { createSignal } from "solid-js"
2
+ import type { OperationResult } from "../commander/operations"
3
+ import { createSimpleContext } from "./helper"
4
+
5
+ export interface CommandLogEntry {
6
+ id: string
7
+ command: string
8
+ output: string
9
+ success: boolean
10
+ timestamp: Date
11
+ }
12
+
13
+ export const { use: useCommandLog, provider: CommandLogProvider } =
14
+ createSimpleContext({
15
+ name: "CommandLog",
16
+ init: () => {
17
+ const [entries, setEntries] = createSignal<CommandLogEntry[]>([])
18
+
19
+ const addEntry = (result: OperationResult) => {
20
+ const entry: CommandLogEntry = {
21
+ id: crypto.randomUUID(),
22
+ command: result.command,
23
+ output: result.success
24
+ ? result.stdout.trim() || "Done"
25
+ : result.stderr.trim() || result.stdout.trim() || "Failed",
26
+ success: result.success,
27
+ timestamp: new Date(),
28
+ }
29
+ setEntries((prev) => [...prev, entry])
30
+ }
31
+
32
+ const clear = () => {
33
+ setEntries([])
34
+ }
35
+
36
+ const latest = () => entries().at(-1)
37
+
38
+ return {
39
+ entries,
40
+ addEntry,
41
+ clear,
42
+ latest,
43
+ }
44
+ },
45
+ })
@@ -0,0 +1,217 @@
1
+ import { RGBA } from "@opentui/core"
2
+ import { useKeyboard, useRenderer } from "@opentui/solid"
3
+ import {
4
+ type JSX,
5
+ type ParentProps,
6
+ Show,
7
+ createSignal,
8
+ onCleanup,
9
+ onMount,
10
+ } from "solid-js"
11
+ import { createSimpleContext } from "./helper"
12
+ import { useTheme } from "./theme"
13
+
14
+ export interface DialogHint {
15
+ key: string
16
+ label: string
17
+ }
18
+
19
+ interface DialogState {
20
+ id?: string
21
+ render: () => JSX.Element
22
+ onClose?: () => void
23
+ hints?: DialogHint[]
24
+ }
25
+
26
+ interface ConfirmOptions {
27
+ message: string
28
+ }
29
+
30
+ function ConfirmDialogContent(props: {
31
+ message: string
32
+ onResolve: (confirmed: boolean) => void
33
+ }) {
34
+ const { colors, style } = useTheme()
35
+
36
+ useKeyboard((evt) => {
37
+ if (evt.name === "y" || evt.name === "return") {
38
+ evt.preventDefault()
39
+ props.onResolve(true)
40
+ } else if (evt.name === "n" || evt.name === "escape") {
41
+ evt.preventDefault()
42
+ props.onResolve(false)
43
+ }
44
+ })
45
+
46
+ return (
47
+ <box
48
+ flexDirection="column"
49
+ border
50
+ borderStyle={style().panel.borderStyle}
51
+ borderColor={colors().borderFocused}
52
+ backgroundColor={colors().background}
53
+ paddingLeft={2}
54
+ width="50%"
55
+ >
56
+ <text fg={colors().text}>{props.message}</text>
57
+ </box>
58
+ )
59
+ }
60
+
61
+ export const { use: useDialog, provider: DialogProvider } = createSimpleContext(
62
+ {
63
+ name: "Dialog",
64
+ init: () => {
65
+ const [stack, setStack] = createSignal<DialogState[]>([])
66
+
67
+ const close = () => {
68
+ const current = stack().at(-1)
69
+ current?.onClose?.()
70
+ setStack((s) => s.slice(0, -1))
71
+ }
72
+
73
+ useKeyboard((evt) => {
74
+ if (stack().length > 0 && evt.name === "escape") {
75
+ evt.preventDefault()
76
+ close()
77
+ }
78
+ })
79
+
80
+ const open = (
81
+ render: () => JSX.Element,
82
+ options?: { id?: string; onClose?: () => void; hints?: DialogHint[] },
83
+ ) => {
84
+ setStack((s) => [
85
+ ...s,
86
+ {
87
+ id: options?.id,
88
+ render,
89
+ onClose: options?.onClose,
90
+ hints: options?.hints,
91
+ },
92
+ ])
93
+ }
94
+
95
+ const toggle = (
96
+ id: string,
97
+ render: () => JSX.Element,
98
+ options?: { onClose?: () => void; hints?: DialogHint[] },
99
+ ) => {
100
+ const current = stack().at(-1)
101
+ if (current?.id === id) {
102
+ close()
103
+ } else {
104
+ open(render, { id, onClose: options?.onClose, hints: options?.hints })
105
+ }
106
+ }
107
+
108
+ const confirm = (options: ConfirmOptions): Promise<boolean> => {
109
+ return new Promise((resolve) => {
110
+ let resolved = false
111
+ const handleResolve = (confirmed: boolean) => {
112
+ if (resolved) return
113
+ resolved = true
114
+ close()
115
+ resolve(confirmed)
116
+ }
117
+ open(
118
+ () => (
119
+ <ConfirmDialogContent
120
+ message={options.message}
121
+ onResolve={handleResolve}
122
+ />
123
+ ),
124
+ {
125
+ id: "confirm-dialog",
126
+ hints: [
127
+ { key: "y", label: "confirm" },
128
+ { key: "n", label: "cancel" },
129
+ ],
130
+ onClose: () => {
131
+ if (!resolved) {
132
+ resolved = true
133
+ resolve(false)
134
+ }
135
+ },
136
+ },
137
+ )
138
+ })
139
+ }
140
+
141
+ return {
142
+ isOpen: () => stack().length > 0,
143
+ current: () => stack().at(-1),
144
+ hints: () => stack().at(-1)?.hints ?? [],
145
+ setHints: (hints: DialogHint[]) => {
146
+ setStack((s) => {
147
+ if (s.length === 0) return s
148
+ const last = s[s.length - 1]
149
+ if (!last) return s
150
+ return [...s.slice(0, -1), { ...last, hints }]
151
+ })
152
+ },
153
+ open,
154
+ toggle,
155
+ close,
156
+ confirm,
157
+ clear: () => {
158
+ for (const item of stack()) {
159
+ item.onClose?.()
160
+ }
161
+ setStack([])
162
+ },
163
+ }
164
+ },
165
+ },
166
+ )
167
+
168
+ function DialogBackdrop(props: { onClose: () => void; children: JSX.Element }) {
169
+ const renderer = useRenderer()
170
+ const { style } = useTheme()
171
+ const [dimensions, setDimensions] = createSignal({
172
+ width: renderer.width,
173
+ height: renderer.height,
174
+ })
175
+
176
+ onMount(() => {
177
+ const handleResize = (width: number, height: number) => {
178
+ setDimensions({ width, height })
179
+ }
180
+ renderer.on("resize", handleResize)
181
+ onCleanup(() => renderer.off("resize", handleResize))
182
+ })
183
+
184
+ const overlayColor = () =>
185
+ RGBA.fromInts(0, 0, 0, style().dialog.overlayOpacity)
186
+
187
+ return (
188
+ <box
189
+ position="absolute"
190
+ left={0}
191
+ top={0}
192
+ width={dimensions().width}
193
+ height={dimensions().height}
194
+ backgroundColor={overlayColor()}
195
+ flexDirection="column"
196
+ justifyContent="center"
197
+ alignItems="center"
198
+ >
199
+ {props.children}
200
+ </box>
201
+ )
202
+ }
203
+
204
+ export function DialogContainer(props: ParentProps) {
205
+ const dialog = useDialog()
206
+
207
+ return (
208
+ <box flexGrow={1} width="100%" height="100%">
209
+ {props.children}
210
+ <Show when={dialog.isOpen()}>
211
+ <DialogBackdrop onClose={dialog.close}>
212
+ {dialog.current()?.render()}
213
+ </DialogBackdrop>
214
+ </Show>
215
+ </box>
216
+ )
217
+ }