@brimveyn/aimux 1.12.6 → 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 (130) 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/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +538 -83
  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 +3 -3
  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 +1 -1
  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 +3 -3
  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 +2 -1
  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 +6 -4
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  91. package/src/ui/components/git/git-panel.tsx +17 -10
  92. package/src/ui/components/git/git-view.tsx +79 -19
  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 +5 -4
  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 +3 -3
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  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 +1 -1
  103. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  109. package/src/ui/components/modals/shared/form.tsx +4 -5
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +28 -5
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +11 -3
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  114. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  115. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  116. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  117. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  118. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  119. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  120. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  121. package/src/ui/components/primitives/input-field.tsx +1 -1
  122. package/src/ui/components/primitives/list-item.tsx +3 -3
  123. package/src/ui/filter-themes.ts +1 -1
  124. package/src/ui/root.tsx +57 -3
  125. package/src/ui/session-ordering.ts +2 -2
  126. package/src/ui/status-bar-model.ts +14 -7
  127. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  128. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  129. package/src/ui/terminal-graphics/kitty.ts +2 -7
  130. package/src/update/version-check.ts +1 -1
@@ -62,7 +62,11 @@ export function CreateSessionModal({
62
62
  label="Search projects"
63
63
  placeholder="Type a project name..."
64
64
  value={directoryQuery}
65
- displayValue={pendingProjectPath ? abbreviatePath(pendingProjectPath) : directoryQuery}
65
+ displayValue={
66
+ pendingProjectPath != null && pendingProjectPath !== ''
67
+ ? abbreviatePath(pendingProjectPath)
68
+ : directoryQuery
69
+ }
66
70
  items={items}
67
71
  selectedIndex={selectedIndex}
68
72
  maxVisibleRows={VISIBLE_ROWS}
@@ -47,7 +47,7 @@ export function SessionPickerModal({
47
47
  const ordered = orderSessionsForDisplay(sessions)
48
48
  const baselineOrder = ordered.map((s) => s.id)
49
49
  const filtered = filterSessions(ordered, filter)
50
- const hasFilter = !!filter
50
+ const hasFilter = !!(filter != null && filter !== '')
51
51
 
52
52
  const sessionItems: PickerItem[] = filtered.map((session, index) => {
53
53
  const active = index === selectedIndex
@@ -56,9 +56,10 @@ export function SessionPickerModal({
56
56
  key: session.id,
57
57
  onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
58
58
  onDelete: () => runSideEffectGlobal({ type: 'delete-selected-session' }),
59
- subtitle: session.projectPath ? (
60
- <text fg={t.textMuted}>{abbreviatePath(session.projectPath)}</text>
61
- ) : undefined,
59
+ subtitle:
60
+ session.projectPath != null && session.projectPath !== '' ? (
61
+ <text fg={t.textMuted}>{abbreviatePath(session.projectPath)}</text>
62
+ ) : undefined,
62
63
  title: (
63
64
  <text fg={active ? t.text : t.textMuted}>
64
65
  {formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
@@ -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
@@ -52,7 +52,9 @@ export function SnippetPickerModal({
52
52
  <text fg={active ? t.text : t.textMuted}>
53
53
  <strong>{snippet.name}</strong>
54
54
  </text>
55
- {snippet.trigger ? <text fg={t.textMuted}>{` :${snippet.trigger}`}</text> : null}
55
+ {snippet.trigger != null && snippet.trigger !== '' ? (
56
+ <text fg={t.textMuted}>{` :${snippet.trigger}`}</text>
57
+ ) : null}
56
58
  {fromConfig ? <text fg={t.textMuted}>{' [config]'}</text> : null}
57
59
  </box>
58
60
  ),
@@ -71,10 +73,16 @@ export function SnippetPickerModal({
71
73
  selectedIndex={selectedIndex}
72
74
  emptyState={
73
75
  <text fg={t.textMuted}>
74
- {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.'}
75
79
  </text>
76
80
  }
77
- footer={actionMessage ? <text fg={t.error}>{actionMessage}</text> : undefined}
81
+ footer={
82
+ actionMessage != null && actionMessage !== '' ? (
83
+ <text fg={t.error}>{actionMessage}</text>
84
+ ) : undefined
85
+ }
78
86
  onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
79
87
  />
80
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
+ }
@@ -0,0 +1,45 @@
1
+ import type { ToastPosition } from '../../../../state/toast-store'
2
+
3
+ export interface ToastAnchor {
4
+ vertical: 'top' | 'bottom'
5
+ horizontal: 'left' | 'center' | 'right'
6
+ }
7
+
8
+ export function parseToastPosition(position: ToastPosition): ToastAnchor {
9
+ const [vertical, horizontal] = position.split('-') as [
10
+ ToastAnchor['vertical'],
11
+ ToastAnchor['horizontal'],
12
+ ]
13
+ return { horizontal, vertical }
14
+ }
15
+
16
+ // Absolute box props that pin a stack to its corner. Center spans the width and
17
+ // centers its children; left/right hug their edge. Margin keeps it off the edge.
18
+ export function stackContainerProps(anchor: ToastAnchor, margin: number) {
19
+ const vertical = anchor.vertical === 'top' ? { top: margin } : { bottom: margin }
20
+ if (anchor.horizontal === 'left') {
21
+ return { ...vertical, alignItems: 'flex-start' as const, left: margin }
22
+ }
23
+ if (anchor.horizontal === 'right') {
24
+ return { ...vertical, alignItems: 'flex-end' as const, right: margin }
25
+ }
26
+ return { ...vertical, alignItems: 'center' as const, left: margin, right: margin }
27
+ }
28
+
29
+ interface SlideMargins {
30
+ marginLeft?: number
31
+ marginRight?: number
32
+ marginTop?: number
33
+ marginBottom?: number
34
+ }
35
+
36
+ // A negative margin on the entering edge pushes the toast that many cells off the
37
+ // nearest screen edge (the overflow is clipped by the screen buffer); animating it
38
+ // back to 0 slides the toast in. `offset` is cells off-screen (0 = resting). Used
39
+ // instead of opacity/transform, which terminals don't have.
40
+ export function slideOffsetStyle(anchor: ToastAnchor, offset: number): SlideMargins {
41
+ const off = -offset
42
+ if (anchor.horizontal === 'right') return { marginRight: off }
43
+ if (anchor.horizontal === 'left') return { marginLeft: off }
44
+ return anchor.vertical === 'top' ? { marginTop: off } : { marginBottom: off }
45
+ }