@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.
- package/README.md +3 -0
- package/package.json +4 -3
- package/src/app-runtime/auto-commit-driver.ts +8 -4
- package/src/app-runtime/auto-commit-ref.ts +1 -1
- package/src/app-runtime/backend-attach-runtime.ts +18 -2
- package/src/app-runtime/backend-runtime-events.ts +1 -1
- package/src/app-runtime/pty-write.ts +52 -0
- package/src/app-runtime/selection-scroll.ts +2 -2
- package/src/app-runtime/session-actions.ts +16 -4
- package/src/app-runtime/side-effects.ts +598 -102
- package/src/app-runtime/snippet-actions.ts +25 -11
- package/src/app-runtime/use-auto-commit-driver.ts +7 -4
- package/src/app-runtime/use-backend-runtime.ts +3 -3
- package/src/app-runtime/use-directory-search.ts +7 -5
- package/src/app-runtime/use-mouse-handlers.ts +11 -6
- package/src/app-runtime/use-pane-size-report.ts +1 -1
- package/src/app-runtime/use-renderer-bindings.ts +143 -8
- package/src/app.tsx +25 -9
- package/src/auto-commit/headless-commands.ts +4 -6
- package/src/daemon/daemon.ts +3 -3
- package/src/daemon/runtime-paths.ts +1 -1
- package/src/daemon/session-manager.ts +1 -1
- package/src/daemon/session-registry.ts +10 -3
- package/src/diff-parser/parse-patch-files.ts +18 -18
- package/src/git/command-queue.ts +1 -1
- package/src/git/divergence.ts +46 -0
- package/src/git/git-diff.ts +12 -5
- package/src/git/git-poller.ts +33 -11
- package/src/git/git-status.ts +14 -3
- package/src/git/move-worktree.ts +96 -0
- package/src/git/use-repo-discovery.ts +2 -1
- package/src/git/worktree-divergence-poller.ts +59 -0
- package/src/git/worktree.ts +99 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/describe-bindings.ts +27 -9
- package/src/input/keymap/key-chord.ts +4 -4
- package/src/input/keymap/key-format.ts +2 -2
- package/src/input/keymap/sequence-resolver.ts +1 -1
- package/src/input/keymap/trie.ts +1 -1
- package/src/input/modes/bridge.ts +7 -0
- package/src/input/modes/transitions.ts +2 -1
- package/src/input/modes/types.ts +13 -1
- package/src/input/raw-input-handler.ts +34 -1
- package/src/input/terminal-text-extraction.ts +9 -5
- package/src/integrations/claude-syntax-overlay.ts +8 -8
- package/src/integrations/claude-theme-sync.ts +12 -12
- package/src/ipc/protocol.ts +3 -3
- package/src/platform/clipboard.ts +18 -2
- package/src/platform/worktree-deps.ts +22 -0
- package/src/platform/worktree-paths.ts +67 -0
- package/src/profile-paths.ts +3 -3
- package/src/pty/assistant-status-detection-loop.ts +2 -2
- package/src/pty/assistant-status-detector.ts +15 -6
- package/src/pty/command-registry.ts +8 -1
- package/src/pty/pty-manager.ts +1 -1
- package/src/services/ai-usage/adapters/claude.ts +5 -5
- package/src/services/ai-usage/adapters/codex.ts +8 -8
- package/src/services/ai-usage/cache.ts +2 -2
- package/src/services/ai-usage/pace.ts +3 -6
- package/src/services/ai-usage/provider.ts +1 -1
- package/src/session-backend/bootstrap.ts +2 -2
- package/src/session-backend/local-session-backend.ts +11 -11
- package/src/session-backend/remote-session-backend.ts +3 -3
- package/src/session-backend/types.ts +2 -2
- package/src/snippets/expand-variables.ts +112 -0
- package/src/snippets/run-shell-var.ts +93 -0
- package/src/snippets/trigger-detector.ts +105 -0
- package/src/state/ai-usage-store.ts +1 -1
- package/src/state/git-tree.ts +8 -4
- package/src/state/layout-tree.ts +8 -5
- package/src/state/reducers/git-mode-state.ts +44 -14
- package/src/state/reducers/git-panel-state.ts +38 -2
- package/src/state/reducers/modal-state.ts +385 -24
- package/src/state/reducers/session-state.ts +60 -1
- package/src/state/reducers/tab-state.ts +151 -85
- package/src/state/selectors.ts +6 -4
- package/src/state/session-catalog.ts +20 -5
- package/src/state/session-persistence.ts +8 -4
- package/src/state/session-worktrees.ts +227 -0
- package/src/state/snippet-catalog.ts +65 -4
- package/src/state/store.ts +1 -0
- package/src/state/toast-store.ts +95 -0
- package/src/state/types.ts +108 -16
- package/src/state/validation.ts +47 -3
- package/src/terminal-manager/manager-client.ts +15 -13
- package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
- package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
- package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
- package/src/ui/components/git/git-panel.tsx +17 -10
- package/src/ui/components/git/git-view.tsx +79 -19
- package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
- package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
- package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
- package/src/ui/components/layout/session-bar.tsx +3 -3
- package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
- package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
- package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
- package/src/ui/components/layout/split-layout.tsx +1 -1
- package/src/ui/components/layout/terminal-pane.tsx +43 -40
- package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
- package/src/ui/components/modals/app/help-modal.tsx +4 -2
- package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
- package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
- package/src/ui/components/modals/shared/form.tsx +4 -5
- package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
- package/src/ui/components/modals/shared/picker.tsx +28 -5
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
- package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
- package/src/ui/components/overlays/toast/toast-position.ts +45 -0
- package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
- package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
- package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
- package/src/ui/components/primitives/input-field.tsx +1 -1
- package/src/ui/components/primitives/list-item.tsx +3 -3
- package/src/ui/filter-themes.ts +1 -1
- package/src/ui/root.tsx +65 -14
- package/src/ui/session-ordering.ts +2 -2
- package/src/ui/status-bar-model.ts +14 -7
- package/src/ui/terminal-graphics/capabilities.ts +1 -1
- package/src/ui/terminal-graphics/format-fallback.ts +7 -9
- package/src/ui/terminal-graphics/kitty.ts +2 -7
- 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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -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
|
+
}
|