@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,12 +1,23 @@
1
- import {
2
- type BoxRenderable,
3
- type MouseEvent as OtuiMouseEvent,
4
- type ScrollBoxRenderable,
1
+ import type {
2
+ BoxRenderable,
3
+ MouseEvent as OtuiMouseEvent,
4
+ ScrollBoxRenderable,
5
5
  } from '@opentui/core'
6
+
6
7
  import { memo, useMemo, useRef } from 'react'
7
8
 
9
+ import type { BranchDivergence } from '../../../../state/types'
10
+
11
+ import { useWorktreeDivergencePolling } from '../../../../git/worktree-divergence-poller'
8
12
  import { useAppStore } from '../../../../state/app-store'
9
13
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
14
+ import {
15
+ getActiveWorktree,
16
+ getRenderedTabWorktreeId,
17
+ getSessionProjectPath,
18
+ getWorktreeColor,
19
+ orderTabsByWorktree,
20
+ } from '../../../../state/session-worktrees'
10
21
  import { getCurrentTheme, type ResolvedTuiTheme, useTheme } from '../../../theme'
11
22
  import { buildGitPaneContextMenu } from '../../git/pane/git-pane-context-menu'
12
23
  import { GitPaneWidget } from '../../git/pane/git-pane-widget'
@@ -33,6 +44,28 @@ const GUTTER_END = '╰'
33
44
  const GUTTER_PAD = '│'
34
45
  const RESIZE_HANDLE = '─'
35
46
 
47
+ // Left accent strip + trailing space for a worktree group header.
48
+ const WORKTREE_STRIP = '▍ '
49
+
50
+ // Compact "↑ahead ↓behind" label for a worktree group header; empty when the
51
+ // branch is level with its base (or divergence isn't known yet).
52
+ function formatDivergence(divergence: BranchDivergence | undefined): string {
53
+ if (divergence == null) return ''
54
+ const parts: string[] = []
55
+ if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
56
+ if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
57
+ return parts.join(' ')
58
+ }
59
+
60
+ // Fit a worktree label into `max` columns, ellipsizing when it would overflow.
61
+ // Branch names are ASCII, so character count tracks rendered column width.
62
+ function truncateLabel(label: string, max: number): string {
63
+ if (max <= 0) return ''
64
+ if (label.length <= max) return label
65
+ if (max === 1) return '…'
66
+ return `${label.slice(0, max - 1)}…`
67
+ }
68
+
36
69
  function getRowBackground({
37
70
  alternate,
38
71
  isActive,
@@ -51,10 +84,12 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
51
84
  const t = useTheme()
52
85
  const currentSessionId = useAppStore((s) => s.currentSessionId)
53
86
  const sessions = useAppStore((s) => s.sessions)
54
- const currentSession = currentSessionId
55
- ? sessions.find((s) => s.id === currentSessionId)
56
- : undefined
57
- const projectPath = currentSession?.projectPath
87
+ const currentSession =
88
+ currentSessionId != null && currentSessionId !== ''
89
+ ? sessions.find((s) => s.id === currentSessionId)
90
+ : undefined
91
+ const activeWorktree = getActiveWorktree(currentSession)
92
+ const projectPath = getSessionProjectPath(currentSession)
58
93
  const branch = useSidebarBranch(projectPath)
59
94
 
60
95
  return (
@@ -65,14 +100,20 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
65
100
  <text fg={t.text} selectable={false}>
66
101
  {currentSession ? currentSession.name : 'No workspace selected'}
67
102
  </text>
68
- {branch ? (
103
+ {(branch != null && branch !== '') || activeWorktree ? (
69
104
  <box flexDirection="row">
70
105
  <text fg={t.text} selectable={false}>
71
106
  {'\u{e702}'}{' '}
72
107
  </text>
73
108
  <text fg={t.text} selectable={false}>
74
- {branch}
109
+ {branch ?? activeWorktree?.branch ?? activeWorktree?.name}
75
110
  </text>
111
+ {activeWorktree?.source === 'aimux-temp' ? (
112
+ <text fg={t.textMuted} selectable={false}>
113
+ {' '}
114
+ tmp
115
+ </text>
116
+ ) : null}
76
117
  </box>
77
118
  ) : null}
78
119
  <box
@@ -114,25 +155,54 @@ function renderGroupGutter(isGroupStart: boolean, isGroupMiddle: boolean, isGrou
114
155
 
115
156
  interface TabsBodyProps {
116
157
  onTabActivate?: (tabId: string) => void
158
+ contentWidth: number
117
159
  }
118
160
 
119
- const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
161
+ const TabsBody = memo(function TabsBody({ contentWidth, onTabActivate }: TabsBodyProps) {
120
162
  const t = useTheme()
121
163
  const tabs = useAppStore((s) => s.tabs)
122
164
  const activeTabId = useAppStore((s) => s.activeTabId)
123
165
  const focusMode = useAppStore((s) => s.focusMode)
124
166
  const layoutTrees = useAppStore((s) => s.layoutTrees)
125
167
  const sidebarVisible = useAppStore((s) => s.sidebar.visible)
168
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
169
+ const sessions = useAppStore((s) => s.sessions)
170
+ const worktreeDivergence = useAppStore((s) => s.worktreeDivergence)
171
+ const currentSession =
172
+ currentSessionId != null && currentSessionId !== ''
173
+ ? sessions.find((s) => s.id === currentSessionId)
174
+ : undefined
175
+ const worktrees = useMemo(() => currentSession?.worktrees ?? [], [currentSession?.worktrees])
176
+ const worktreeById = useMemo(
177
+ () => new Map(worktrees.map((worktree) => [worktree.id, worktree])),
178
+ [worktrees]
179
+ )
180
+ const groupedTabs = useMemo(() => {
181
+ return orderTabsByWorktree(tabs, currentSession)
182
+ }, [currentSession, tabs])
183
+ const showWorktreeSeparators = useMemo(() => {
184
+ const ids = new Set<string>()
185
+ for (const tab of groupedTabs) {
186
+ ids.add(getRenderedTabWorktreeId(tab, worktrees))
187
+ }
188
+ const activeWorktreeId =
189
+ currentSession?.activeWorktreeId ?? getActiveWorktree(currentSession)?.id
190
+ if (activeWorktreeId != null && activeWorktreeId !== '') ids.add(activeWorktreeId)
191
+ return ids.size >= 2
192
+ }, [currentSession, groupedTabs, worktrees])
126
193
 
127
194
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
128
- const activeIndex = tabs.findIndex((tab) => tab.id === activeTabId)
129
- const tabGroupInfo = useMemo(() => buildTabGroupInfo(layoutTrees, tabs), [layoutTrees, tabs])
195
+ const activeIndex = groupedTabs.findIndex((tab) => tab.id === activeTabId)
196
+ const tabGroupInfo = useMemo(
197
+ () => buildTabGroupInfo(layoutTrees, groupedTabs),
198
+ [layoutTrees, groupedTabs]
199
+ )
130
200
 
131
201
  useSidebarAutoScroll({
132
202
  activeIndex,
133
203
  activeTabId,
134
204
  scrollRef,
135
- tabCount: tabs.length,
205
+ tabCount: groupedTabs.length,
136
206
  visible: sidebarVisible,
137
207
  })
138
208
 
@@ -152,35 +222,90 @@ const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
152
222
  </text>
153
223
  </box>
154
224
  ) : (
155
- tabs.map((tab, index) => {
225
+ groupedTabs.map((tab, index) => {
156
226
  const isActive = tab.id === activeTabId
157
227
  const alternate = index % 2 === 1
158
228
  const info = tabGroupInfo.get(tab.id)
159
- const inLayout = !!info?.inLayout
229
+ const inLayout = !!(info?.inLayout === true)
160
230
  const inGroup = info ? index >= info.groupStart && index <= info.groupEnd : false
161
231
  const isGroupStart = info ? index === info.groupStart : false
162
232
  const isGroupEnd = info ? index === info.groupEnd : false
163
233
  const isGroupMiddle = inGroup && !isGroupStart && !isGroupEnd
234
+ const tabOwnWorktree =
235
+ tab.worktreeId != null && tab.worktreeId !== ''
236
+ ? worktreeById.get(tab.worktreeId)
237
+ : undefined
238
+ let tabWorktree = tabOwnWorktree
239
+ if (!tabWorktree && worktrees.length > 1) {
240
+ tabWorktree = worktrees[0]
241
+ }
242
+ // A tab's worktree can be moved when it has a branch (squash needs one,
243
+ // so not the primary) and there's at least one other worktree to land in.
244
+ const moveWorktreeId =
245
+ tabOwnWorktree?.branch != null && tabOwnWorktree.branch !== '' && worktrees.length > 1
246
+ ? tabOwnWorktree.id
247
+ : undefined
248
+ const prevTab = groupedTabs[index - 1]
249
+ const startsWorktreeGroup =
250
+ !prevTab ||
251
+ getRenderedTabWorktreeId(prevTab, worktrees) !==
252
+ getRenderedTabWorktreeId(tab, worktrees)
253
+ const worktreeColor = tabWorktree
254
+ ? (tabWorktree.color ?? getWorktreeColor(tabWorktree.id))
255
+ : t.textMuted
256
+ const worktreeLabel = tabWorktree?.branch ?? tabWorktree?.name ?? 'main'
257
+ const aheadBehind = formatDivergence(
258
+ tabWorktree != null ? worktreeDivergence[tabWorktree.id] : undefined
259
+ )
260
+ const aheadBehindText = aheadBehind === '' ? '' : ` ${aheadBehind}`
261
+ // Reserve room for the strip, the ahead/behind chip and a 1-col gap so
262
+ // the header never wraps; the divider fills whatever remains (min 0).
263
+ const labelBudget = Math.max(
264
+ 0,
265
+ contentWidth - WORKTREE_STRIP.length - aheadBehindText.length - 1
266
+ )
267
+ const headerLabel = truncateLabel(worktreeLabel, labelBudget)
164
268
 
165
269
  return (
166
- <box
167
- key={tab.id}
168
- backgroundColor={getRowBackground({ alternate, isActive, t })}
169
- flexDirection="row"
170
- onMouseDown={(event) => {
171
- event.stopPropagation()
172
- onTabActivate?.(tab.id)
173
- }}
174
- >
175
- {inGroup ? renderGroupGutter(isGroupStart, isGroupMiddle, isGroupEnd) : null}
176
- <box flexGrow={1}>
177
- <TabItem
178
- id={`sidebar-tab-${tab.id}`}
179
- tab={tab}
180
- active={isActive}
181
- focused={focusMode === 'navigation'}
182
- inLayout={inLayout}
183
- />
270
+ <box key={tab.id} flexDirection="column">
271
+ {showWorktreeSeparators && startsWorktreeGroup ? (
272
+ <box flexDirection="row" overflow="hidden" paddingTop={index === 0 ? 0 : 1}>
273
+ <text selectable={false} wrapMode="none" flexShrink={0}>
274
+ <span fg={worktreeColor}>
275
+ {WORKTREE_STRIP}
276
+ {headerLabel}
277
+ </span>
278
+ {aheadBehindText !== '' ? (
279
+ <span fg={t.textMuted}>{aheadBehindText}</span>
280
+ ) : null}
281
+ </text>
282
+ <box flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
283
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
284
+ {' '}
285
+ {''.repeat(contentWidth)}
286
+ </text>
287
+ </box>
288
+ </box>
289
+ ) : null}
290
+ <box
291
+ backgroundColor={getRowBackground({ alternate, isActive, t })}
292
+ flexDirection="row"
293
+ onMouseDown={(event) => {
294
+ event.stopPropagation()
295
+ onTabActivate?.(tab.id)
296
+ }}
297
+ >
298
+ {inGroup ? renderGroupGutter(isGroupStart, isGroupMiddle, isGroupEnd) : null}
299
+ <box flexGrow={1}>
300
+ <TabItem
301
+ id={`sidebar-tab-${tab.id}`}
302
+ tab={tab}
303
+ active={isActive}
304
+ focused={focusMode === 'navigation'}
305
+ inLayout={inLayout}
306
+ moveWorktreeId={moveWorktreeId}
307
+ />
308
+ </box>
184
309
  </box>
185
310
  </box>
186
311
  )
@@ -203,6 +328,7 @@ export function Sidebar({
203
328
  const gitPane = useAppStore((s) => s.gitPane)
204
329
  const focusMode = useAppStore((s) => s.focusMode)
205
330
  const bodyRef = useRef<BoxRenderable | null>(null)
331
+ useWorktreeDivergencePolling(sidebarVisible)
206
332
 
207
333
  if (!sidebarVisible) {
208
334
  return null
@@ -289,7 +415,7 @@ export function Sidebar({
289
415
  }
290
416
  }}
291
417
  onMouseDrag={(event) => {
292
- if (onResizeDrag?.(event)) {
418
+ if (onResizeDrag?.(event) === true) {
293
419
  event.preventDefault()
294
420
  event.stopPropagation()
295
421
  }
@@ -315,7 +441,7 @@ export function Sidebar({
315
441
  flexBasis={0}
316
442
  overflow="hidden"
317
443
  >
318
- <TabsBody onTabActivate={onTabActivate} />
444
+ <TabsBody onTabActivate={onTabActivate} contentWidth={contentWidth} />
319
445
  </box>
320
446
  {gitOnBottom ? (
321
447
  <>
@@ -13,6 +13,8 @@ interface TabItemProps {
13
13
  active: boolean
14
14
  focused: boolean
15
15
  inLayout?: boolean
16
+ /** When set, this tab's worktree can be moved — adds a "Move worktree" entry. */
17
+ moveWorktreeId?: string
16
18
  }
17
19
 
18
20
  function getStatusColor(status: TabSession['status']): string {
@@ -106,7 +108,7 @@ function ActivityIndicator({ tab }: { tab: TabSession }) {
106
108
  )
107
109
  }
108
110
 
109
- export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
111
+ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }: TabItemProps) {
110
112
  const t = useTheme()
111
113
  const label = tab.command.split(' ')[0]
112
114
  const isInLayout = inLayout ?? false
@@ -152,6 +154,20 @@ export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
152
154
  dispatchGlobal({ delta: 1, type: 'reorder-active-tab' })
153
155
  },
154
156
  ],
157
+ // Move this tab's worktree into another one. Opened from here it overlays
158
+ // the normal view (no git mode) since the open action doesn't touch focus.
159
+ ...(moveWorktreeId != null && moveWorktreeId !== ''
160
+ ? [
161
+ [
162
+ 'Move worktree',
163
+ () =>
164
+ dispatchGlobal({
165
+ sourceWorktreeId: moveWorktreeId,
166
+ type: 'open-worktree-move-modal',
167
+ }),
168
+ ] as [string, () => void],
169
+ ]
170
+ : []),
155
171
  ]}
156
172
  onMouseOver={() => setHovered(true)}
157
173
  onMouseOut={() => setHovered(false)}
@@ -36,7 +36,7 @@ export function useSidebarAutoScroll({
36
36
  return
37
37
  }
38
38
 
39
- if (!previousVisibilityRef.current && activeTabId) {
39
+ if (!previousVisibilityRef.current && activeTabId != null && activeTabId !== '') {
40
40
  scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
41
41
  previousVisibilityRef.current = true
42
42
  previousActiveIndexRef.current = activeIndex
@@ -53,7 +53,7 @@ export function useSidebarAutoScroll({
53
53
  scrollbox.scrollTo({ x: 0, y: 0 })
54
54
  } else if (scrollTarget === 'bottom') {
55
55
  scrollbox.scrollTo({ x: 0, y: scrollbox.scrollHeight })
56
- } else if (scrollTarget === 'active-item' && activeTabId) {
56
+ } else if (scrollTarget === 'active-item' && activeTabId != null && activeTabId !== '') {
57
57
  scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
58
58
  }
59
59
 
@@ -8,7 +8,7 @@ export function useSidebarBranch(projectPath: string | undefined): string | null
8
8
  const [branch, setBranch] = useState<string | null>(null)
9
9
 
10
10
  useEffect(() => {
11
- if (!projectPath) {
11
+ if (!(projectPath != null && projectPath !== '')) {
12
12
  setBranch(null)
13
13
  return
14
14
  }
@@ -161,7 +161,7 @@ export function SplitLayout({
161
161
  e.preventDefault()
162
162
  if (!onSeparatorDragStart) return
163
163
  const leafId = getFirstLeafId(node.first)
164
- if (!leafId) return
164
+ if (!(leafId != null && leafId !== '')) return
165
165
  onSeparatorDragStart({
166
166
  direction: node.direction,
167
167
  screenStart:
@@ -3,7 +3,7 @@ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
3
3
  import { memo, type ReactNode } from 'react'
4
4
 
5
5
  import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
6
- import type { TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
6
+ import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
7
7
 
8
8
  import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
9
9
  import { logInputDebug } from '../../../debug/input-log'
@@ -15,7 +15,7 @@ import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
15
15
  interface TerminalPaneProps {
16
16
  tab?: TabSession
17
17
  tabId?: string
18
- focusMode: import('../../../state/types').FocusMode
18
+ focusMode: FocusMode
19
19
  isActive?: boolean
20
20
  contentOrigin: TerminalContentOrigin
21
21
  mouseForwardingEnabled: boolean
@@ -61,15 +61,15 @@ function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMo
61
61
  function renderSpan(span: TerminalSpan, key: string): ReactNode {
62
62
  let node: ReactNode = span.text
63
63
 
64
- if (span.underline) {
64
+ if (span.underline === true) {
65
65
  node = <u>{node}</u>
66
66
  }
67
67
 
68
- if (span.italic) {
68
+ if (span.italic === true) {
69
69
  node = <em>{node}</em>
70
70
  }
71
71
 
72
- if (span.bold) {
72
+ if (span.bold === true) {
73
73
  node = <strong>{node}</strong>
74
74
  }
75
75
 
@@ -132,35 +132,36 @@ export function TerminalPane({
132
132
  const paneIsActive = isActive ?? true
133
133
  const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
134
134
  const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
135
- const rightClickMenu: ContextMenuItem[] | undefined = tabId
136
- ? [
137
- [
138
- 'Split vertically',
139
- () =>
140
- runSideEffectGlobal({
141
- direction: 'vertical',
142
- sourceTabId: tabId,
143
- type: 'split-pane',
144
- }),
145
- ],
146
- [
147
- 'Split horizontally',
148
- () =>
149
- runSideEffectGlobal({
150
- direction: 'horizontal',
151
- sourceTabId: tabId,
152
- type: 'split-pane',
153
- }),
154
- ],
155
- [
156
- 'Close pane',
157
- () => {
158
- dispatchGlobal({ tabId, type: 'close-pane' })
159
- runSideEffectGlobal({ tabId, type: 'close-tab' })
160
- },
161
- ],
162
- ]
163
- : undefined
135
+ const rightClickMenu: ContextMenuItem[] | undefined =
136
+ tabId != null && tabId !== ''
137
+ ? [
138
+ [
139
+ 'Split vertically',
140
+ () =>
141
+ runSideEffectGlobal({
142
+ direction: 'vertical',
143
+ sourceTabId: tabId,
144
+ type: 'split-pane',
145
+ }),
146
+ ],
147
+ [
148
+ 'Split horizontally',
149
+ () =>
150
+ runSideEffectGlobal({
151
+ direction: 'horizontal',
152
+ sourceTabId: tabId,
153
+ type: 'split-pane',
154
+ }),
155
+ ],
156
+ [
157
+ 'Close pane',
158
+ () => {
159
+ dispatchGlobal({ tabId, type: 'close-pane' })
160
+ runSideEffectGlobal({ tabId, type: 'close-tab' })
161
+ },
162
+ ],
163
+ ]
164
+ : undefined
164
165
  const isOnPaneBorder = (event: OtuiMouseEvent) =>
165
166
  event.x === contentOrigin.x - 1 ||
166
167
  event.x === contentOrigin.x + contentOrigin.cols ||
@@ -206,22 +207,22 @@ export function TerminalPane({
206
207
  })
207
208
  }
208
209
  if (event.type === 'drag') {
209
- if (onTerminalDrag?.(event, contentOrigin, tabId)) {
210
+ if (onTerminalDrag?.(event, contentOrigin, tabId) === true) {
210
211
  event.preventDefault()
211
212
  return
212
213
  }
213
- if (onSeparatorDrag?.(event)) {
214
+ if (onSeparatorDrag?.(event) === true) {
214
215
  event.preventDefault()
215
216
  return
216
217
  }
217
218
  }
218
219
  if (event.type === 'up') {
219
- if (onTerminalMouseUp?.(event)) {
220
+ if (onTerminalMouseUp?.(event) === true) {
220
221
  event.preventDefault()
221
222
  }
222
223
  onSeparatorDragEnd?.()
223
224
  }
224
- if (tabId && onPaneActivate && event.type === 'down') {
225
+ if (tabId != null && tabId !== '' && onPaneActivate && event.type === 'down') {
225
226
  onPaneActivate(tabId)
226
227
  }
227
228
  if (!canForwardMouse) {
@@ -308,7 +309,7 @@ export function TerminalPane({
308
309
  </box>
309
310
  )}
310
311
  </ContextMenuBox>
311
- {tab?.status === 'disconnected' || tab?.errorMessage ? (
312
+ {tab?.status === 'disconnected' || (tab?.errorMessage != null && tab.errorMessage !== '') ? (
312
313
  // Absolutely positioned so it overlays the bordered box instead of
313
314
  // consuming a flex row. If it took a row, the rendered terminal area
314
315
  // would be one line shorter than the size sent to the PTY/xterm,
@@ -317,7 +318,9 @@ export function TerminalPane({
317
318
  {tab?.status === 'disconnected' ? (
318
319
  <text fg={t.warning}>Restored snapshot. Press Ctrl+r to restart this workspace.</text>
319
320
  ) : null}
320
- {tab?.errorMessage ? <text fg={t.error}>{tab.errorMessage}</text> : null}
321
+ {tab?.errorMessage != null && tab?.errorMessage !== '' ? (
322
+ <text fg={t.error}>{tab.errorMessage}</text>
323
+ ) : null}
321
324
  </box>
322
325
  ) : null}
323
326
  </box>
@@ -93,7 +93,7 @@ interface ToolSectionProps {
93
93
 
94
94
  function ToolSection({ snap, tool }: ToolSectionProps) {
95
95
  const t = useTheme()
96
- const isHardError = Boolean(snap.error) && !snap.stale
96
+ const isHardError = Boolean(snap.error) && !(snap.stale === true)
97
97
  const relative = formatRelative(snap.lastUpdated)
98
98
 
99
99
  let body: ReactNode
@@ -119,7 +119,7 @@ function ToolSection({ snap, tool }: ToolSectionProps) {
119
119
  <text fg={t.text} selectable={false}>
120
120
  {TOOL_TITLE[tool]}
121
121
  </text>
122
- {snap.planTier ? (
122
+ {snap.planTier != null && snap.planTier !== '' ? (
123
123
  <text fg={t.textMuted} selectable={false}>
124
124
  {snap.planTier}
125
125
  </text>
@@ -145,7 +145,10 @@ function WindowRow({ window }: { window: UsageWindow }) {
145
145
  }
146
146
 
147
147
  const pctText = percent === null ? '—' : `${Math.round(percent)}% used`
148
- const resetText = window.timeRemaining ? `Resets in ${window.timeRemaining}` : null
148
+ const resetText =
149
+ window.timeRemaining != null && window.timeRemaining !== ''
150
+ ? `Resets in ${window.timeRemaining}`
151
+ : null
149
152
 
150
153
  return (
151
154
  <box flexDirection="column" paddingTop={1}>
@@ -164,7 +167,7 @@ function WindowRow({ window }: { window: UsageWindow }) {
164
167
  <text fg={t.textMuted} selectable={false}>
165
168
  {pctText}
166
169
  </text>
167
- {resetText ? (
170
+ {resetText != null && resetText !== '' ? (
168
171
  <text fg={t.textMuted} selectable={false}>
169
172
  {resetText}
170
173
  </text>
@@ -181,7 +184,7 @@ function PaceLine({ pace }: { pace: NonNullable<UsageWindow['pace']> }) {
181
184
  if (paceStageIsBehind(pace.stage)) color = t.warning
182
185
  else if (paceStageIsAhead(pace.stage)) color = t.success
183
186
 
184
- const suffix = pace.rightText ? ` · ${pace.rightText}` : ''
187
+ const suffix = pace.rightText != null && pace.rightText !== '' ? ` · ${pace.rightText}` : ''
185
188
  return (
186
189
  <text fg={color} selectable={false}>
187
190
  {`Pace: ${pace.label}${suffix}`}
@@ -19,7 +19,7 @@ interface HelpModalProps {
19
19
  function matchesFilter(needle: string, ...fields: (string | undefined)[]): boolean {
20
20
  if (!needle) return true
21
21
  const lower = needle.toLowerCase()
22
- return fields.some((f) => f?.toLowerCase().includes(lower))
22
+ return fields.some((f) => f?.toLowerCase().includes(lower) === true)
23
23
  }
24
24
 
25
25
  export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModalProps) {
@@ -75,7 +75,9 @@ export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModal
75
75
  items={items}
76
76
  selectedIndex={selectedIndex}
77
77
  emptyState={
78
- <text fg={t.textMuted}>{filter ? 'No matching bindings.' : 'No bindings registered.'}</text>
78
+ <text fg={t.textMuted}>
79
+ {filter != null && filter !== '' ? 'No matching bindings.' : 'No bindings registered.'}
80
+ </text>
79
81
  }
80
82
  onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
81
83
  />
@@ -90,7 +90,7 @@ export function GitCommitModal({
90
90
  const currentSessionId = useAppStore((s) => s.currentSessionId)
91
91
  const bgKind = useAppStore((s) => {
92
92
  const id = s.currentSessionId
93
- return id ? s.autoCommit.bySession[id]?.kind : undefined
93
+ return id != null && id !== '' ? s.autoCommit.bySession[id]?.kind : undefined
94
94
  })
95
95
  const isBgGenerating = bgKind === 'generating'
96
96
  const showBgSpinner = isBgGenerating && !isConfirm && !isGenerating
@@ -101,7 +101,7 @@ export function GitCommitModal({
101
101
 
102
102
  const onAutoCommitClick = (): void => {
103
103
  const hasTitle = title.trim().length > 0
104
- if (hasTitle || !currentSessionId) {
104
+ if (hasTitle || !(currentSessionId != null && currentSessionId !== '')) {
105
105
  dispatchGlobal({ type: 'git-commit-enter-confirm' })
106
106
  return
107
107
  }
@@ -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)}