@brimveyn/aimux 1.12.6 → 1.13.2

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 +29 -13
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +563 -85
  10. package/src/app-runtime/snippet-actions.ts +3 -3
  11. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  12. package/src/app-runtime/use-backend-runtime.ts +3 -3
  13. package/src/app-runtime/use-directory-search.ts +7 -5
  14. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  15. package/src/app-runtime/use-pane-size-report.ts +1 -1
  16. package/src/app-runtime/use-renderer-bindings.ts +6 -5
  17. package/src/app.tsx +12 -7
  18. package/src/auto-commit/headless-commands.ts +4 -6
  19. package/src/daemon/daemon.ts +21 -11
  20. package/src/daemon/runtime-paths.ts +1 -1
  21. package/src/daemon/session-manager.ts +1 -1
  22. package/src/daemon/session-registry.ts +10 -3
  23. package/src/diff-parser/parse-patch-files.ts +18 -18
  24. package/src/git/command-queue.ts +19 -3
  25. package/src/git/divergence.ts +46 -0
  26. package/src/git/git-diff.ts +12 -5
  27. package/src/git/git-poller.ts +33 -11
  28. package/src/git/git-status.ts +14 -3
  29. package/src/git/move-worktree.ts +96 -0
  30. package/src/git/use-repo-discovery.ts +2 -1
  31. package/src/git/worktree-divergence-poller.ts +59 -0
  32. package/src/git/worktree.ts +99 -0
  33. package/src/index.tsx +1 -1
  34. package/src/input/keymap/describe-bindings.ts +27 -9
  35. package/src/input/keymap/key-chord.ts +4 -4
  36. package/src/input/keymap/key-format.ts +2 -2
  37. package/src/input/keymap/sequence-resolver.ts +1 -1
  38. package/src/input/keymap/trie.ts +1 -1
  39. package/src/input/modes/bridge.ts +7 -0
  40. package/src/input/modes/transitions.ts +2 -1
  41. package/src/input/modes/types.ts +11 -1
  42. package/src/input/raw-input-handler.ts +1 -1
  43. package/src/input/terminal-text-extraction.ts +9 -5
  44. package/src/integrations/claude-syntax-overlay.ts +8 -8
  45. package/src/integrations/claude-theme-sync.ts +12 -12
  46. package/src/ipc/protocol.ts +3 -3
  47. package/src/platform/clipboard.ts +4 -2
  48. package/src/platform/worktree-deps.ts +22 -0
  49. package/src/platform/worktree-paths.ts +67 -0
  50. package/src/profile-paths.ts +3 -3
  51. package/src/pty/assistant-status-detection-loop.ts +2 -2
  52. package/src/pty/assistant-status-detector.ts +15 -6
  53. package/src/pty/command-registry.ts +2 -1
  54. package/src/pty/pty-manager.ts +1 -1
  55. package/src/services/ai-usage/adapters/claude.ts +5 -5
  56. package/src/services/ai-usage/adapters/codex.ts +8 -8
  57. package/src/services/ai-usage/cache.ts +2 -2
  58. package/src/services/ai-usage/pace.ts +3 -6
  59. package/src/services/ai-usage/provider.ts +1 -1
  60. package/src/session-backend/bootstrap.ts +2 -2
  61. package/src/session-backend/local-session-backend.ts +11 -11
  62. package/src/session-backend/remote-session-backend.ts +60 -45
  63. package/src/session-backend/types.ts +2 -2
  64. package/src/snippets/expand-variables.ts +7 -7
  65. package/src/snippets/run-shell-var.ts +1 -1
  66. package/src/snippets/trigger-detector.ts +1 -1
  67. package/src/state/ai-usage-store.ts +1 -1
  68. package/src/state/git-tree.ts +8 -4
  69. package/src/state/layout-tree.ts +8 -5
  70. package/src/state/reducers/git-mode-state.ts +44 -14
  71. package/src/state/reducers/git-panel-state.ts +38 -2
  72. package/src/state/reducers/modal-state.ts +355 -21
  73. package/src/state/reducers/session-state.ts +60 -1
  74. package/src/state/reducers/tab-state.ts +151 -85
  75. package/src/state/selectors.ts +6 -4
  76. package/src/state/session-catalog.ts +20 -5
  77. package/src/state/session-persistence.ts +8 -4
  78. package/src/state/session-worktrees.ts +227 -0
  79. package/src/state/store.ts +1 -0
  80. package/src/state/toast-store.ts +95 -0
  81. package/src/state/types.ts +92 -13
  82. package/src/state/validation.ts +25 -2
  83. package/src/terminal-manager/manager-client.ts +15 -13
  84. package/src/ui/components/git/diff-renderer/fold-strip.tsx +32 -24
  85. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  86. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  87. package/src/ui/components/git/diff-renderer/split-view.tsx +39 -11
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +29 -7
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +28 -11
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +10 -8
  91. package/src/ui/components/git/git-panel.tsx +129 -83
  92. package/src/ui/components/git/git-view.tsx +96 -28
  93. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  94. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +14 -13
  95. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  96. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  97. package/src/ui/components/layout/session-bar.tsx +135 -117
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +244 -82
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +68 -41
  100. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  101. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  102. package/src/ui/components/layout/split-layout.tsx +27 -20
  103. package/src/ui/components/layout/terminal-pane.tsx +163 -120
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +29 -20
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +17 -10
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +18 -10
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +36 -28
  109. package/src/ui/components/modals/shared/form.tsx +6 -6
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +71 -32
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +50 -29
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +209 -25
  114. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  115. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +111 -0
  116. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +14 -9
  117. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  118. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  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 +23 -7
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +121 -38
  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
@@ -1,4 +1,6 @@
1
- import type { AssistantId } from '../../../../state/types'
1
+ import { useCallback, useMemo } from 'react'
2
+
3
+ import type { AssistantId, WorktreeRecord } from '../../../../state/types'
2
4
 
3
5
  import { getAllAssistantOptions, getAssistantOption } from '../../../../pty/command-registry'
4
6
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
@@ -13,24 +15,141 @@ interface NewTabModalProps {
13
15
  customCommands: Record<string, string>
14
16
  filter: string | null
15
17
  cursorPos?: number
18
+ currentSessionId: string | null
16
19
  editingCommand: AssistantId | null
17
20
  editBuffer: string
21
+ activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
22
+ branchError: string | null
23
+ branchName: string
24
+ createWorktree: boolean
25
+ selectedAssistantId: AssistantId | null
26
+ step: 'assistant' | 'worktree' | 'worktree-create'
27
+ worktreeDeleteConfirmId: string | null
28
+ worktreeDeleteMessage: string | null
29
+ worktrees: WorktreeRecord[]
30
+ worktreeName: string
31
+ }
32
+
33
+ function getDeleteBlockedReason({
34
+ currentSessionId,
35
+ worktree,
36
+ worktrees,
37
+ }: {
38
+ currentSessionId: string | null
39
+ worktree: WorktreeRecord | undefined
40
+ worktrees: WorktreeRecord[]
41
+ }): string | null {
42
+ if (!worktree) return null
43
+ if (!(currentSessionId != null && currentSessionId !== ''))
44
+ return 'Cannot delete: no active session.'
45
+ if (worktrees.length <= 1) return 'Cannot delete: at least one worktree must remain.'
46
+ if (worktree.source === 'primary') return 'Cannot delete: root worktree is required.'
47
+ if (worktree.source === 'aimux-temp' && !worktree.createdByAimux) {
48
+ return 'Cannot delete: this temp worktree was not created by Aimux.'
49
+ }
50
+ return null
18
51
  }
19
52
 
20
53
  export function NewTabModal({
54
+ activeField,
55
+ branchError,
56
+ branchName,
57
+ createWorktree,
58
+ currentSessionId,
21
59
  cursorPos,
22
60
  customCommands,
23
61
  editBuffer,
24
62
  editingCommand,
25
63
  filter,
64
+ selectedAssistantId,
26
65
  selectedIndex,
66
+ step,
67
+ worktreeDeleteConfirmId,
68
+ worktreeDeleteMessage,
69
+ worktreeName,
70
+ worktrees,
27
71
  }: NewTabModalProps) {
28
72
  const t = useTheme()
73
+ const options = useMemo(() => getAllAssistantOptions(customCommands), [customCommands])
74
+ const filtered = useMemo(() => filterAssistants(options, filter), [filter, options])
75
+
76
+ const handleHover = useCallback(
77
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
78
+ []
79
+ )
80
+
81
+ const worktreeItems = useMemo<PickerItem[]>(
82
+ () => [
83
+ ...worktrees.map((worktree, index) => {
84
+ const active = index === selectedIndex
85
+ const label = worktree.branch ?? worktree.name
86
+ const blockedReason = getDeleteBlockedReason({ currentSessionId, worktree, worktrees })
87
+ const canDelete = blockedReason == null || blockedReason === ''
88
+ return {
89
+ key: worktree.id,
90
+ onClick: () => {
91
+ dispatchGlobal({ index, type: 'set-modal-selection-index' })
92
+ runSideEffectGlobal({ type: 'launch-selected-assistant' })
93
+ },
94
+ onDelete:
95
+ active && canDelete && currentSessionId != null && currentSessionId !== ''
96
+ ? () => {
97
+ dispatchGlobal({ index, type: 'set-modal-selection-index' })
98
+ dispatchGlobal({ message: null, type: 'set-new-tab-worktree-delete-state' })
99
+ runSideEffectGlobal({
100
+ force: worktreeDeleteConfirmId === worktree.id,
101
+ sessionId: currentSessionId,
102
+ type: 'delete-worktree',
103
+ worktreeId: worktree.id,
104
+ })
105
+ }
106
+ : undefined,
107
+ subtitle: (
108
+ <box flexDirection="column">
109
+ <text fg={t.textMuted}>{worktree.path}</text>
110
+ <text fg={t.textMuted}>{worktree.source}</text>
111
+ </box>
112
+ ),
113
+ title: <text fg={active ? t.text : t.textMuted}>{label}</text>,
114
+ }
115
+ }),
116
+ {
117
+ key: '__create-worktree__',
118
+ onClick: () => dispatchGlobal({ type: 'enter-new-tab-worktree-create' }),
119
+ subtitle: <text fg={t.textMuted}>Create an Aimux temp worktree</text>,
120
+ title: <text fg={createWorktree ? t.text : t.textMuted}>Create new worktree</text>,
121
+ },
122
+ ],
123
+ [createWorktree, currentSessionId, selectedIndex, t, worktreeDeleteConfirmId, worktrees]
124
+ )
125
+
126
+ const items = useMemo<PickerItem[]>(
127
+ () =>
128
+ filtered.map((option, index) => {
129
+ const active = index === selectedIndex
130
+ const customCmd = customCommands[option.id]
131
+ return {
132
+ key: option.id,
133
+ onClick: () =>
134
+ dispatchGlobal({ assistantId: option.id, type: 'select-new-tab-assistant' }),
135
+ onEdit: () =>
136
+ dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
137
+ subtitle: (
138
+ <box flexDirection="column">
139
+ <text fg={t.textMuted}>{option.description}</text>
140
+ {customCmd != null && customCmd !== '' ? (
141
+ <text fg={t.primary}>{customCmd}</text>
142
+ ) : null}
143
+ </box>
144
+ ),
145
+ title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
146
+ }
147
+ }),
148
+ [customCommands, filtered, selectedIndex, t]
149
+ )
29
150
 
30
151
  if (editingCommand !== null) {
31
- const option =
32
- getAllAssistantOptions(customCommands).find((o) => o.id === editingCommand) ??
33
- getAssistantOption(0)
152
+ const option = options.find((o) => o.id === editingCommand) ?? getAssistantOption(0)
34
153
  return (
35
154
  <Form
36
155
  title={`Edit command — ${option.label}`}
@@ -48,38 +167,103 @@ export function NewTabModal({
48
167
  )
49
168
  }
50
169
 
51
- const options = getAllAssistantOptions(customCommands)
52
- const filtered = filterAssistants(options, filter)
53
-
54
- const items: PickerItem[] = filtered.map((option, index) => {
55
- const active = index === selectedIndex
56
- const customCmd = customCommands[option.id]
57
- return {
58
- key: option.id,
59
- onClick: () => runSideEffectGlobal({ type: 'launch-selected-assistant' }),
60
- onEdit: () => dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
61
- subtitle: (
62
- <box flexDirection="column">
63
- <text fg={t.textMuted}>{option.description}</text>
64
- {customCmd ? <text fg={t.primary}>{customCmd}</text> : null}
170
+ if (step === 'worktree-create') {
171
+ const selectedAssistant =
172
+ options.find((option) => option.id === selectedAssistantId) ?? options[0]
173
+ return (
174
+ <Form
175
+ title={`New worktree: ${selectedAssistant?.label ?? 'assistant'}`}
176
+ keybindsModeId="modal.new-tab.command-edit"
177
+ width={uiTokens.modalWidth.xl}
178
+ >
179
+ <box flexDirection="column" gap={1}>
180
+ <TextField
181
+ active={activeField === 'worktree-name'}
182
+ label="Worktree name"
183
+ value={worktreeName}
184
+ cursorPos={activeField === 'worktree-name' ? cursorPos : undefined}
185
+ placeholder="my-feature"
186
+ />
187
+ <box flexDirection="column">
188
+ <TextField
189
+ active={activeField === 'branch-name'}
190
+ label="Branch name"
191
+ value={branchName}
192
+ cursorPos={activeField === 'branch-name' ? cursorPos : undefined}
193
+ placeholder="aimux/my-feature"
194
+ />
195
+ {branchError != null && branchError !== '' ? (
196
+ <text fg={t.error}>{branchError}</text>
197
+ ) : null}
198
+ </box>
199
+ <text fg={t.textMuted}>Step 3/3: configure new worktree</text>
65
200
  </box>
66
- ),
67
- title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
68
- }
69
- })
201
+ </Form>
202
+ )
203
+ }
204
+
205
+ if (step === 'worktree') {
206
+ const selectedAssistant =
207
+ options.find((option) => option.id === selectedAssistantId) ??
208
+ filtered[selectedIndex] ??
209
+ options[0]
210
+ const selectedWorktree = worktrees[selectedIndex]
211
+ const deleteBlockedReason = getDeleteBlockedReason({
212
+ currentSessionId,
213
+ worktree: selectedWorktree,
214
+ worktrees,
215
+ })
216
+ return (
217
+ <Picker
218
+ title={`New assistant: ${selectedAssistant?.label ?? 'assistant'}`}
219
+ keybindsModeId="modal.new-tab.command-edit"
220
+ width={uiTokens.modalWidth.md}
221
+ gap={1}
222
+ filter={null}
223
+ items={worktreeItems}
224
+ selectedIndex={selectedIndex}
225
+ emptyState={<text fg={t.textMuted}>No worktrees.</text>}
226
+ onHover={handleHover}
227
+ footer={
228
+ <box flexDirection="column">
229
+ {(worktreeDeleteMessage != null && worktreeDeleteMessage !== '') ||
230
+ (deleteBlockedReason != null && deleteBlockedReason !== '') ? (
231
+ <text
232
+ fg={
233
+ worktreeDeleteMessage != null && worktreeDeleteMessage !== ''
234
+ ? t.error
235
+ : t.textMuted
236
+ }
237
+ >
238
+ {worktreeDeleteMessage ?? deleteBlockedReason}
239
+ </text>
240
+ ) : null}
241
+ <text fg={t.textMuted}>Step 2/2: choose worktree</text>
242
+ <text fg={t.textMuted}>Enter launches, Ctrl+d deletes selected worktree</text>
243
+ </box>
244
+ }
245
+ />
246
+ )
247
+ }
70
248
 
71
249
  return (
72
250
  <Picker
73
- title="New assistant tab"
251
+ title="New assistant: choose assistant"
74
252
  keybindsModeId="modal.new-tab.command-edit"
75
253
  width={uiTokens.modalWidth.md}
76
254
  gap={1}
77
255
  filter={filter}
78
- cursorPos={cursorPos}
256
+ cursorPos={activeField === 'assistant' ? cursorPos : undefined}
79
257
  items={items}
80
258
  selectedIndex={selectedIndex}
81
259
  emptyState={<text fg={t.textMuted}>No matching assistants.</text>}
82
- onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
260
+ onHover={handleHover}
261
+ footer={
262
+ <box flexDirection="column">
263
+ <text fg={t.textMuted}>Step 1/2: choose assistant</text>
264
+ <text fg={t.textMuted}>Enter continues to worktree selection</text>
265
+ </box>
266
+ }
83
267
  />
84
268
  )
85
269
  }
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useMemo } from 'react'
1
+ import { useCallback, useLayoutEffect, useMemo } from 'react'
2
2
 
3
3
  import type { ThemeId } from '../../../themes'
4
4
 
@@ -37,19 +37,28 @@ export function ThemePickerModal({
37
37
 
38
38
  const effectiveIndex = clampSelection(selectedIndex, filtered.length)
39
39
 
40
- const items: PickerItem[] = filtered.map((id, rowIndex) => {
41
- const active = rowIndex === effectiveIndex
42
- const isCurrent = id === currentThemeId
43
- return {
44
- key: id,
45
- onClick: () => {
46
- dispatchGlobal({ type: 'close-modal' })
47
- runSideEffectGlobal({ action: 'confirm', type: 'apply-theme' })
48
- },
49
- title: <text fg={active ? t.text : t.textMuted}>{themeDisplayName(id)}</text>,
50
- trailing: isCurrent ? <text fg={t.primary}>current</text> : undefined,
51
- }
52
- })
40
+ const items = useMemo<PickerItem[]>(
41
+ () =>
42
+ filtered.map((id, rowIndex) => {
43
+ const active = rowIndex === effectiveIndex
44
+ const isCurrent = id === currentThemeId
45
+ return {
46
+ key: id,
47
+ onClick: () => {
48
+ dispatchGlobal({ type: 'close-modal' })
49
+ runSideEffectGlobal({ action: 'confirm', type: 'apply-theme' })
50
+ },
51
+ title: <text fg={active ? t.text : t.textMuted}>{themeDisplayName(id)}</text>,
52
+ trailing: isCurrent ? <text fg={t.primary}>current</text> : undefined,
53
+ }
54
+ }),
55
+ [currentThemeId, effectiveIndex, filtered, t]
56
+ )
57
+
58
+ const handleHover = useCallback(
59
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
60
+ []
61
+ )
53
62
 
54
63
  return (
55
64
  <Picker
@@ -70,7 +79,7 @@ export function ThemePickerModal({
70
79
  items={items}
71
80
  selectedIndex={effectiveIndex}
72
81
  emptyState={<text fg={t.textMuted}>No themes available.</text>}
73
- onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
82
+ onHover={handleHover}
74
83
  />
75
84
  )
76
85
  }
@@ -0,0 +1,111 @@
1
+ import { useCallback, useMemo } from 'react'
2
+
3
+ import type { WorktreeRecord } from '../../../../state/types'
4
+
5
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
6
+ import { useTheme } from '../../../theme'
7
+ import { uiTokens } from '../../../ui-tokens'
8
+ import { ListItem } from '../../primitives/list-item'
9
+ import { ModalShell } from '../shared/modal-shell'
10
+
11
+ interface WorktreeMoveModalProps {
12
+ deleteSource: boolean
13
+ divergence: Record<string, { ahead: number; behind: number }>
14
+ selectedIndex: number
15
+ sourceWorktreeId: string
16
+ worktrees: WorktreeRecord[]
17
+ }
18
+
19
+ const MOVE_HINTS: [key: string, label: string][] = [
20
+ ['↵', 'move into selected target'],
21
+ ['d', 'toggle delete source'],
22
+ ['j/k', 'change target'],
23
+ ['esc', 'cancel'],
24
+ ]
25
+
26
+ function formatDivergence(divergence: { ahead: number; behind: number } | undefined): string {
27
+ if (divergence == null) return ''
28
+ const parts: string[] = []
29
+ if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
30
+ if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
31
+ return parts.join(' ')
32
+ }
33
+
34
+ export function WorktreeMoveModal({
35
+ deleteSource,
36
+ divergence,
37
+ selectedIndex,
38
+ sourceWorktreeId,
39
+ worktrees,
40
+ }: WorktreeMoveModalProps) {
41
+ const t = useTheme()
42
+ const source = useMemo(
43
+ () => worktrees.find((w) => w.id === sourceWorktreeId),
44
+ [sourceWorktreeId, worktrees]
45
+ )
46
+ const targets = useMemo(
47
+ () => worktrees.filter((w) => w.id !== sourceWorktreeId),
48
+ [sourceWorktreeId, worktrees]
49
+ )
50
+ const sourceLabel =
51
+ source?.branch != null && source.branch !== '' ? source.branch : (source?.name ?? 'worktree')
52
+ const handleSelectIndex = useCallback(
53
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
54
+ []
55
+ )
56
+ return (
57
+ <ModalShell
58
+ title={`Move ${sourceLabel} →`}
59
+ keybindsModeId="modal.worktree-move"
60
+ width={uiTokens.modalWidth.md}
61
+ footer={
62
+ <box flexDirection="column" gap={1} marginTop={1}>
63
+ <box flexDirection="row" gap={1}>
64
+ <text fg={t.textMuted}>delete source after move</text>
65
+ <text fg={deleteSource ? t.warning : t.text}>{deleteSource ? 'on' : 'off'}</text>
66
+ </box>
67
+ <box flexDirection="column" gap={0}>
68
+ {MOVE_HINTS.map(([key, label]) => (
69
+ <box key={key} flexDirection="row" gap={2}>
70
+ <box width={4} flexShrink={0}>
71
+ <text fg={t.primary}>{key}</text>
72
+ </box>
73
+ <text fg={t.textMuted}>{label}</text>
74
+ </box>
75
+ ))}
76
+ </box>
77
+ </box>
78
+ }
79
+ >
80
+ <box flexDirection="column" marginTop={1}>
81
+ {targets.length === 0 ? (
82
+ <text fg={t.textMuted}>No other worktree to move into.</text>
83
+ ) : (
84
+ targets.map((worktree, index) => {
85
+ const active = index === selectedIndex
86
+ const label =
87
+ worktree.branch != null && worktree.branch !== '' ? worktree.branch : worktree.name
88
+ const ahead = formatDivergence(divergence[worktree.id])
89
+ return (
90
+ <ListItem
91
+ key={worktree.id}
92
+ id={worktree.id}
93
+ index={index}
94
+ active={active}
95
+ onHoverIndex={handleSelectIndex}
96
+ onClickIndex={handleSelectIndex}
97
+ title={
98
+ <text fg={active ? t.text : t.textMuted} wrapMode="none">
99
+ {label}
100
+ {worktree.source === 'primary' ? ' (primary)' : ''}
101
+ {ahead !== '' ? ` ${ahead}` : ''}
102
+ </text>
103
+ }
104
+ />
105
+ )
106
+ })
107
+ )}
108
+ </box>
109
+ </ModalShell>
110
+ )
111
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AIUsageTool } from '@brimveyn/aimux-config'
2
2
 
3
+ import { useCallback } from 'react'
4
+
3
5
  import { useAIUsageStore } from '../../../../state/ai-usage-store'
4
6
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
5
7
  import { useTheme } from '../../../theme'
@@ -34,7 +36,7 @@ function formatResetIn(snap: {
34
36
  resetAt: string | null
35
37
  timeRemaining: string | null
36
38
  }): string | null {
37
- if (snap.resetAt) {
39
+ if (snap.resetAt != null && snap.resetAt !== '') {
38
40
  const diffMs = new Date(snap.resetAt).getTime() - Date.now()
39
41
  if (diffMs > 0) {
40
42
  const totalMin = Math.round(diffMs / 60_000)
@@ -52,6 +54,15 @@ export function AIUsageIndicator() {
52
54
  const enabled = useAIUsageStore((s) => s.enabled)
53
55
  const snapshots = useAIUsageStore((s) => s.snapshots)
54
56
 
57
+ const openModal = useCallback(
58
+ (e: { preventDefault: () => void; stopPropagation: () => void }) => {
59
+ e.preventDefault()
60
+ e.stopPropagation()
61
+ dispatchGlobal({ type: 'open-ai-usage-modal' })
62
+ },
63
+ []
64
+ )
65
+
55
66
  if (!enabled) return null
56
67
 
57
68
  const ordered: AIUsageTool[] = ['claude', 'codex']
@@ -59,12 +70,6 @@ export function AIUsageIndicator() {
59
70
  .map((tool) => ({ snap: snapshots[tool], tool }))
60
71
  .filter((entry) => entry.snap !== undefined)
61
72
 
62
- const openModal = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
63
- e.preventDefault()
64
- e.stopPropagation()
65
- dispatchGlobal({ type: 'open-ai-usage-modal' })
66
- }
67
-
68
73
  if (entries.length === 0) {
69
74
  return (
70
75
  <box
@@ -85,7 +90,7 @@ export function AIUsageIndicator() {
85
90
  if (!snap) return null
86
91
  const icon = TOOL_ICON[tool]
87
92
 
88
- if (snap.error && !snap.stale) {
93
+ if (snap.error != null && snap.error !== '' && !(snap.stale === true)) {
89
94
  return (
90
95
  <box
91
96
  key={tool}
@@ -134,7 +139,7 @@ export function AIUsageIndicator() {
134
139
  <text fg={t.text} selectable={false}>
135
140
  {` ${pctText}`}
136
141
  </text>
137
- {reset ? (
142
+ {reset != null && reset !== '' ? (
138
143
  <text fg={t.textMuted} selectable={false}>
139
144
  {` · ${reset}`}
140
145
  </text>
@@ -1,21 +1,30 @@
1
1
  import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
  import type { BoxProps } from '@opentui/react'
3
3
 
4
+ import { memo, useCallback } from 'react'
5
+
4
6
  import { type ContextMenuItem, openContextMenu } from '../../../context-menu/controller'
5
7
 
6
8
  interface ContextMenuBoxProps extends BoxProps {
7
9
  rightClickMenu?: ContextMenuItem[]
8
10
  }
9
11
 
10
- export function ContextMenuBox({ onMouseDown, rightClickMenu, ...boxProps }: ContextMenuBoxProps) {
11
- function handleMouseDown(this: BoxRenderable, event: OtuiMouseEvent): void {
12
- if (event.button === 2 && rightClickMenu && rightClickMenu.length > 0) {
13
- event.preventDefault()
14
- event.stopPropagation()
15
- openContextMenu(event.x, event.y, rightClickMenu)
16
- return
17
- }
18
- onMouseDown?.call(this, event)
19
- }
12
+ export const ContextMenuBox = memo(function ContextMenuBox({
13
+ onMouseDown,
14
+ rightClickMenu,
15
+ ...boxProps
16
+ }: ContextMenuBoxProps) {
17
+ const handleMouseDown = useCallback(
18
+ function (this: BoxRenderable, event: OtuiMouseEvent): void {
19
+ if (event.button === 2 && rightClickMenu && rightClickMenu.length > 0) {
20
+ event.preventDefault()
21
+ event.stopPropagation()
22
+ openContextMenu(event.x, event.y, rightClickMenu)
23
+ return
24
+ }
25
+ onMouseDown?.call(this, event)
26
+ },
27
+ [onMouseDown, rightClickMenu]
28
+ )
20
29
  return <box {...boxProps} onMouseDown={handleMouseDown} />
21
- }
30
+ })
@@ -1,5 +1,7 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
1
3
  import { useKeyboard } from '@opentui/react'
2
- import { useEffect, useState } from 'react'
4
+ import { memo, useCallback, useEffect, useState } from 'react'
3
5
 
4
6
  import { useAppStore } from '../../../../state/app-store'
5
7
  import {
@@ -9,6 +11,50 @@ import {
9
11
  } from '../../../context-menu/controller'
10
12
  import { useTheme } from '../../../theme'
11
13
 
14
+ const MenuItem = memo(function MenuItem({
15
+ active,
16
+ index,
17
+ label,
18
+ onHover,
19
+ onSelect,
20
+ width,
21
+ }: {
22
+ active: boolean
23
+ index: number
24
+ label: string
25
+ onHover: (index: number) => void
26
+ onSelect: () => void
27
+ width: number
28
+ }) {
29
+ const t = useTheme()
30
+ const handleMouseOver = useCallback(() => onHover(index), [index, onHover])
31
+ const handleMouseDown = useCallback(
32
+ (e: OtuiMouseEvent) => {
33
+ e.preventDefault()
34
+ e.stopPropagation()
35
+ if (e.button !== 0) return
36
+ closeContextMenu()
37
+ onSelect()
38
+ },
39
+ [onSelect]
40
+ )
41
+ return (
42
+ <box
43
+ width={width - 2}
44
+ flexShrink={0}
45
+ paddingLeft={1}
46
+ paddingRight={1}
47
+ backgroundColor={active ? t.backgroundElement : undefined}
48
+ onMouseOver={handleMouseOver}
49
+ onMouseDown={handleMouseDown}
50
+ >
51
+ <text fg={active ? t.text : t.textMuted} selectable={false}>
52
+ {label}
53
+ </text>
54
+ </box>
55
+ )
56
+ })
57
+
12
58
  export function ContextMenuOverlay() {
13
59
  const [menu, setMenu] = useState<ContextMenuState | null>(null)
14
60
  const [selected, setSelected] = useState(0)
@@ -48,6 +94,15 @@ export function ContextMenuOverlay() {
48
94
  }
49
95
  })
50
96
 
97
+ const handleBackdropMouseDown = useCallback((e: OtuiMouseEvent) => {
98
+ e.preventDefault()
99
+ e.stopPropagation()
100
+ closeContextMenu()
101
+ }, [])
102
+ const handleMenuMouseDown = useCallback((e: OtuiMouseEvent) => {
103
+ e.stopPropagation()
104
+ }, [])
105
+
51
106
  if (!menu) return null
52
107
 
53
108
  const maxLabel = Math.max(...menu.items.map(([label]) => label.length))
@@ -66,11 +121,7 @@ export function ContextMenuOverlay() {
66
121
  left={0}
67
122
  width="100%"
68
123
  height="100%"
69
- onMouseDown={(e) => {
70
- e.preventDefault()
71
- e.stopPropagation()
72
- closeContextMenu()
73
- }}
124
+ onMouseDown={handleBackdropMouseDown}
74
125
  />
75
126
  <box
76
127
  position="absolute"
@@ -81,35 +132,19 @@ export function ContextMenuOverlay() {
81
132
  border
82
133
  borderColor={t.border}
83
134
  backgroundColor={t.backgroundPanel}
84
- onMouseDown={(e) => {
85
- e.stopPropagation()
86
- }}
135
+ onMouseDown={handleMenuMouseDown}
87
136
  >
88
- {menu.items.map(([label, onSelect], index) => {
89
- const active = index === selected
90
- return (
91
- <box
92
- key={`${label}-${index}`}
93
- width={width - 2}
94
- flexShrink={0}
95
- paddingLeft={1}
96
- paddingRight={1}
97
- backgroundColor={active ? t.backgroundElement : undefined}
98
- onMouseOver={() => setSelected(index)}
99
- onMouseDown={(e) => {
100
- e.preventDefault()
101
- e.stopPropagation()
102
- if (e.button !== 0) return
103
- closeContextMenu()
104
- onSelect()
105
- }}
106
- >
107
- <text fg={active ? t.text : t.textMuted} selectable={false}>
108
- {label}
109
- </text>
110
- </box>
111
- )
112
- })}
137
+ {menu.items.map(([label, onSelect], index) => (
138
+ <MenuItem
139
+ key={label}
140
+ index={index}
141
+ label={label}
142
+ onHover={setSelected}
143
+ onSelect={onSelect}
144
+ active={index === selected}
145
+ width={width}
146
+ />
147
+ ))}
113
148
  </box>
114
149
  </box>
115
150
  )