@brimveyn/aimux 1.12.2 → 1.13.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 (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +18 -2
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/pty-write.ts +52 -0
  8. package/src/app-runtime/selection-scroll.ts +2 -2
  9. package/src/app-runtime/session-actions.ts +16 -4
  10. package/src/app-runtime/side-effects.ts +598 -102
  11. package/src/app-runtime/snippet-actions.ts +25 -11
  12. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  13. package/src/app-runtime/use-backend-runtime.ts +3 -3
  14. package/src/app-runtime/use-directory-search.ts +7 -5
  15. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  16. package/src/app-runtime/use-pane-size-report.ts +1 -1
  17. package/src/app-runtime/use-renderer-bindings.ts +143 -8
  18. package/src/app.tsx +25 -9
  19. package/src/auto-commit/headless-commands.ts +4 -6
  20. package/src/daemon/daemon.ts +3 -3
  21. package/src/daemon/runtime-paths.ts +1 -1
  22. package/src/daemon/session-manager.ts +1 -1
  23. package/src/daemon/session-registry.ts +10 -3
  24. package/src/diff-parser/parse-patch-files.ts +18 -18
  25. package/src/git/command-queue.ts +1 -1
  26. package/src/git/divergence.ts +46 -0
  27. package/src/git/git-diff.ts +12 -5
  28. package/src/git/git-poller.ts +33 -11
  29. package/src/git/git-status.ts +14 -3
  30. package/src/git/move-worktree.ts +96 -0
  31. package/src/git/use-repo-discovery.ts +2 -1
  32. package/src/git/worktree-divergence-poller.ts +59 -0
  33. package/src/git/worktree.ts +99 -0
  34. package/src/index.tsx +1 -1
  35. package/src/input/keymap/describe-bindings.ts +27 -9
  36. package/src/input/keymap/key-chord.ts +4 -4
  37. package/src/input/keymap/key-format.ts +2 -2
  38. package/src/input/keymap/sequence-resolver.ts +1 -1
  39. package/src/input/keymap/trie.ts +1 -1
  40. package/src/input/modes/bridge.ts +7 -0
  41. package/src/input/modes/transitions.ts +2 -1
  42. package/src/input/modes/types.ts +13 -1
  43. package/src/input/raw-input-handler.ts +34 -1
  44. package/src/input/terminal-text-extraction.ts +9 -5
  45. package/src/integrations/claude-syntax-overlay.ts +8 -8
  46. package/src/integrations/claude-theme-sync.ts +12 -12
  47. package/src/ipc/protocol.ts +3 -3
  48. package/src/platform/clipboard.ts +18 -2
  49. package/src/platform/worktree-deps.ts +22 -0
  50. package/src/platform/worktree-paths.ts +67 -0
  51. package/src/profile-paths.ts +3 -3
  52. package/src/pty/assistant-status-detection-loop.ts +2 -2
  53. package/src/pty/assistant-status-detector.ts +15 -6
  54. package/src/pty/command-registry.ts +8 -1
  55. package/src/pty/pty-manager.ts +1 -1
  56. package/src/services/ai-usage/adapters/claude.ts +5 -5
  57. package/src/services/ai-usage/adapters/codex.ts +8 -8
  58. package/src/services/ai-usage/cache.ts +2 -2
  59. package/src/services/ai-usage/pace.ts +3 -6
  60. package/src/services/ai-usage/provider.ts +1 -1
  61. package/src/session-backend/bootstrap.ts +2 -2
  62. package/src/session-backend/local-session-backend.ts +11 -11
  63. package/src/session-backend/remote-session-backend.ts +3 -3
  64. package/src/session-backend/types.ts +2 -2
  65. package/src/snippets/expand-variables.ts +112 -0
  66. package/src/snippets/run-shell-var.ts +93 -0
  67. package/src/snippets/trigger-detector.ts +105 -0
  68. package/src/state/ai-usage-store.ts +1 -1
  69. package/src/state/git-tree.ts +8 -4
  70. package/src/state/layout-tree.ts +8 -5
  71. package/src/state/reducers/git-mode-state.ts +44 -14
  72. package/src/state/reducers/git-panel-state.ts +38 -2
  73. package/src/state/reducers/modal-state.ts +385 -24
  74. package/src/state/reducers/session-state.ts +60 -1
  75. package/src/state/reducers/tab-state.ts +151 -85
  76. package/src/state/selectors.ts +6 -4
  77. package/src/state/session-catalog.ts +20 -5
  78. package/src/state/session-persistence.ts +8 -4
  79. package/src/state/session-worktrees.ts +227 -0
  80. package/src/state/snippet-catalog.ts +65 -4
  81. package/src/state/store.ts +1 -0
  82. package/src/state/toast-store.ts +95 -0
  83. package/src/state/types.ts +108 -16
  84. package/src/state/validation.ts +47 -3
  85. package/src/terminal-manager/manager-client.ts +15 -13
  86. package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
  87. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  88. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  89. package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
  90. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  91. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  92. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  93. package/src/ui/components/git/git-panel.tsx +17 -10
  94. package/src/ui/components/git/git-view.tsx +79 -19
  95. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  96. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
  97. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  98. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  99. package/src/ui/components/layout/session-bar.tsx +3 -3
  100. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  101. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  102. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  103. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  104. package/src/ui/components/layout/split-layout.tsx +1 -1
  105. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  106. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  107. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  108. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  109. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  110. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  111. package/src/ui/components/modals/shared/form.tsx +4 -5
  112. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  113. package/src/ui/components/modals/shared/picker.tsx +28 -5
  114. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
  115. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
  116. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  117. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  118. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +3 -3
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +65 -14
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -0,0 +1,227 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync } from 'node:fs'
4
+ import { basename } from 'node:path'
5
+
6
+ import type { SessionRecord, TabSession, WorktreeRecord } from './types'
7
+
8
+ import { createPrefixedId } from '../platform/id'
9
+ import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
10
+
11
+ const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
12
+
13
+ export function getWorktreeColor(worktreeId: string): string {
14
+ let hash = 0
15
+ for (let i = 0; i < worktreeId.length; i++) {
16
+ hash = (hash * 31 + worktreeId.charCodeAt(i)) >>> 0
17
+ }
18
+ return WORKTREE_COLORS[hash % WORKTREE_COLORS.length] ?? '#7dd3fc'
19
+ }
20
+
21
+ export function createPrimaryWorktree(projectPath: string, now: string): WorktreeRecord {
22
+ const id = createPrefixedId('worktree')
23
+ return {
24
+ createdAt: now,
25
+ createdByAimux: false,
26
+ id,
27
+ name: basename(projectPath) || projectPath,
28
+ path: projectPath,
29
+ repoRoot: projectPath,
30
+ source: 'primary',
31
+ updatedAt: now,
32
+ }
33
+ }
34
+
35
+ export function ensureSessionWorktrees(
36
+ session: SessionRecord,
37
+ now = new Date().toISOString()
38
+ ): SessionRecord {
39
+ if (session.worktrees?.length != null && session.worktrees?.length !== 0) {
40
+ const worktrees = mergeExistingGitWorktrees(
41
+ pruneMissingAimuxTempWorktrees(session.worktrees),
42
+ now
43
+ )
44
+ if (worktrees.length === 0 && session.projectPath != null && session.projectPath !== '') {
45
+ const worktree = createPrimaryWorktree(session.projectPath, now)
46
+ return { ...session, activeWorktreeId: worktree.id, worktrees: [worktree] }
47
+ }
48
+ const activeWorktreeId = worktrees.some((w) => w.id === session.activeWorktreeId)
49
+ ? session.activeWorktreeId
50
+ : worktrees[0]?.id
51
+ const active = worktrees.find((w) => w.id === activeWorktreeId)
52
+ return {
53
+ ...session,
54
+ activeWorktreeId,
55
+ projectPath: active?.path ?? session.projectPath,
56
+ worktrees,
57
+ }
58
+ }
59
+
60
+ if (!(session.projectPath != null && session.projectPath !== '')) {
61
+ return session
62
+ }
63
+
64
+ const worktree = createPrimaryWorktree(session.projectPath, now)
65
+ const worktrees = mergeExistingGitWorktrees([worktree], now)
66
+ return {
67
+ ...session,
68
+ activeWorktreeId:
69
+ worktrees.find((entry) => entry.path === session.projectPath)?.id ?? worktree.id,
70
+ worktrees,
71
+ }
72
+ }
73
+
74
+ function stableWorktreeId(path: string): string {
75
+ return `worktree-${createHash('sha1').update(path).digest('hex').slice(0, 10)}`
76
+ }
77
+
78
+ function mergeExistingGitWorktrees(worktrees: WorktreeRecord[], now: string): WorktreeRecord[] {
79
+ const anchor = worktrees.find((worktree) => existsSync(worktree.path)) ?? worktrees[0]
80
+ if (!anchor) return worktrees
81
+ const discovered = listGitWorktreesSync(anchor.path)
82
+ if (discovered.length === 0) return worktrees
83
+
84
+ // Git keeps admin entries for worktrees whose directory is gone (e.g. a temp
85
+ // dir cleared on reboot). Drop that stale state so the entries stop
86
+ // resurfacing in `git worktree list`, and ignore them while reconciling.
87
+ if (discovered.some((entry) => entry.prunable === true)) {
88
+ pruneGitWorktreesSync(anchor.path)
89
+ }
90
+ const live = discovered.filter((entry) => entry.prunable !== true)
91
+
92
+ const byPath = new Map(worktrees.map((worktree) => [worktree.path, worktree]))
93
+ // `git worktree list` reports the main worktree first; prefer the primary
94
+ // record's repoRoot when it still exists, then git's main worktree.
95
+ const primaryRepoRoot = worktrees.find((worktree) => worktree.source === 'primary')?.repoRoot
96
+ const repoRoot =
97
+ primaryRepoRoot !== undefined && existsSync(primaryRepoRoot)
98
+ ? primaryRepoRoot
99
+ : (live[0]?.path ?? anchor.repoRoot)
100
+ for (const entry of live) {
101
+ if (byPath.has(entry.path)) continue
102
+ if (isInsideAimuxWorktreeRoot(entry.path)) continue
103
+ const worktree: WorktreeRecord = {
104
+ branch: entry.branch,
105
+ commitSha: entry.head,
106
+ createdAt: now,
107
+ createdByAimux: false,
108
+ id: stableWorktreeId(entry.path),
109
+ name: basename(entry.path) || entry.path,
110
+ path: entry.path,
111
+ repoRoot,
112
+ source: 'external',
113
+ updatedAt: now,
114
+ }
115
+ byPath.set(entry.path, worktree)
116
+ }
117
+ return (
118
+ [...byPath.values()]
119
+ // Drop records whose directory no longer exists (a deleted external
120
+ // worktree that git already pruned) so they don't reappear on restart.
121
+ .filter((worktree) => worktree.source === 'primary' || existsSync(worktree.path))
122
+ // Heal records whose repoRoot points at a since-deleted sibling worktree
123
+ // so later git operations (e.g. `git -C repoRoot`) don't fail.
124
+ .map((worktree) =>
125
+ existsSync(worktree.repoRoot) ? worktree : { ...worktree, repoRoot, updatedAt: now }
126
+ )
127
+ )
128
+ }
129
+
130
+ interface DiscoveredWorktree {
131
+ path: string
132
+ head?: string
133
+ branch?: string
134
+ prunable?: boolean
135
+ }
136
+
137
+ function listGitWorktreesSync(cwd: string): DiscoveredWorktree[] {
138
+ const result = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
139
+ encoding: 'utf8',
140
+ })
141
+ if (result.status !== 0 || !result.stdout) return []
142
+ const worktrees: DiscoveredWorktree[] = []
143
+ let current: DiscoveredWorktree | null = null
144
+ for (const line of result.stdout.split('\n')) {
145
+ if (line.startsWith('worktree ')) {
146
+ if (current) worktrees.push(current)
147
+ current = { path: line.slice('worktree '.length) }
148
+ } else if (current && line.startsWith('HEAD ')) {
149
+ current.head = line.slice('HEAD '.length)
150
+ } else if (current && line.startsWith('branch ')) {
151
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
152
+ } else if (current && line.startsWith('prunable')) {
153
+ current.prunable = true
154
+ }
155
+ }
156
+ if (current) worktrees.push(current)
157
+ return worktrees
158
+ }
159
+
160
+ function pruneGitWorktreesSync(cwd: string): void {
161
+ spawnSync('git', ['-C', cwd, 'worktree', 'prune'], { encoding: 'utf8' })
162
+ }
163
+
164
+ function pruneMissingAimuxTempWorktrees(worktrees: WorktreeRecord[]): WorktreeRecord[] {
165
+ return worktrees.filter((worktree) => {
166
+ if (
167
+ worktree.source !== 'aimux-temp' ||
168
+ !worktree.createdByAimux ||
169
+ !isInsideAimuxWorktreeRoot(worktree.path)
170
+ ) {
171
+ return true
172
+ }
173
+ return existsSync(worktree.path)
174
+ })
175
+ }
176
+
177
+ export function getActiveWorktree(session: SessionRecord | undefined): WorktreeRecord | undefined {
178
+ if (!(session?.worktrees?.length != null && session?.worktrees?.length !== 0)) return undefined
179
+ return (
180
+ session.worktrees.find((worktree) => worktree.id === session.activeWorktreeId) ??
181
+ session.worktrees[0]
182
+ )
183
+ }
184
+
185
+ export function getSessionProjectPath(session: SessionRecord | undefined): string | undefined {
186
+ return getActiveWorktree(session)?.path ?? session?.projectPath
187
+ }
188
+
189
+ export function withActiveWorktree(session: SessionRecord, worktreeId: string): SessionRecord {
190
+ const worktree = session.worktrees?.find((entry) => entry.id === worktreeId)
191
+ if (!worktree) return session
192
+ return {
193
+ ...session,
194
+ activeWorktreeId: worktreeId,
195
+ projectPath: worktree.path,
196
+ updatedAt: new Date().toISOString(),
197
+ }
198
+ }
199
+
200
+ export function getRenderedTabWorktreeId(
201
+ tab: { worktreeId?: string },
202
+ worktrees: { id: string }[]
203
+ ): string {
204
+ return tab.worktreeId ?? worktrees[0]?.id ?? '__main__'
205
+ }
206
+
207
+ export function orderTabsByWorktree(
208
+ tabs: TabSession[],
209
+ session: SessionRecord | undefined
210
+ ): TabSession[] {
211
+ const worktrees = session?.worktrees ?? []
212
+ const order = new Map<string, number>()
213
+ for (const [index, worktree] of worktrees.entries()) {
214
+ order.set(worktree.id, index)
215
+ }
216
+ order.set('__main__', order.size)
217
+
218
+ return tabs
219
+ .map((tab, index) => ({ index, tab }))
220
+ .sort((a, b) => {
221
+ const aw = order.get(getRenderedTabWorktreeId(a.tab, worktrees)) ?? Number.MAX_SAFE_INTEGER
222
+ const bw = order.get(getRenderedTabWorktreeId(b.tab, worktrees)) ?? Number.MAX_SAFE_INTEGER
223
+ if (aw !== bw) return aw - bw
224
+ return a.index - b.index
225
+ })
226
+ .map((entry) => entry.tab)
227
+ }
@@ -1,3 +1,5 @@
1
+ import type { SnippetDef, SnippetVar } from '@brimveyn/aimux-config'
2
+
1
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
4
  import { join } from 'node:path'
3
5
 
@@ -9,9 +11,15 @@ export interface SnippetRecord {
9
11
  id: string
10
12
  name: string
11
13
  content: string
14
+ trigger?: string
15
+ vars?: Record<string, SnippetVar>
16
+ }
17
+
18
+ export function getSnippetsCatalogPath(): string {
19
+ return join(getProfileConfigDir(), 'aimux-snippets.json')
12
20
  }
13
21
 
14
- const SNIPPETS_PATH = join(getProfileConfigDir(), 'aimux-snippets.json')
22
+ const SNIPPETS_PATH = getSnippetsCatalogPath()
15
23
 
16
24
  const DEFAULT_SNIPPETS: SnippetRecord[] = [
17
25
  {
@@ -52,7 +60,7 @@ export function loadSnippetCatalog(): SnippetRecord[] {
52
60
  version?: unknown
53
61
  snippets?: unknown
54
62
  }
55
- if (parsed.version !== 1 || !Array.isArray(parsed.snippets)) {
63
+ if ((parsed.version !== 1 && parsed.version !== 2) || !Array.isArray(parsed.snippets)) {
56
64
  logDebug('snippets.catalog.loadIssue', {
57
65
  issue: 'invalid snippet catalog header',
58
66
  path: SNIPPETS_PATH,
@@ -66,7 +74,7 @@ export function loadSnippetCatalog(): SnippetRecord[] {
66
74
  })
67
75
  return []
68
76
  }
69
- return parsed.snippets
77
+ return parsed.snippets.map(stripUserVars)
70
78
  } catch (error) {
71
79
  logDebug('snippets.catalog.loadIssue', {
72
80
  issue: error instanceof Error ? error.message : String(error),
@@ -79,7 +87,16 @@ export function loadSnippetCatalog(): SnippetRecord[] {
79
87
  export function saveSnippetCatalog(snippets: SnippetRecord[]): void {
80
88
  try {
81
89
  mkdirSync(getProfileConfigDir(), { recursive: true })
82
- writeFileSync(SNIPPETS_PATH, `${JSON.stringify({ snippets, version: 1 }, null, 2)}\n`)
90
+ // Persist only user-owned snippets. Config-pinned entries are reapplied
91
+ // at boot from `aimux.config.ts`.
92
+ const userSnippets = snippets.filter((s) => !isConfigSnippetId(s.id)).map(stripUserVars)
93
+ // Schema v2 adds the optional `trigger` and `vars` fields. v1 files are
94
+ // still accepted on read (they validate as v2 — both fields are optional)
95
+ // and get rewritten as v2 on the next save.
96
+ writeFileSync(
97
+ SNIPPETS_PATH,
98
+ `${JSON.stringify({ snippets: userSnippets, version: 2 }, null, 2)}\n`
99
+ )
83
100
  } catch (error) {
84
101
  logDebug('snippets.catalog.saveError', {
85
102
  error: error instanceof Error ? error.message : String(error),
@@ -88,3 +105,47 @@ export function saveSnippetCatalog(snippets: SnippetRecord[]): void {
88
105
  })
89
106
  }
90
107
  }
108
+
109
+ export const CONFIG_SNIPPET_ID_PREFIX = 'config:'
110
+
111
+ export function isConfigSnippetId(id: string): boolean {
112
+ return id.startsWith(CONFIG_SNIPPET_ID_PREFIX)
113
+ }
114
+
115
+ /**
116
+ * Shell `vars` are only authorized on config-pinned snippets (those defined in
117
+ * `aimux.config.ts`). If they ever appear on a user-edited JSON snippet — by
118
+ * hand edit, restore, or import — strip them. This keeps shell execution
119
+ * gated by the user's TypeScript config file.
120
+ *
121
+ * Exported for testing; called at both load and save time.
122
+ */
123
+ export function stripUserVars(snippet: SnippetRecord): SnippetRecord {
124
+ if (snippet.vars === undefined) return snippet
125
+ if (isConfigSnippetId(snippet.id)) return snippet
126
+ logDebug('snippets.catalog.strippedVars', { id: snippet.id, name: snippet.name })
127
+ const { vars, ...clean } = snippet
128
+ void vars
129
+ return clean
130
+ }
131
+
132
+ /**
133
+ * Merge config-defined snippets with user-edited snippets.
134
+ * Config-pinned snippets ("sticky") get a stable id `config:${name}` and win
135
+ * over a user-edited snippet with the same id (they're read-only in the UI).
136
+ */
137
+ export function mergeConfigSnippets(
138
+ userSnippets: readonly SnippetRecord[],
139
+ configSnippets: readonly SnippetDef[]
140
+ ): SnippetRecord[] {
141
+ const fromConfig: SnippetRecord[] = configSnippets.map((s) => ({
142
+ content: s.text,
143
+ id: `${CONFIG_SNIPPET_ID_PREFIX}${s.name}`,
144
+ name: s.name,
145
+ trigger: s.trigger,
146
+ vars: s.vars,
147
+ }))
148
+ const configIds = new Set(fromConfig.map((s) => s.id))
149
+ const userKept = userSnippets.filter((s) => !configIds.has(s.id))
150
+ return [...fromConfig, ...userKept]
151
+ }
@@ -117,6 +117,7 @@ export function createInitialState(
117
117
  snippets,
118
118
  tabGroupMap: {},
119
119
  tabs: [],
120
+ worktreeDivergence: {},
120
121
  }
121
122
  }
122
123
 
@@ -0,0 +1,95 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { useStore } from 'zustand'
4
+ import { createStore } from 'zustand/vanilla'
5
+
6
+ import { createPrefixedId } from '../platform/id'
7
+
8
+ export type ToastVariant = 'info' | 'success' | 'warning' | 'error'
9
+ export type ToastPosition = `${'top' | 'bottom'}-${'left' | 'center' | 'right'}`
10
+
11
+ export interface ToastOptions {
12
+ title?: string
13
+ message?: string
14
+ /** Custom body — rendered inside the toast shell instead of title/message. */
15
+ content?: ReactNode
16
+ variant?: ToastVariant
17
+ /** Auto-dismiss after this many ms; 0 keeps it until dismissed. */
18
+ durationMs?: number
19
+ position?: ToastPosition
20
+ }
21
+
22
+ export interface Toast {
23
+ id: string
24
+ createdAt: number
25
+ content?: ReactNode
26
+ durationMs: number
27
+ message?: string
28
+ position: ToastPosition
29
+ title?: string
30
+ variant: ToastVariant
31
+ }
32
+
33
+ export const TOAST_CONFIG = {
34
+ defaultPosition: 'top-right' as ToastPosition,
35
+ // Default auto-dismiss per variant when `durationMs` isn't given. Errors (and
36
+ // warnings) linger longer than confirmations so they're not missed.
37
+ durations: { error: 7000, info: 3500, success: 3500, warning: 5000 } as Record<
38
+ ToastVariant,
39
+ number
40
+ >,
41
+ gap: 1,
42
+ /** Most recent toasts kept visible per position; older ones are evicted. */
43
+ maxVisible: 4,
44
+ /** Instant (no slide) — set in tests or for reduced motion. */
45
+ reduceMotion: false,
46
+ width: 40,
47
+ }
48
+
49
+ interface ToastStoreState {
50
+ toasts: Toast[]
51
+ show: (opts: ToastOptions) => string
52
+ dismiss: (id: string) => void
53
+ clear: () => void
54
+ }
55
+
56
+ export const toastStore = createStore<ToastStoreState>((set) => ({
57
+ clear: () => set({ toasts: [] }),
58
+ dismiss: (id) => set((state) => ({ toasts: state.toasts.filter((entry) => entry.id !== id) })),
59
+ show: (opts) => {
60
+ const id = createPrefixedId('toast')
61
+ const variant = opts.variant ?? 'info'
62
+ const next: Toast = {
63
+ content: opts.content,
64
+ createdAt: Date.now(),
65
+ durationMs: opts.durationMs ?? TOAST_CONFIG.durations[variant],
66
+ id,
67
+ message: opts.message,
68
+ position: opts.position ?? TOAST_CONFIG.defaultPosition,
69
+ title: opts.title,
70
+ variant,
71
+ }
72
+ set((state) => ({ toasts: [...state.toasts, next] }))
73
+ return id
74
+ },
75
+ toasts: [],
76
+ }))
77
+
78
+ export function useToastStore<T>(selector: (state: ToastStoreState) => T): T {
79
+ return useStore(toastStore, selector)
80
+ }
81
+
82
+ function variantShortcut(variant: ToastVariant) {
83
+ return (message: string, opts: Omit<ToastOptions, 'message' | 'variant'> = {}): string =>
84
+ toastStore.getState().show({ ...opts, message, variant })
85
+ }
86
+
87
+ // Imperative API usable anywhere (components, side effects) without React.
88
+ export const toast = {
89
+ dismiss: (id: string) => toastStore.getState().dismiss(id),
90
+ error: variantShortcut('error'),
91
+ info: variantShortcut('info'),
92
+ show: (opts: ToastOptions) => toastStore.getState().show(opts),
93
+ success: variantShortcut('success'),
94
+ warning: variantShortcut('warning'),
95
+ }