@brimveyn/aimux 1.12.6 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +29 -13
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +563 -85
  10. package/src/app-runtime/snippet-actions.ts +3 -3
  11. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  12. package/src/app-runtime/use-backend-runtime.ts +3 -3
  13. package/src/app-runtime/use-directory-search.ts +7 -5
  14. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  15. package/src/app-runtime/use-pane-size-report.ts +1 -1
  16. package/src/app-runtime/use-renderer-bindings.ts +6 -5
  17. package/src/app.tsx +12 -7
  18. package/src/auto-commit/headless-commands.ts +4 -6
  19. package/src/daemon/daemon.ts +21 -11
  20. package/src/daemon/runtime-paths.ts +1 -1
  21. package/src/daemon/session-manager.ts +1 -1
  22. package/src/daemon/session-registry.ts +10 -3
  23. package/src/diff-parser/parse-patch-files.ts +18 -18
  24. package/src/git/command-queue.ts +19 -3
  25. package/src/git/divergence.ts +46 -0
  26. package/src/git/git-diff.ts +12 -5
  27. package/src/git/git-poller.ts +33 -11
  28. package/src/git/git-status.ts +14 -3
  29. package/src/git/move-worktree.ts +96 -0
  30. package/src/git/use-repo-discovery.ts +2 -1
  31. package/src/git/worktree-divergence-poller.ts +59 -0
  32. package/src/git/worktree.ts +99 -0
  33. package/src/index.tsx +1 -1
  34. package/src/input/keymap/describe-bindings.ts +27 -9
  35. package/src/input/keymap/key-chord.ts +4 -4
  36. package/src/input/keymap/key-format.ts +2 -2
  37. package/src/input/keymap/sequence-resolver.ts +1 -1
  38. package/src/input/keymap/trie.ts +1 -1
  39. package/src/input/modes/bridge.ts +7 -0
  40. package/src/input/modes/transitions.ts +2 -1
  41. package/src/input/modes/types.ts +11 -1
  42. package/src/input/raw-input-handler.ts +1 -1
  43. package/src/input/terminal-text-extraction.ts +9 -5
  44. package/src/integrations/claude-syntax-overlay.ts +8 -8
  45. package/src/integrations/claude-theme-sync.ts +12 -12
  46. package/src/ipc/protocol.ts +3 -3
  47. package/src/platform/clipboard.ts +4 -2
  48. package/src/platform/worktree-deps.ts +22 -0
  49. package/src/platform/worktree-paths.ts +67 -0
  50. package/src/profile-paths.ts +3 -3
  51. package/src/pty/assistant-status-detection-loop.ts +2 -2
  52. package/src/pty/assistant-status-detector.ts +15 -6
  53. package/src/pty/command-registry.ts +2 -1
  54. package/src/pty/pty-manager.ts +1 -1
  55. package/src/services/ai-usage/adapters/claude.ts +5 -5
  56. package/src/services/ai-usage/adapters/codex.ts +8 -8
  57. package/src/services/ai-usage/cache.ts +2 -2
  58. package/src/services/ai-usage/pace.ts +3 -6
  59. package/src/services/ai-usage/provider.ts +1 -1
  60. package/src/session-backend/bootstrap.ts +2 -2
  61. package/src/session-backend/local-session-backend.ts +11 -11
  62. package/src/session-backend/remote-session-backend.ts +60 -45
  63. package/src/session-backend/types.ts +2 -2
  64. package/src/snippets/expand-variables.ts +7 -7
  65. package/src/snippets/run-shell-var.ts +1 -1
  66. package/src/snippets/trigger-detector.ts +1 -1
  67. package/src/state/ai-usage-store.ts +1 -1
  68. package/src/state/git-tree.ts +8 -4
  69. package/src/state/layout-tree.ts +8 -5
  70. package/src/state/reducers/git-mode-state.ts +44 -14
  71. package/src/state/reducers/git-panel-state.ts +38 -2
  72. package/src/state/reducers/modal-state.ts +355 -21
  73. package/src/state/reducers/session-state.ts +60 -1
  74. package/src/state/reducers/tab-state.ts +151 -85
  75. package/src/state/selectors.ts +6 -4
  76. package/src/state/session-catalog.ts +20 -5
  77. package/src/state/session-persistence.ts +8 -4
  78. package/src/state/session-worktrees.ts +227 -0
  79. package/src/state/store.ts +1 -0
  80. package/src/state/toast-store.ts +95 -0
  81. package/src/state/types.ts +92 -13
  82. package/src/state/validation.ts +25 -2
  83. package/src/terminal-manager/manager-client.ts +15 -13
  84. package/src/ui/components/git/diff-renderer/fold-strip.tsx +32 -24
  85. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  86. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  87. package/src/ui/components/git/diff-renderer/split-view.tsx +39 -11
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +29 -7
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +28 -11
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +10 -8
  91. package/src/ui/components/git/git-panel.tsx +129 -83
  92. package/src/ui/components/git/git-view.tsx +96 -28
  93. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  94. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +14 -13
  95. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  96. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  97. package/src/ui/components/layout/session-bar.tsx +135 -117
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +244 -82
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +68 -41
  100. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  101. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  102. package/src/ui/components/layout/split-layout.tsx +27 -20
  103. package/src/ui/components/layout/terminal-pane.tsx +163 -120
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +29 -20
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +17 -10
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +18 -10
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +36 -28
  109. package/src/ui/components/modals/shared/form.tsx +6 -6
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +71 -32
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +50 -29
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +209 -25
  114. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  115. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +111 -0
  116. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +14 -9
  117. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  118. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +23 -7
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +121 -38
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -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
+ }
@@ -0,0 +1,30 @@
1
+ import { type Toast, TOAST_CONFIG, type ToastPosition } from '../../../../state/toast-store'
2
+ import { ToastItem } from './toast-item'
3
+ import { parseToastPosition, stackContainerProps } from './toast-position'
4
+
5
+ const EDGE_MARGIN = 1
6
+
7
+ interface ToastStackProps {
8
+ position: ToastPosition
9
+ toasts: Toast[]
10
+ }
11
+
12
+ // One corner's column of toasts. Newest sits nearest the anchored edge; the stack
13
+ // is capped at maxVisible (older toasts drop off, though they usually auto-dismiss).
14
+ export function ToastStack({ position, toasts }: ToastStackProps) {
15
+ const anchor = parseToastPosition(position)
16
+ const recent = toasts.slice(-TOAST_CONFIG.maxVisible)
17
+ const ordered = anchor.vertical === 'top' ? [...recent].reverse() : recent
18
+ return (
19
+ <box
20
+ position="absolute"
21
+ flexDirection="column"
22
+ gap={TOAST_CONFIG.gap}
23
+ {...stackContainerProps(anchor, EDGE_MARGIN)}
24
+ >
25
+ {ordered.map((toast) => (
26
+ <ToastItem key={toast.id} toast={toast} anchor={anchor} />
27
+ ))}
28
+ </box>
29
+ )
30
+ }
@@ -0,0 +1,24 @@
1
+ import { type Toast, type ToastPosition, useToastStore } from '../../../../state/toast-store'
2
+ import { ToastStack } from './toast-stack'
3
+
4
+ // Mounted once at the root. Groups active toasts by position and renders one
5
+ // absolutely-positioned stack per occupied corner, on top of everything else.
6
+ export function ToastViewport() {
7
+ const toasts = useToastStore((state) => state.toasts)
8
+ if (toasts.length === 0) return null
9
+
10
+ const byPosition = new Map<ToastPosition, Toast[]>()
11
+ for (const entry of toasts) {
12
+ const group = byPosition.get(entry.position) ?? []
13
+ group.push(entry)
14
+ byPosition.set(entry.position, group)
15
+ }
16
+
17
+ return (
18
+ <>
19
+ {[...byPosition].map(([position, group]) => (
20
+ <ToastStack key={position} position={position} toasts={group} />
21
+ ))}
22
+ </>
23
+ )
24
+ }
@@ -0,0 +1,64 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ const STEP_MS = 28
4
+ // Normalized distance off-screen: 1 = fully hidden, 0 = resting. Eased so the
5
+ // toast decelerates in and accelerates out.
6
+ const ENTER_FRAMES = [1, 0.55, 0.3, 0.15, 0.05, 0]
7
+ const EXIT_FRAMES = [0.2, 0.45, 0.72, 1]
8
+
9
+ interface ToastAnimationOptions {
10
+ reduceMotion: boolean
11
+ onExited: () => void
12
+ }
13
+
14
+ /**
15
+ * Drives a toast's slide via a frame loop (terminals have no opacity/transform,
16
+ * so motion is a stepped integer offset). Returns `away` in [0,1] — multiply by a
17
+ * pixel distance to get the cell offset — and `requestExit` to slide back out.
18
+ */
19
+ export function useToastAnimation({ onExited, reduceMotion }: ToastAnimationOptions): {
20
+ away: number
21
+ requestExit: () => void
22
+ } {
23
+ const [away, setAway] = useState(reduceMotion ? 0 : 1)
24
+ const exitingRef = useRef(false)
25
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
26
+ const onExitedRef = useRef(onExited)
27
+ onExitedRef.current = onExited
28
+
29
+ const runFrames = useCallback((frames: number[], done?: () => void) => {
30
+ if (timerRef.current != null) clearInterval(timerRef.current)
31
+ let index = 0
32
+ setAway(frames[0] ?? 0)
33
+ timerRef.current = setInterval(() => {
34
+ index += 1
35
+ if (index >= frames.length) {
36
+ if (timerRef.current != null) clearInterval(timerRef.current)
37
+ timerRef.current = null
38
+ done?.()
39
+ return
40
+ }
41
+ setAway(frames[index] ?? 0)
42
+ }, STEP_MS)
43
+ }, [])
44
+
45
+ useEffect(() => {
46
+ if (reduceMotion) return
47
+ runFrames(ENTER_FRAMES)
48
+ return () => {
49
+ if (timerRef.current != null) clearInterval(timerRef.current)
50
+ }
51
+ }, [reduceMotion, runFrames])
52
+
53
+ const requestExit = useCallback(() => {
54
+ if (exitingRef.current) return
55
+ exitingRef.current = true
56
+ if (reduceMotion) {
57
+ onExitedRef.current()
58
+ return
59
+ }
60
+ runFrames(EXIT_FRAMES, () => onExitedRef.current())
61
+ }, [reduceMotion, runFrames])
62
+
63
+ return { away, requestExit }
64
+ }
@@ -12,7 +12,7 @@ export function InputField({ active, cursorPos, placeholder, value }: InputField
12
12
  const t = useTheme()
13
13
  const fg = active ? t.text : t.textMuted
14
14
  if (!active) {
15
- const showPlaceholder = !value && !!placeholder
15
+ const showPlaceholder = !value && !!(placeholder != null && placeholder !== '')
16
16
  return (
17
17
  <Surface tone="input" padding={1}>
18
18
  <text fg={showPlaceholder ? t.textMuted : fg}>{showPlaceholder ? placeholder : value}</text>
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { memo, type ReactNode, useMemo } from 'react'
2
2
 
3
3
  import { useTheme } from '../../theme'
4
4
  import { Surface } from './surface'
@@ -9,27 +9,43 @@ interface ListItemProps {
9
9
  active: boolean
10
10
  direction?: ListItemDirection
11
11
  id?: string
12
+ index?: number
12
13
  leading?: ReactNode
13
14
  subtitle?: ReactNode
14
15
  title: ReactNode
15
16
  trailing?: ReactNode
16
17
  onHover?: () => void
17
18
  onClick?: () => void
19
+ onHoverIndex?: (index: number) => void
20
+ onClickIndex?: (index: number) => void
18
21
  }
19
22
 
20
- export function ListItem({
23
+ export const ListItem = memo(function ListItem({
21
24
  active,
22
25
  direction = 'column',
23
26
  id,
27
+ index,
24
28
  leading,
25
29
  onClick,
30
+ onClickIndex,
26
31
  onHover,
32
+ onHoverIndex,
27
33
  subtitle,
28
34
  title,
29
35
  trailing,
30
36
  }: ListItemProps) {
31
37
  const t = useTheme()
32
38
  const isRow = direction === 'row'
39
+ // Build per-item handlers from the stable index-based callbacks so callers can
40
+ // avoid creating fresh closures inside their list `.map()`.
41
+ const handleHover = useMemo<(() => void) | undefined>(() => {
42
+ if (onHoverIndex && index !== undefined) return () => onHoverIndex(index)
43
+ return onHover
44
+ }, [index, onHover, onHoverIndex])
45
+ const handleClick = useMemo<(() => void) | undefined>(() => {
46
+ if (onClickIndex && index !== undefined) return () => onClickIndex(index)
47
+ return onClick
48
+ }, [index, onClick, onClickIndex])
33
49
  const inner = (
34
50
  <box flexDirection="column">
35
51
  <box flexDirection="row">
@@ -40,15 +56,15 @@ export function ListItem({
40
56
  </>
41
57
  )}
42
58
  {leading}
43
- {leading ? <text> </text> : null}
59
+ {leading != null ? <text> </text> : null}
44
60
  <box flexGrow={isRow ? 0 : 1}>{title}</box>
45
- {trailing ? <box>{trailing}</box> : null}
61
+ {trailing != null ? <box>{trailing}</box> : null}
46
62
  </box>
47
- {subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
63
+ {subtitle != null ? <box paddingLeft={2}>{subtitle}</box> : null}
48
64
  </box>
49
65
  )
50
66
  return (
51
- <box id={id} onMouseOver={onHover} onMouseDown={onClick}>
67
+ <box id={id} onMouseOver={handleHover} onMouseDown={handleClick}>
52
68
  {active ? (
53
69
  <Surface tone="selected" paddingLeft={isRow ? 2 : 1} paddingRight={isRow ? 2 : 1}>
54
70
  {inner}
@@ -60,4 +76,4 @@ export function ListItem({
60
76
  )}
61
77
  </box>
62
78
  )
63
- }
79
+ })
@@ -8,7 +8,7 @@ export function themeDisplayName(id: ThemeId): string {
8
8
  }
9
9
 
10
10
  export function filterThemeIds(filter: string | null): ThemeId[] {
11
- if (!filter) return THEME_IDS
11
+ if (!(filter != null && filter !== '')) return THEME_IDS
12
12
  const needle = filter.toLowerCase()
13
13
  return THEME_IDS.filter((id) => {
14
14
  return id.toLowerCase().includes(needle) || themeDisplayName(id).toLowerCase().includes(needle)
package/src/ui/root.tsx CHANGED
@@ -1,8 +1,16 @@
1
1
  import type { MouseEvent } from '@opentui/core'
2
2
 
3
+ import { useCallback, useMemo } from 'react'
4
+
3
5
  import type { MeasuredPaneRect } from '../app-runtime/use-pane-size-report'
4
6
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
5
- import type { FocusMode, ModalState, SessionRecord, SnippetRecord } from '../state/types'
7
+ import type {
8
+ FocusMode,
9
+ ModalState,
10
+ SessionRecord,
11
+ SnippetRecord,
12
+ WorktreeRecord,
13
+ } from '../state/types'
6
14
  import type { ThemeId } from './themes'
7
15
 
8
16
  import { useAppStore } from '../state/app-store'
@@ -28,11 +36,15 @@ import { SnippetEditorModal } from './components/modals/snippets/snippet-editor-
28
36
  import { SnippetPickerModal } from './components/modals/snippets/snippet-picker-modal'
29
37
  import { NewTabModal } from './components/modals/tabs/new-tab-modal'
30
38
  import { ThemePickerModal } from './components/modals/themes/theme-picker-modal'
39
+ import { WorktreeMoveModal } from './components/modals/worktree/worktree-move-modal'
31
40
  import { ContextMenuBox } from './components/overlays/context-menu/context-menu-box'
32
41
  import { ContextMenuOverlay } from './components/overlays/context-menu/context-menu-overlay'
33
42
  import { PendingChordOverlay } from './components/overlays/pending-chord-overlay'
43
+ import { ToastViewport } from './components/overlays/toast/toast-viewport'
34
44
  import { useTheme } from './theme'
35
45
 
46
+ const EMPTY_WORKTREES: WorktreeRecord[] = []
47
+
36
48
  function getCreateSessionFields(modal: ModalState) {
37
49
  if (modal.type !== 'create-session') {
38
50
  return { directoryQuery: '', sessionName: '' }
@@ -78,6 +90,7 @@ function renderModal(
78
90
  focusMode: FocusMode
79
91
  activeAssistant?: string
80
92
  autoCommitModel?: string
93
+ worktreeDivergence: Record<string, { ahead: number; behind: number }>
81
94
  }
82
95
  ) {
83
96
  switch (modal.type) {
@@ -89,8 +102,24 @@ function renderModal(
89
102
  customCommands={options.customCommands}
90
103
  filter={modal.editBuffer}
91
104
  cursorPos={modal.cursorPos}
105
+ currentSessionId={options.currentSessionId}
92
106
  editingCommand={modal.type === 'new-tab' ? modal.editingCommand : null}
93
107
  editBuffer={modal.editBuffer ?? ''}
108
+ activeField={modal.type === 'new-tab' ? modal.activeField : 'assistant'}
109
+ branchError={modal.type === 'new-tab' ? modal.branchError : null}
110
+ branchName={modal.type === 'new-tab' ? modal.branchName : ''}
111
+ createWorktree={modal.type === 'new-tab' ? modal.createWorktree : false}
112
+ selectedAssistantId={modal.type === 'new-tab' ? modal.selectedAssistantId : null}
113
+ step={modal.type === 'new-tab' ? modal.step : 'assistant'}
114
+ worktreeDeleteConfirmId={modal.type === 'new-tab' ? modal.worktreeDeleteConfirmId : null}
115
+ worktreeDeleteMessage={modal.type === 'new-tab' ? modal.worktreeDeleteMessage : null}
116
+ worktrees={
117
+ options.currentSessionId != null && options.currentSessionId !== ''
118
+ ? (options.sessions.find((session) => session.id === options.currentSessionId)
119
+ ?.worktrees ?? EMPTY_WORKTREES)
120
+ : EMPTY_WORKTREES
121
+ }
122
+ worktreeName={modal.type === 'new-tab' ? modal.worktreeName : ''}
94
123
  />
95
124
  )
96
125
  case 'session-picker':
@@ -107,7 +136,11 @@ function renderModal(
107
136
  case 'session-name':
108
137
  return (
109
138
  <SessionNameModal
110
- title={modal.sessionTargetId ? 'Rename workspace' : 'Create workspace'}
139
+ title={
140
+ modal.sessionTargetId != null && modal.sessionTargetId !== ''
141
+ ? 'Rename workspace'
142
+ : 'Create workspace'
143
+ }
111
144
  value={modal.editBuffer ?? ''}
112
145
  />
113
146
  )
@@ -161,6 +194,21 @@ function renderModal(
161
194
  latestVersion={modal.latestVersion}
162
195
  />
163
196
  )
197
+ case 'worktree-move': {
198
+ const session =
199
+ options.currentSessionId != null && options.currentSessionId !== ''
200
+ ? options.sessions.find((entry) => entry.id === options.currentSessionId)
201
+ : undefined
202
+ return (
203
+ <WorktreeMoveModal
204
+ deleteSource={modal.type === 'worktree-move' ? modal.deleteSource : false}
205
+ divergence={options.worktreeDivergence}
206
+ selectedIndex={modal.selectedIndex}
207
+ sourceWorktreeId={modal.sourceWorktreeId}
208
+ worktrees={session?.worktrees ?? EMPTY_WORKTREES}
209
+ />
210
+ )
211
+ }
164
212
  case 'help':
165
213
  return (
166
214
  <HelpModal
@@ -266,6 +314,7 @@ export function RootView({
266
314
  const customCommands = useAppStore((s) => s.customCommands)
267
315
  const sessions = useAppStore((s) => s.sessions)
268
316
  const currentSessionId = useAppStore((s) => s.currentSessionId)
317
+ const worktreeDivergence = useAppStore((s) => s.worktreeDivergence)
269
318
  const sessionBarPosition = useAppStore((s) => s.sessionBar.position)
270
319
  const gitPaneMode = useAppStore((s) => s.gitPane.mode)
271
320
  const gitPaneVisible = useAppStore((s) => s.gitPane.visible)
@@ -275,19 +324,66 @@ export function RootView({
275
324
  const sidebarVisible = useAppStore((s) => s.sidebar.visible)
276
325
 
277
326
  const gitPaneInPaneOnLeft = gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'left'
327
+ const splitChrome = PANE_BORDER * 2
328
+
329
+ const handleSidebarEdgeResize = useCallback(
330
+ (event: MouseEvent): boolean => {
331
+ onSidebarResizeStart?.({ initialWidth: sidebarWidth, screenStart: event.x })
332
+ return true
333
+ },
334
+ [onSidebarResizeStart, sidebarWidth]
335
+ )
278
336
  const handleTerminalLeftEdgeMouseDown =
279
337
  sidebarVisible && !gitPaneInPaneOnLeft && onSidebarResizeStart
280
- ? (event: MouseEvent) => {
281
- onSidebarResizeStart({ initialWidth: sidebarWidth, screenStart: event.x })
282
- return true
283
- }
338
+ ? handleSidebarEdgeResize
284
339
  : undefined
285
340
 
341
+ const handleRootMouseDrag = useCallback(
342
+ (event: MouseEvent) => {
343
+ if (onSeparatorDrag?.(event) === true) {
344
+ event.preventDefault()
345
+ event.stopPropagation()
346
+ }
347
+ },
348
+ [onSeparatorDrag]
349
+ )
350
+ const handleRootMouseUp = useCallback(
351
+ (event: MouseEvent) => {
352
+ // Catch releases that land outside any TerminalPane (sidebar, gap,
353
+ // status bar, …) so an in-flight multi-click drag — and its
354
+ // auto-scroll interval — is always finalised.
355
+ onTerminalMouseUp?.(event)
356
+ onSeparatorDragEnd?.()
357
+ },
358
+ [onSeparatorDragEnd, onTerminalMouseUp]
359
+ )
360
+
361
+ const splitContentOrigin = useMemo(
362
+ () => ({
363
+ cols: terminalCols + splitChrome,
364
+ rows: terminalRows + splitChrome,
365
+ x: contentOrigin.x - PANE_BORDER,
366
+ y: contentOrigin.y - PANE_BORDER,
367
+ }),
368
+ [contentOrigin, splitChrome, terminalCols, terminalRows]
369
+ )
370
+ const splitBounds = useMemo(
371
+ () => ({
372
+ cols: terminalCols + splitChrome,
373
+ rows: terminalRows + splitChrome,
374
+ x: 0,
375
+ y: 0,
376
+ }),
377
+ [splitChrome, terminalCols, terminalRows]
378
+ )
379
+
286
380
  const activeTab = tabs.find((tab) => tab.id === activeTabId)
287
- const activeTree = activeTabId ? getTreeForTab(layoutTrees, tabGroupMap, activeTabId) : null
381
+ const activeTree =
382
+ activeTabId != null && activeTabId !== ''
383
+ ? getTreeForTab(layoutTrees, tabGroupMap, activeTabId)
384
+ : null
288
385
  const createSessionFields = getCreateSessionFields(modal)
289
386
  const snippetEditorFields = getSnippetEditorFields(modal)
290
- const splitChrome = PANE_BORDER * 2
291
387
 
292
388
  const inGitMode = focusMode === 'git' || modal.type === 'git-commit'
293
389
  if (inGitMode) {
@@ -310,7 +406,9 @@ export function RootView({
310
406
  snippetEditorFields,
311
407
  snippets,
312
408
  themeId,
409
+ worktreeDivergence,
313
410
  })}
411
+ <ToastViewport />
314
412
  </box>
315
413
  )
316
414
  }
@@ -321,19 +419,8 @@ export function RootView({
321
419
  width="100%"
322
420
  height="100%"
323
421
  backgroundColor={editorBg}
324
- onMouseDrag={(event) => {
325
- if (onSeparatorDrag?.(event)) {
326
- event.preventDefault()
327
- event.stopPropagation()
328
- }
329
- }}
330
- onMouseUp={(event) => {
331
- // Catch releases that land outside any TerminalPane (sidebar, gap,
332
- // status bar, …) so an in-flight multi-click drag — and its
333
- // auto-scroll interval — is always finalised.
334
- onTerminalMouseUp?.(event)
335
- onSeparatorDragEnd?.()
336
- }}
422
+ onMouseDrag={handleRootMouseDrag}
423
+ onMouseUp={handleRootMouseUp}
337
424
  >
338
425
  {sessionBarPosition === 'top' && <SessionBar />}
339
426
  <box flexDirection="row" gap={0} padding={0} flexGrow={1}>
@@ -356,12 +443,7 @@ export function RootView({
356
443
  tabs={tabs}
357
444
  activeTabId={activeTabId}
358
445
  focusMode={focusMode}
359
- contentOrigin={{
360
- cols: terminalCols + splitChrome,
361
- rows: terminalRows + splitChrome,
362
- x: contentOrigin.x - PANE_BORDER,
363
- y: contentOrigin.y - PANE_BORDER,
364
- }}
446
+ contentOrigin={splitContentOrigin}
365
447
  mouseForwardingEnabled={mouseForwardingEnabled}
366
448
  localScrollbackEnabled={localScrollbackEnabled}
367
449
  onTerminalMouseEvent={onTerminalMouseEvent}
@@ -376,12 +458,7 @@ export function RootView({
376
458
  onSeparatorDragEnd={onSeparatorDragEnd}
377
459
  onLeftEdgeMouseDown={handleTerminalLeftEdgeMouseDown}
378
460
  onMeasure={onMeasure}
379
- bounds={{
380
- cols: terminalCols + splitChrome,
381
- rows: terminalRows + splitChrome,
382
- x: 0,
383
- y: 0,
384
- }}
461
+ bounds={splitBounds}
385
462
  />
386
463
  ) : (
387
464
  <TerminalPane
@@ -424,7 +501,9 @@ export function RootView({
424
501
  snippetEditorFields,
425
502
  snippets,
426
503
  themeId,
504
+ worktreeDivergence,
427
505
  })}
506
+ <ToastViewport />
428
507
  </box>
429
508
  )
430
509
  }
@@ -455,16 +534,20 @@ function GitPaneInPaneMode({
455
534
  dispatchGlobal({ position: nextPosition, type: 'set-git-pane-position' })
456
535
  }
457
536
  )
537
+ const handleResizeMouseDown = useCallback(
538
+ (event: MouseEvent) => {
539
+ event.preventDefault()
540
+ event.stopPropagation()
541
+ onGitPaneResizeStart?.({ initialWidth: width, screenStart: event.x, side: position })
542
+ },
543
+ [onGitPaneResizeStart, position, width]
544
+ )
458
545
  const handle = (
459
546
  <box
460
547
  width={1}
461
548
  flexShrink={0}
462
549
  backgroundColor={tokens.border}
463
- onMouseDown={(event) => {
464
- event.preventDefault()
465
- event.stopPropagation()
466
- onGitPaneResizeStart?.({ initialWidth: width, screenStart: event.x, side: position })
467
- }}
550
+ onMouseDown={handleResizeMouseDown}
468
551
  />
469
552
  )
470
553
  return (
@@ -5,7 +5,7 @@ import type { SessionRecord } from '../state/types'
5
5
  * with any missing `order` falling back to `createdAt` ascending.
6
6
  */
7
7
  export function orderSessionsForDisplay(sessions: SessionRecord[]): SessionRecord[] {
8
- return sessions.slice().sort((a, b) => {
8
+ return [...sessions].sort((a, b) => {
9
9
  const ao = a.order ?? Number.MAX_SAFE_INTEGER
10
10
  const bo = b.order ?? Number.MAX_SAFE_INTEGER
11
11
  if (ao !== bo) return ao - bo
@@ -27,7 +27,7 @@ export function moveIdToIdPosition(
27
27
  const from = ids.indexOf(moveId)
28
28
  const to = ids.indexOf(intoPositionOfId)
29
29
  if (from < 0 || to < 0) return ids
30
- const next = ids.slice()
30
+ const next = [...ids]
31
31
  next.splice(from, 1)
32
32
  next.splice(to, 0, moveId)
33
33
  return next