@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
@@ -1,6 +1,5 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
-
3
- import { type ReactNode } from 'react'
2
+ import type { ReactNode } from 'react'
4
3
 
5
4
  import { useTheme } from '../../../theme'
6
5
  import { InputField } from '../../primitives/input-field'
@@ -84,8 +83,8 @@ export function FieldLabel({ active = false, children, description }: FieldLabel
84
83
  const t = useTheme()
85
84
  return (
86
85
  <box flexDirection="column">
87
- {children ? <text fg={active ? t.text : t.textMuted}>{children}</text> : null}
88
- {description ? <text fg={t.textMuted}>{description}</text> : null}
86
+ {children != null ? <text fg={active ? t.text : t.textMuted}>{children}</text> : null}
87
+ {description != null ? <text fg={t.textMuted}>{description}</text> : null}
89
88
  </box>
90
89
  )
91
90
  }
@@ -100,7 +99,7 @@ export function TextField({
100
99
  }: TextFieldProps) {
101
100
  return (
102
101
  <box flexDirection="column">
103
- {label || description ? (
102
+ {label != null || description != null ? (
104
103
  <FieldLabel active={active} description={description}>
105
104
  {label}
106
105
  </FieldLabel>
@@ -1,7 +1,7 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
+ import type { ReactNode } from 'react'
2
3
 
3
4
  import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
4
- import { type ReactNode } from 'react'
5
5
 
6
6
  import { useTheme, useTransparent } from '../../../theme'
7
7
  import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
@@ -81,10 +81,10 @@ export function ModalShell({
81
81
  <box width="100%" flexDirection="column" gap={listGap}>
82
82
  <box flexDirection="column">
83
83
  <text fg={t.text}>{title}</text>
84
- {subtitle ? <text fg={t.textMuted}>{subtitle}</text> : null}
84
+ {subtitle != null && subtitle !== '' ? <text fg={t.textMuted}>{subtitle}</text> : null}
85
85
  </box>
86
86
  {children}
87
- {footer ? <box>{footer}</box> : null}
87
+ {footer != null ? <box>{footer}</box> : null}
88
88
  </box>
89
89
  </box>
90
90
  {keybindsModeId ? <ModalKeybindsOverlay modeId={keybindsModeId} /> : null}
@@ -18,6 +18,7 @@ export interface PickerItem {
18
18
  onClick?: () => void
19
19
  onEdit?: () => void
20
20
  onDelete?: () => void
21
+ onWorktree?: () => void
21
22
  }
22
23
 
23
24
  interface PickerProps {
@@ -38,7 +39,15 @@ interface PickerProps {
38
39
  const VIEWPORT_HEIGHT_RATIO = 0.6
39
40
  const MODAL_CHROME_ROWS = 6
40
41
 
41
- function PickerItemCtas({ onDelete, onEdit }: { onEdit?: () => void; onDelete?: () => void }) {
42
+ function PickerItemCtas({
43
+ onDelete,
44
+ onEdit,
45
+ onWorktree,
46
+ }: {
47
+ onEdit?: () => void
48
+ onDelete?: () => void
49
+ onWorktree?: () => void
50
+ }) {
42
51
  const t = useTheme()
43
52
  return (
44
53
  <box flexDirection="row" gap={1}>
@@ -52,6 +61,16 @@ function PickerItemCtas({ onDelete, onEdit }: { onEdit?: () => void; onDelete?:
52
61
  <text fg={t.primary}>[edit]</text>
53
62
  </box>
54
63
  ) : null}
64
+ {onWorktree ? (
65
+ <box
66
+ onMouseDown={(event) => {
67
+ event.stopPropagation()
68
+ onWorktree()
69
+ }}
70
+ >
71
+ <text fg={t.warning}>[WT]</text>
72
+ </box>
73
+ ) : null}
55
74
  {onDelete ? (
56
75
  <box
57
76
  onMouseDown={(event) => {
@@ -105,7 +124,7 @@ export function Picker({
105
124
  return
106
125
  }
107
126
  const target = items[selectedIndex]?.key
108
- if (!target) return
127
+ if (!(target != null && target !== '')) return
109
128
  isScrollingRef.current = true
110
129
  scrollboxRef.current.scrollChildIntoView(target)
111
130
  resetScrollTimer()
@@ -137,7 +156,7 @@ export function Picker({
137
156
  for (let index = 0; index < items.length; index++) {
138
157
  const item = items[index]
139
158
  if (!item) continue
140
- if (item.group && item.group !== prevGroup) {
159
+ if (item.group != null && item.group !== '' && item.group !== prevGroup) {
141
160
  nodes.push(
142
161
  <box key={`group::${item.group}`} paddingLeft={1} paddingTop={index === 0 ? 0 : 1}>
143
162
  <text fg={t.textMuted} wrapMode="none">
@@ -151,8 +170,12 @@ export function Picker({
151
170
  const capturedIndex = index
152
171
  const trailing =
153
172
  item.trailing ??
154
- (active && (item.onEdit || item.onDelete) ? (
155
- <PickerItemCtas onEdit={item.onEdit} onDelete={item.onDelete} />
173
+ (active && (item.onEdit || item.onDelete || item.onWorktree) ? (
174
+ <PickerItemCtas
175
+ onEdit={item.onEdit}
176
+ onDelete={item.onDelete}
177
+ onWorktree={item.onWorktree}
178
+ />
156
179
  ) : null)
157
180
  nodes.push(
158
181
  <ListItem
@@ -2,8 +2,9 @@ import { uiTokens } from '../../../ui-tokens'
2
2
  import { Form, TextField } from '../shared/form'
3
3
 
4
4
  interface SnippetEditorModalProps {
5
- activeField: 'name' | 'content'
5
+ activeField: 'name' | 'trigger' | 'content'
6
6
  snippetName: string
7
+ snippetTrigger: string
7
8
  snippetContent: string
8
9
  isEditing: boolean
9
10
  }
@@ -13,18 +14,21 @@ export function SnippetEditorModal({
13
14
  isEditing,
14
15
  snippetContent,
15
16
  snippetName,
17
+ snippetTrigger,
16
18
  }: SnippetEditorModalProps) {
17
- const nameActive = activeField === 'name'
18
- const contentActive = activeField === 'content'
19
-
20
19
  return (
21
20
  <Form
22
21
  title={isEditing ? 'Edit snippet' : 'Create snippet'}
23
22
  keybindsModeId="modal.snippet-editor"
24
23
  width={uiTokens.modalWidth.xl}
25
24
  >
26
- <TextField active={nameActive} label="Name" value={snippetName} />
27
- <TextField active={contentActive} label="Content" value={snippetContent} />
25
+ <TextField active={activeField === 'name'} label="Name" value={snippetName} />
26
+ <TextField
27
+ active={activeField === 'trigger'}
28
+ label="Trigger (optional)"
29
+ value={snippetTrigger}
30
+ />
31
+ <TextField active={activeField === 'content'} label="Content" value={snippetContent} />
28
32
  </Form>
29
33
  )
30
34
  }
@@ -2,6 +2,7 @@ import type { SnippetRecord } from '../../../../state/types'
2
2
 
3
3
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
4
4
  import { filterSnippets } from '../../../../state/selectors'
5
+ import { isConfigSnippetId } from '../../../../state/snippet-catalog'
5
6
  import { useTheme } from '../../../theme'
6
7
  import { uiTokens } from '../../../ui-tokens'
7
8
  import { Picker, type PickerItem } from '../shared/picker'
@@ -11,6 +12,7 @@ interface SnippetPickerModalProps {
11
12
  selectedIndex: number
12
13
  filter: string | null
13
14
  cursorPos?: number
15
+ actionMessage?: string | null
14
16
  }
15
17
 
16
18
  const MAX_PREVIEW_LENGTH = 60
@@ -22,6 +24,7 @@ function truncateContent(content: string): string {
22
24
  }
23
25
 
24
26
  export function SnippetPickerModal({
27
+ actionMessage,
25
28
  cursorPos,
26
29
  filter,
27
30
  selectedIndex,
@@ -32,19 +35,28 @@ export function SnippetPickerModal({
32
35
 
33
36
  const items: PickerItem[] = filtered.map((snippet, index) => {
34
37
  const active = index === selectedIndex
38
+ const fromConfig = isConfigSnippetId(snippet.id)
35
39
  return {
36
40
  key: snippet.id,
37
41
  onClick: () => {
38
42
  dispatchGlobal({ type: 'close-modal' })
39
43
  runSideEffectGlobal({ type: 'paste-selected-snippet' })
40
44
  },
41
- onDelete: () => runSideEffectGlobal({ type: 'delete-selected-snippet' }),
42
- onEdit: () => runSideEffectGlobal({ type: 'edit-selected-snippet' }),
45
+ onDelete: fromConfig
46
+ ? undefined
47
+ : () => runSideEffectGlobal({ type: 'delete-selected-snippet' }),
48
+ onEdit: fromConfig ? undefined : () => runSideEffectGlobal({ type: 'edit-selected-snippet' }),
43
49
  subtitle: <text fg={t.textMuted}>{truncateContent(snippet.content)}</text>,
44
50
  title: (
45
- <text fg={active ? t.text : t.textMuted}>
46
- <strong>{snippet.name}</strong>
47
- </text>
51
+ <box flexDirection="row">
52
+ <text fg={active ? t.text : t.textMuted}>
53
+ <strong>{snippet.name}</strong>
54
+ </text>
55
+ {snippet.trigger != null && snippet.trigger !== '' ? (
56
+ <text fg={t.textMuted}>{` :${snippet.trigger}`}</text>
57
+ ) : null}
58
+ {fromConfig ? <text fg={t.textMuted}>{' [config]'}</text> : null}
59
+ </box>
48
60
  ),
49
61
  }
50
62
  })
@@ -61,9 +73,16 @@ export function SnippetPickerModal({
61
73
  selectedIndex={selectedIndex}
62
74
  emptyState={
63
75
  <text fg={t.textMuted}>
64
- {filter ? 'No matching snippets.' : 'No snippets yet. Press n to create one.'}
76
+ {filter != null && filter !== ''
77
+ ? 'No matching snippets.'
78
+ : 'No snippets yet. Press n to create one.'}
65
79
  </text>
66
80
  }
81
+ footer={
82
+ actionMessage != null && actionMessage !== '' ? (
83
+ <text fg={t.error}>{actionMessage}</text>
84
+ ) : undefined
85
+ }
67
86
  onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
68
87
  />
69
88
  )
@@ -1,4 +1,4 @@
1
- import type { AssistantId } from '../../../../state/types'
1
+ import type { AssistantId, WorktreeRecord } from '../../../../state/types'
2
2
 
3
3
  import { getAllAssistantOptions, getAssistantOption } from '../../../../pty/command-registry'
4
4
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
@@ -13,17 +13,59 @@ interface NewTabModalProps {
13
13
  customCommands: Record<string, string>
14
14
  filter: string | null
15
15
  cursorPos?: number
16
+ currentSessionId: string | null
16
17
  editingCommand: AssistantId | null
17
18
  editBuffer: string
19
+ activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
20
+ branchError: string | null
21
+ branchName: string
22
+ createWorktree: boolean
23
+ selectedAssistantId: AssistantId | null
24
+ step: 'assistant' | 'worktree' | 'worktree-create'
25
+ worktreeDeleteConfirmId: string | null
26
+ worktreeDeleteMessage: string | null
27
+ worktrees: WorktreeRecord[]
28
+ worktreeName: string
29
+ }
30
+
31
+ function getDeleteBlockedReason({
32
+ currentSessionId,
33
+ worktree,
34
+ worktrees,
35
+ }: {
36
+ currentSessionId: string | null
37
+ worktree: WorktreeRecord | undefined
38
+ worktrees: WorktreeRecord[]
39
+ }): string | null {
40
+ if (!worktree) return null
41
+ if (!(currentSessionId != null && currentSessionId !== ''))
42
+ return 'Cannot delete: no active session.'
43
+ if (worktrees.length <= 1) return 'Cannot delete: at least one worktree must remain.'
44
+ if (worktree.source === 'primary') return 'Cannot delete: root worktree is required.'
45
+ if (worktree.source === 'aimux-temp' && !worktree.createdByAimux) {
46
+ return 'Cannot delete: this temp worktree was not created by Aimux.'
47
+ }
48
+ return null
18
49
  }
19
50
 
20
51
  export function NewTabModal({
52
+ activeField,
53
+ branchError,
54
+ branchName,
55
+ createWorktree,
56
+ currentSessionId,
21
57
  cursorPos,
22
58
  customCommands,
23
59
  editBuffer,
24
60
  editingCommand,
25
61
  filter,
62
+ selectedAssistantId,
26
63
  selectedIndex,
64
+ step,
65
+ worktreeDeleteConfirmId,
66
+ worktreeDeleteMessage,
67
+ worktreeName,
68
+ worktrees,
27
69
  }: NewTabModalProps) {
28
70
  const t = useTheme()
29
71
 
@@ -51,17 +93,137 @@ export function NewTabModal({
51
93
  const options = getAllAssistantOptions(customCommands)
52
94
  const filtered = filterAssistants(options, filter)
53
95
 
96
+ if (step === 'worktree-create') {
97
+ const selectedAssistant =
98
+ options.find((option) => option.id === selectedAssistantId) ?? options[0]
99
+ return (
100
+ <Form
101
+ title={`New worktree: ${selectedAssistant?.label ?? 'assistant'}`}
102
+ keybindsModeId="modal.new-tab.command-edit"
103
+ width={uiTokens.modalWidth.xl}
104
+ >
105
+ <box flexDirection="column" gap={1}>
106
+ <TextField
107
+ active={activeField === 'worktree-name'}
108
+ label="Worktree name"
109
+ value={worktreeName}
110
+ cursorPos={activeField === 'worktree-name' ? cursorPos : undefined}
111
+ placeholder="my-feature"
112
+ />
113
+ <box flexDirection="column">
114
+ <TextField
115
+ active={activeField === 'branch-name'}
116
+ label="Branch name"
117
+ value={branchName}
118
+ cursorPos={activeField === 'branch-name' ? cursorPos : undefined}
119
+ placeholder="aimux/my-feature"
120
+ />
121
+ {branchError != null && branchError !== '' ? (
122
+ <text fg={t.error}>{branchError}</text>
123
+ ) : null}
124
+ </box>
125
+ <text fg={t.textMuted}>Step 3/3: configure new worktree</text>
126
+ </box>
127
+ </Form>
128
+ )
129
+ }
130
+
131
+ if (step === 'worktree') {
132
+ const selectedAssistant =
133
+ options.find((option) => option.id === selectedAssistantId) ??
134
+ filtered[selectedIndex] ??
135
+ options[0]
136
+ const selectedWorktree = worktrees[selectedIndex]
137
+ const deleteBlockedReason = getDeleteBlockedReason({
138
+ currentSessionId,
139
+ worktree: selectedWorktree,
140
+ worktrees,
141
+ })
142
+ const worktreeItems: PickerItem[] = [
143
+ ...worktrees.map((worktree, index) => {
144
+ const active = index === selectedIndex
145
+ const label = worktree.branch ?? worktree.name
146
+ const blockedReason = getDeleteBlockedReason({ currentSessionId, worktree, worktrees })
147
+ const canDelete = blockedReason == null || blockedReason === ''
148
+ return {
149
+ key: worktree.id,
150
+ onClick: () => {
151
+ dispatchGlobal({ index, type: 'set-modal-selection-index' })
152
+ runSideEffectGlobal({ type: 'launch-selected-assistant' })
153
+ },
154
+ onDelete:
155
+ active && canDelete && currentSessionId != null && currentSessionId !== ''
156
+ ? () => {
157
+ dispatchGlobal({ index, type: 'set-modal-selection-index' })
158
+ dispatchGlobal({ message: null, type: 'set-new-tab-worktree-delete-state' })
159
+ runSideEffectGlobal({
160
+ force: worktreeDeleteConfirmId === worktree.id,
161
+ sessionId: currentSessionId,
162
+ type: 'delete-worktree',
163
+ worktreeId: worktree.id,
164
+ })
165
+ }
166
+ : undefined,
167
+ subtitle: (
168
+ <box flexDirection="column">
169
+ <text fg={t.textMuted}>{worktree.path}</text>
170
+ <text fg={t.textMuted}>{worktree.source}</text>
171
+ </box>
172
+ ),
173
+ title: <text fg={active ? t.text : t.textMuted}>{label}</text>,
174
+ }
175
+ }),
176
+ {
177
+ key: '__create-worktree__',
178
+ onClick: () => dispatchGlobal({ type: 'enter-new-tab-worktree-create' }),
179
+ subtitle: <text fg={t.textMuted}>Create an Aimux temp worktree</text>,
180
+ title: <text fg={createWorktree ? t.text : t.textMuted}>Create new worktree</text>,
181
+ },
182
+ ]
183
+ return (
184
+ <Picker
185
+ title={`New assistant: ${selectedAssistant?.label ?? 'assistant'}`}
186
+ keybindsModeId="modal.new-tab.command-edit"
187
+ width={uiTokens.modalWidth.md}
188
+ gap={1}
189
+ filter={null}
190
+ items={worktreeItems}
191
+ selectedIndex={selectedIndex}
192
+ emptyState={<text fg={t.textMuted}>No worktrees.</text>}
193
+ onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
194
+ footer={
195
+ <box flexDirection="column">
196
+ {(worktreeDeleteMessage != null && worktreeDeleteMessage !== '') ||
197
+ (deleteBlockedReason != null && deleteBlockedReason !== '') ? (
198
+ <text
199
+ fg={
200
+ worktreeDeleteMessage != null && worktreeDeleteMessage !== ''
201
+ ? t.error
202
+ : t.textMuted
203
+ }
204
+ >
205
+ {worktreeDeleteMessage ?? deleteBlockedReason}
206
+ </text>
207
+ ) : null}
208
+ <text fg={t.textMuted}>Step 2/2: choose worktree</text>
209
+ <text fg={t.textMuted}>Enter launches, Ctrl+d deletes selected worktree</text>
210
+ </box>
211
+ }
212
+ />
213
+ )
214
+ }
215
+
54
216
  const items: PickerItem[] = filtered.map((option, index) => {
55
217
  const active = index === selectedIndex
56
218
  const customCmd = customCommands[option.id]
57
219
  return {
58
220
  key: option.id,
59
- onClick: () => runSideEffectGlobal({ type: 'launch-selected-assistant' }),
221
+ onClick: () => dispatchGlobal({ assistantId: option.id, type: 'select-new-tab-assistant' }),
60
222
  onEdit: () => dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
61
223
  subtitle: (
62
224
  <box flexDirection="column">
63
225
  <text fg={t.textMuted}>{option.description}</text>
64
- {customCmd ? <text fg={t.primary}>{customCmd}</text> : null}
226
+ {customCmd != null && customCmd !== '' ? <text fg={t.primary}>{customCmd}</text> : null}
65
227
  </box>
66
228
  ),
67
229
  title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
@@ -70,16 +232,22 @@ export function NewTabModal({
70
232
 
71
233
  return (
72
234
  <Picker
73
- title="New assistant tab"
235
+ title="New assistant: choose assistant"
74
236
  keybindsModeId="modal.new-tab.command-edit"
75
237
  width={uiTokens.modalWidth.md}
76
238
  gap={1}
77
239
  filter={filter}
78
- cursorPos={cursorPos}
240
+ cursorPos={activeField === 'assistant' ? cursorPos : undefined}
79
241
  items={items}
80
242
  selectedIndex={selectedIndex}
81
243
  emptyState={<text fg={t.textMuted}>No matching assistants.</text>}
82
244
  onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
245
+ footer={
246
+ <box flexDirection="column">
247
+ <text fg={t.textMuted}>Step 1/2: choose assistant</text>
248
+ <text fg={t.textMuted}>Enter continues to worktree selection</text>
249
+ </box>
250
+ }
83
251
  />
84
252
  )
85
253
  }
@@ -0,0 +1,94 @@
1
+ import type { WorktreeRecord } from '../../../../state/types'
2
+
3
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
4
+ import { useTheme } from '../../../theme'
5
+ import { uiTokens } from '../../../ui-tokens'
6
+ import { ListItem } from '../../primitives/list-item'
7
+ import { ModalShell } from '../shared/modal-shell'
8
+
9
+ interface WorktreeMoveModalProps {
10
+ deleteSource: boolean
11
+ divergence: Record<string, { ahead: number; behind: number }>
12
+ selectedIndex: number
13
+ sourceLabel: string
14
+ targets: WorktreeRecord[]
15
+ }
16
+
17
+ const MOVE_HINTS: [key: string, label: string][] = [
18
+ ['↵', 'move into selected target'],
19
+ ['d', 'toggle delete source'],
20
+ ['j/k', 'change target'],
21
+ ['esc', 'cancel'],
22
+ ]
23
+
24
+ function formatDivergence(divergence: { ahead: number; behind: number } | undefined): string {
25
+ if (divergence == null) return ''
26
+ const parts: string[] = []
27
+ if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
28
+ if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
29
+ return parts.join(' ')
30
+ }
31
+
32
+ export function WorktreeMoveModal({
33
+ deleteSource,
34
+ divergence,
35
+ selectedIndex,
36
+ sourceLabel,
37
+ targets,
38
+ }: WorktreeMoveModalProps) {
39
+ const t = useTheme()
40
+ return (
41
+ <ModalShell
42
+ title={`Move ${sourceLabel} →`}
43
+ keybindsModeId="modal.worktree-move"
44
+ width={uiTokens.modalWidth.md}
45
+ footer={
46
+ <box flexDirection="column" gap={1} marginTop={1}>
47
+ <box flexDirection="row" gap={1}>
48
+ <text fg={t.textMuted}>delete source after move</text>
49
+ <text fg={deleteSource ? t.warning : t.text}>{deleteSource ? 'on' : 'off'}</text>
50
+ </box>
51
+ <box flexDirection="column" gap={0}>
52
+ {MOVE_HINTS.map(([key, label]) => (
53
+ <box key={key} flexDirection="row" gap={2}>
54
+ <box width={4} flexShrink={0}>
55
+ <text fg={t.primary}>{key}</text>
56
+ </box>
57
+ <text fg={t.textMuted}>{label}</text>
58
+ </box>
59
+ ))}
60
+ </box>
61
+ </box>
62
+ }
63
+ >
64
+ <box flexDirection="column" marginTop={1}>
65
+ {targets.length === 0 ? (
66
+ <text fg={t.textMuted}>No other worktree to move into.</text>
67
+ ) : (
68
+ targets.map((worktree, index) => {
69
+ const active = index === selectedIndex
70
+ const label =
71
+ worktree.branch != null && worktree.branch !== '' ? worktree.branch : worktree.name
72
+ const ahead = formatDivergence(divergence[worktree.id])
73
+ return (
74
+ <ListItem
75
+ key={worktree.id}
76
+ id={worktree.id}
77
+ active={active}
78
+ onHover={() => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
79
+ onClick={() => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
80
+ title={
81
+ <text fg={active ? t.text : t.textMuted} wrapMode="none">
82
+ {label}
83
+ {worktree.source === 'primary' ? ' (primary)' : ''}
84
+ {ahead !== '' ? ` ${ahead}` : ''}
85
+ </text>
86
+ }
87
+ />
88
+ )
89
+ })
90
+ )}
91
+ </box>
92
+ </ModalShell>
93
+ )
94
+ }
@@ -34,7 +34,7 @@ function formatResetIn(snap: {
34
34
  resetAt: string | null
35
35
  timeRemaining: string | null
36
36
  }): string | null {
37
- if (snap.resetAt) {
37
+ if (snap.resetAt != null && snap.resetAt !== '') {
38
38
  const diffMs = new Date(snap.resetAt).getTime() - Date.now()
39
39
  if (diffMs > 0) {
40
40
  const totalMin = Math.round(diffMs / 60_000)
@@ -85,7 +85,7 @@ export function AIUsageIndicator() {
85
85
  if (!snap) return null
86
86
  const icon = TOOL_ICON[tool]
87
87
 
88
- if (snap.error && !snap.stale) {
88
+ if (snap.error != null && snap.error !== '' && !(snap.stale === true)) {
89
89
  return (
90
90
  <box
91
91
  key={tool}
@@ -134,7 +134,7 @@ export function AIUsageIndicator() {
134
134
  <text fg={t.text} selectable={false}>
135
135
  {` ${pctText}`}
136
136
  </text>
137
- {reset ? (
137
+ {reset != null && reset !== '' ? (
138
138
  <text fg={t.textMuted} selectable={false}>
139
139
  {` · ${reset}`}
140
140
  </text>
@@ -0,0 +1,88 @@
1
+ import { useTerminalDimensions } from '@opentui/react'
2
+ import { useEffect } from 'react'
3
+
4
+ import {
5
+ type Toast,
6
+ TOAST_CONFIG,
7
+ toastStore,
8
+ type ToastVariant,
9
+ } from '../../../../state/toast-store'
10
+ import { type ResolvedTuiTheme, useTheme } from '../../../theme'
11
+ import { slideOffsetStyle, type ToastAnchor } from './toast-position'
12
+ import { useToastAnimation } from './use-toast-animation'
13
+
14
+ // Cells the toast travels vertically for center anchors (compact, since the card
15
+ // is only a few rows tall); horizontal anchors travel the full card width.
16
+ const VERTICAL_SLIDE = 4
17
+
18
+ function variantStyle(variant: ToastVariant, t: ResolvedTuiTheme): { color: string; icon: string } {
19
+ switch (variant) {
20
+ case 'success':
21
+ return { color: t.success, icon: '✓' }
22
+ case 'warning':
23
+ return { color: t.warning, icon: '⚠' }
24
+ case 'error':
25
+ return { color: t.error, icon: '✗' }
26
+ case 'info':
27
+ return { color: t.primary, icon: '●' }
28
+ }
29
+ }
30
+
31
+ interface ToastItemProps {
32
+ toast: Toast
33
+ anchor: ToastAnchor
34
+ }
35
+
36
+ export function ToastItem({ anchor, toast }: ToastItemProps) {
37
+ const t = useTheme()
38
+ const dimensions = useTerminalDimensions()
39
+ const { away, requestExit } = useToastAnimation({
40
+ onExited: () => toastStore.getState().dismiss(toast.id),
41
+ reduceMotion: TOAST_CONFIG.reduceMotion,
42
+ })
43
+
44
+ useEffect(() => {
45
+ if (toast.durationMs <= 0) return
46
+ const timer = setTimeout(() => requestExit(), toast.durationMs)
47
+ return () => clearTimeout(timer)
48
+ }, [toast.durationMs, requestExit])
49
+
50
+ const width = Math.max(16, Math.min(TOAST_CONFIG.width, dimensions.width - 4))
51
+ const distance = anchor.horizontal === 'center' ? VERTICAL_SLIDE : width + 4
52
+ const margins = slideOffsetStyle(anchor, Math.round(away * distance))
53
+ const { color, icon } = variantStyle(toast.variant, t)
54
+ const hasTitle = toast.title != null && toast.title !== ''
55
+ const hasMessage = toast.message != null && toast.message !== ''
56
+
57
+ return (
58
+ <box flexShrink={0} {...margins}>
59
+ <box
60
+ border
61
+ borderColor={color}
62
+ backgroundColor={t.backgroundElement}
63
+ padding={1}
64
+ width={width}
65
+ >
66
+ {toast.content ?? (
67
+ <box flexDirection="row" gap={1}>
68
+ <text fg={color} selectable={false}>
69
+ {icon}
70
+ </text>
71
+ <box flexDirection="column" flexGrow={1}>
72
+ {hasTitle ? (
73
+ <text fg={t.text} selectable={false} wrapMode="word">
74
+ <strong>{toast.title}</strong>
75
+ </text>
76
+ ) : null}
77
+ {hasMessage ? (
78
+ <text fg={t.textMuted} selectable={false} wrapMode="word">
79
+ {toast.message}
80
+ </text>
81
+ ) : null}
82
+ </box>
83
+ </box>
84
+ )}
85
+ </box>
86
+ </box>
87
+ )
88
+ }