@brimveyn/aimux 1.9.0 → 1.9.1

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 (67) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +6 -1
  3. package/src/app.tsx +20 -16
  4. package/src/input/keymap/describe-bindings.ts +63 -2
  5. package/src/pty/terminal-snapshot.ts +7 -5
  6. package/src/state/reducers/git-mode-state.ts +58 -0
  7. package/src/state/types.ts +11 -6
  8. package/src/ui/breaking-update-screen.tsx +2 -2
  9. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  10. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  11. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  12. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  13. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  14. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  15. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
  17. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  18. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  19. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
  20. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
  21. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  22. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
  23. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  24. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  25. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  26. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  27. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  28. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  29. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  30. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  31. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  32. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  33. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  34. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  35. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  36. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  37. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  38. package/src/ui/components/modals/shared/form.tsx +164 -0
  39. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  40. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  41. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  42. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  43. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  44. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  45. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  46. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  47. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  48. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  49. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  50. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  51. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  52. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  53. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  54. package/src/ui/root.tsx +24 -22
  55. package/src/ui/theme-store.ts +30 -26
  56. package/src/ui/themes.ts +1 -0
  57. package/src/ui/components/create-session-modal.tsx +0 -96
  58. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  59. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  60. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  61. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  62. package/src/ui/components/session-name-modal.tsx +0 -11
  63. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  64. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  65. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  66. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  67. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -0,0 +1,240 @@
1
+ import type { ThemedToken } from 'shiki'
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import type { DiffSegment } from './build-rows'
6
+
7
+ import { useAppStore } from '../../../../state/app-store'
8
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
9
+ import { getHighlightsTokens, getParsedPayload } from '../../../../state/types'
10
+ import { type HighlightPatch, tokenizeSegment } from './highlight'
11
+ import { prepareDiff, type PreparedParsedDiff } from './prepare-diff'
12
+
13
+ export interface DiffPreparation {
14
+ file: PreparedParsedDiff['file']
15
+ filetype: string | null
16
+ firstChangeOffset: number
17
+ highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
18
+ preparing: boolean
19
+ ready: boolean
20
+ requestSegmentHighlights: (segments: readonly DiffSegment[]) => void
21
+ segments: DiffSegment[]
22
+ }
23
+
24
+ const EMPTY_HIGHLIGHTS = { add: [] as ThemedToken[][], del: [] as ThemedToken[][] }
25
+ const EMPTY_PARSED: PreparedParsedDiff = {
26
+ file: null,
27
+ filetype: null,
28
+ firstChangeOffset: -1,
29
+ segments: [],
30
+ }
31
+ const MAX_HIGHLIGHT_SEGMENTS_PER_PASS = 8
32
+
33
+ function applyHighlightPatches(
34
+ source: { add: ThemedToken[][]; del: ThemedToken[][] },
35
+ add: readonly HighlightPatch[],
36
+ del: readonly HighlightPatch[]
37
+ ): { add: ThemedToken[][]; del: ThemedToken[][] } {
38
+ const nextAdd = [...source.add]
39
+ const nextDel = [...source.del]
40
+ for (const patch of add) {
41
+ for (let i = 0; i < patch.tokens.length; i++) nextAdd[patch.start + i] = patch.tokens[i] ?? []
42
+ }
43
+ for (const patch of del) {
44
+ for (let i = 0; i < patch.tokens.length; i++) nextDel[patch.start + i] = patch.tokens[i] ?? []
45
+ }
46
+ return { add: nextAdd, del: nextDel }
47
+ }
48
+
49
+ function segmentHasHighlights(
50
+ segment: DiffSegment,
51
+ highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
52
+ ): boolean {
53
+ if (segment.kind === 'context') {
54
+ for (let i = 0; i < segment.count; i++) {
55
+ if (!highlights.add[segment.addStart + i] || !highlights.del[segment.delStart + i])
56
+ return false
57
+ }
58
+ return true
59
+ }
60
+ if (segment.kind === 'change') {
61
+ for (let i = 0; i < segment.additions; i++) {
62
+ if (!highlights.add[segment.addStart + i]) return false
63
+ }
64
+ for (let i = 0; i < segment.deletions; i++) {
65
+ if (!highlights.del[segment.delStart + i]) return false
66
+ }
67
+ return true
68
+ }
69
+ return true
70
+ }
71
+
72
+ export function useDiffPreparation(
73
+ cacheKey: string,
74
+ diff: string,
75
+ path: string,
76
+ themeId: string
77
+ ): DiffPreparation {
78
+ const requestOwner = `${cacheKey}|${themeId}|${diff}`
79
+ const cachedParsed = useAppStore((s) => s.gitMode.parsedFiles[cacheKey])
80
+ const highlightKey = `${cacheKey}|${themeId}`
81
+ const cachedHighlights = useAppStore((s) => s.gitMode.highlights[highlightKey])
82
+
83
+ const [preparing, setPreparing] = useState(false)
84
+ const [localParsed, setLocalParsed] = useState<PreparedParsedDiff | null>(null)
85
+ const [localHighlights, setLocalHighlights] = useState(EMPTY_HIGHLIGHTS)
86
+ const [localHash, setLocalHash] = useState<string | null>(null)
87
+ const completedSegmentsRef = useRef(new Set<string>())
88
+ const currentOwnerRef = useRef(requestOwner)
89
+ const pendingSegmentsRef = useRef(new Set<string>())
90
+ const requestVersionRef = useRef(0)
91
+ const localOwnerMatches = currentOwnerRef.current === requestOwner
92
+
93
+ const parsed = useMemo(() => {
94
+ return (
95
+ getParsedPayload<PreparedParsedDiff>(cachedParsed) ??
96
+ (localOwnerMatches ? localParsed : null) ??
97
+ EMPTY_PARSED
98
+ )
99
+ }, [cachedParsed, localOwnerMatches, localParsed])
100
+
101
+ const highlights = useMemo(() => {
102
+ const cached = getHighlightsTokens(cachedHighlights)
103
+ if (cached && cachedHighlights?.hash === cachedParsed?.hash) return cached
104
+ return localOwnerMatches ? localHighlights : EMPTY_HIGHLIGHTS
105
+ }, [cachedHighlights, cachedParsed, localHighlights, localOwnerMatches])
106
+
107
+ const ready = !!parsed.file
108
+
109
+ useLayoutEffect(() => {
110
+ currentOwnerRef.current = requestOwner
111
+ requestVersionRef.current += 1
112
+ completedSegmentsRef.current.clear()
113
+ pendingSegmentsRef.current.clear()
114
+ setLocalParsed(null)
115
+ setLocalHighlights(EMPTY_HIGHLIGHTS)
116
+ setLocalHash(null)
117
+ setPreparing(false)
118
+ }, [cacheKey, diff, path, requestOwner, themeId])
119
+
120
+ useEffect(() => {
121
+ if (cachedParsed) return
122
+
123
+ const controller = new AbortController()
124
+ const owner = requestOwner
125
+ const version = requestVersionRef.current
126
+ setPreparing(true)
127
+ void prepareDiff(diff, path, { signal: controller.signal })
128
+ .then((result) => {
129
+ if (
130
+ controller.signal.aborted ||
131
+ requestVersionRef.current !== version ||
132
+ currentOwnerRef.current !== owner
133
+ ) {
134
+ return
135
+ }
136
+ setLocalParsed(result.parsed)
137
+ setLocalHighlights(result.highlights)
138
+ setLocalHash(result.hash)
139
+ dispatchGlobal({
140
+ file: result.parsed,
141
+ hash: result.hash,
142
+ key: cacheKey,
143
+ type: 'git-mode-set-parsed',
144
+ })
145
+ })
146
+ .catch(() => {})
147
+ .finally(() => {
148
+ if (
149
+ !controller.signal.aborted &&
150
+ requestVersionRef.current === version &&
151
+ currentOwnerRef.current === owner
152
+ ) {
153
+ setPreparing(false)
154
+ }
155
+ })
156
+
157
+ return () => {
158
+ controller.abort()
159
+ }
160
+ }, [cacheKey, cachedParsed, diff, path, requestOwner, themeId])
161
+
162
+ const requestSegmentHighlights = useCallback(
163
+ (segments: readonly DiffSegment[]) => {
164
+ const file = parsed.file
165
+ const filetype = parsed.filetype
166
+ if (!file || !filetype) return
167
+ const owner = requestOwner
168
+ const version = requestVersionRef.current
169
+ const activeHash = cachedParsed?.hash ?? (localOwnerMatches ? localHash : null)
170
+ if (!activeHash) return
171
+ const next = segments.filter(
172
+ (segment) =>
173
+ (segment.kind === 'context' || segment.kind === 'change') &&
174
+ !completedSegmentsRef.current.has(segment.id) &&
175
+ !pendingSegmentsRef.current.has(segment.id) &&
176
+ !segmentHasHighlights(segment, highlights)
177
+ )
178
+ const queue = next.slice(0, MAX_HIGHLIGHT_SEGMENTS_PER_PASS)
179
+ if (queue.length === 0) return
180
+ for (const segment of queue) pendingSegmentsRef.current.add(segment.id)
181
+ void (async () => {
182
+ const add: HighlightPatch[] = []
183
+ const del: HighlightPatch[] = []
184
+ try {
185
+ for (const segment of queue) {
186
+ if (requestVersionRef.current !== version || currentOwnerRef.current !== owner) return
187
+ const patch = await tokenizeSegment(file, segment, filetype)
188
+ add.push(...patch.add)
189
+ del.push(...patch.del)
190
+ }
191
+ } finally {
192
+ if (requestVersionRef.current === version && currentOwnerRef.current === owner) {
193
+ for (const segment of queue) {
194
+ pendingSegmentsRef.current.delete(segment.id)
195
+ completedSegmentsRef.current.add(segment.id)
196
+ }
197
+ }
198
+ }
199
+ if (
200
+ requestVersionRef.current !== version ||
201
+ currentOwnerRef.current !== owner ||
202
+ (add.length === 0 && del.length === 0)
203
+ ) {
204
+ return
205
+ }
206
+ setLocalHighlights((current) => applyHighlightPatches(current, add, del))
207
+ dispatchGlobal({
208
+ add,
209
+ del,
210
+ hash: activeHash,
211
+ key: cacheKey,
212
+ themeId,
213
+ type: 'git-mode-merge-highlights',
214
+ })
215
+ })()
216
+ },
217
+ [
218
+ cacheKey,
219
+ cachedParsed?.hash,
220
+ highlights,
221
+ localHash,
222
+ localOwnerMatches,
223
+ parsed.file,
224
+ parsed.filetype,
225
+ requestOwner,
226
+ themeId,
227
+ ]
228
+ )
229
+
230
+ return {
231
+ file: parsed.file,
232
+ filetype: parsed.filetype,
233
+ firstChangeOffset: parsed.firstChangeOffset,
234
+ highlights,
235
+ preparing,
236
+ ready,
237
+ requestSegmentHighlights,
238
+ segments: parsed.segments,
239
+ }
240
+ }
@@ -0,0 +1,95 @@
1
+ import type { ScrollBoxRenderable } from '@opentui/core'
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import type { DiffSegment } from './build-rows'
6
+
7
+ interface VirtualizedSegment<T extends DiffSegment> {
8
+ bottomSpacer: number
9
+ topSpacer: number
10
+ totalHeight: number
11
+ visible: T[]
12
+ }
13
+
14
+ interface Options<T extends DiffSegment> {
15
+ estimateHeight: (segment: T) => number
16
+ overscan: number
17
+ scrollRef: React.RefObject<ScrollBoxRenderable | null>
18
+ segments: readonly T[]
19
+ version: number
20
+ }
21
+
22
+ interface ViewportState {
23
+ height: number
24
+ scrollTop: number
25
+ }
26
+
27
+ function sameViewport(a: ViewportState, b: ViewportState): boolean {
28
+ return a.height === b.height && a.scrollTop === b.scrollTop
29
+ }
30
+
31
+ export function useSegmentVirtualization<T extends DiffSegment>({
32
+ estimateHeight,
33
+ overscan,
34
+ scrollRef,
35
+ segments,
36
+ version,
37
+ }: Options<T>): VirtualizedSegment<T> {
38
+ const [viewport, setViewport] = useState<ViewportState>({ height: 0, scrollTop: 0 })
39
+ const estimateHeightRef = useRef(estimateHeight)
40
+
41
+ useEffect(() => {
42
+ estimateHeightRef.current = estimateHeight
43
+ }, [estimateHeight])
44
+
45
+ useEffect(() => {
46
+ let frame = 0
47
+ const tick = (): void => {
48
+ const node = scrollRef.current
49
+ const next = {
50
+ height: Math.max(1, node?.viewport.height ?? 0),
51
+ scrollTop: Math.max(0, node?.scrollTop ?? 0),
52
+ }
53
+ setViewport((prev) => (sameViewport(prev, next) ? prev : next))
54
+ frame = requestAnimationFrame(tick)
55
+ }
56
+ frame = requestAnimationFrame(tick)
57
+ return () => cancelAnimationFrame(frame)
58
+ }, [scrollRef])
59
+
60
+ return useMemo(() => {
61
+ void version
62
+ if (segments.length === 0) {
63
+ return { bottomSpacer: 0, topSpacer: 0, totalHeight: 0, visible: [] }
64
+ }
65
+ const topTarget = Math.max(0, viewport.scrollTop - overscan)
66
+ const bottomTarget = viewport.scrollTop + viewport.height + overscan
67
+ let totalHeight = 0
68
+ let topSpacer = 0
69
+ let start = 0
70
+ while (start < segments.length) {
71
+ const height = estimateHeightRef.current(segments[start] as T)
72
+ if (totalHeight + height > topTarget) break
73
+ topSpacer += height
74
+ totalHeight += height
75
+ start += 1
76
+ }
77
+ let end = start
78
+ while (end < segments.length && totalHeight < bottomTarget) {
79
+ totalHeight += estimateHeightRef.current(segments[end] as T)
80
+ end += 1
81
+ }
82
+ let bottomSpacer = 0
83
+ for (let i = end; i < segments.length; i++) {
84
+ bottomSpacer += estimateHeightRef.current(segments[i] as T)
85
+ }
86
+ totalHeight = topSpacer + bottomSpacer
87
+ for (let i = start; i < end; i++) totalHeight += estimateHeightRef.current(segments[i] as T)
88
+ return {
89
+ bottomSpacer,
90
+ topSpacer,
91
+ totalHeight,
92
+ visible: segments.slice(start, end) as T[],
93
+ }
94
+ }, [overscan, segments, version, viewport])
95
+ }
@@ -9,11 +9,15 @@ import type {
9
9
  GitPaneDiffCountConfig,
10
10
  GitPanelState,
11
11
  GitPanePathConfig,
12
- } from '../../state/types'
12
+ } from '../../../state/types'
13
13
 
14
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
15
- import { buildGitTreeRows, type GitTreeFileRow, type GitTreeFolderRow } from '../../state/git-tree'
16
- import { getCurrentTokens, getTransparent, useTokens, useTransparent } from '../theme'
14
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
15
+ import {
16
+ buildGitTreeRows,
17
+ type GitTreeFileRow,
18
+ type GitTreeFolderRow,
19
+ } from '../../../state/git-tree'
20
+ import { getCurrentTokens, getTransparent, useTokens, useTransparent } from '../../theme'
17
21
 
18
22
  interface GitPanelProps {
19
23
  collapsedFolders?: Record<string, true>
@@ -344,7 +348,7 @@ export const GitPanel = memo(function GitPanel({
344
348
  }, [selectedEntryKey, tree.visibleRows])
345
349
 
346
350
  return (
347
- <box flexDirection="column" flexGrow={1} gap={0}>
351
+ <box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden" gap={0}>
348
352
  {hasRemoteTracking ? (
349
353
  <text fg={t.muted}>
350
354
  ↑{gitPanel.ahead} ↓{gitPanel.behind}
@@ -1,17 +1,17 @@
1
1
  import { useTerminalDimensions } from '@opentui/react'
2
2
  import { memo, useEffect, useRef } from 'react'
3
3
 
4
- import type { DiffData, GitDiffView } from '../../state/types'
5
- import type { ThemeId } from '../themes'
4
+ import type { DiffData, GitDiffView } from '../../../state/types'
5
+ import type { ThemeId } from '../../themes'
6
6
 
7
- import { diffHash } from '../../git/diff-hash'
8
- import { fetchDiff } from '../../git/git-diff'
9
- import { useGitPanelPolling } from '../../git/git-poller'
10
- import { useAppStore } from '../../state/app-store'
11
- import { dispatchGlobal } from '../../state/dispatch-ref'
12
- import { getSelectedGitFile, gitFileKey } from '../../state/git-tree'
13
- import { setGitDiffScroller } from '../git-view-controls'
14
- import { useBg, useTokens } from '../theme'
7
+ import { diffHash } from '../../../git/diff-hash'
8
+ import { fetchDiff } from '../../../git/git-diff'
9
+ import { useGitPanelPolling } from '../../../git/git-poller'
10
+ import { useAppStore } from '../../../state/app-store'
11
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
12
+ import { getSelectedGitFile, gitFileKey } from '../../../state/git-tree'
13
+ import { setGitDiffScroller } from '../../git-view-controls'
14
+ import { useBg, useTokens } from '../../theme'
15
15
  import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
16
16
  import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
17
17
  import { GitPanel } from './git-panel'
@@ -107,6 +107,7 @@ interface GitViewProps {
107
107
  export const GitView = memo(function GitView({ themeId }: GitViewProps) {
108
108
  const t = useTokens()
109
109
  const sidebarBg = useBg('elevated')
110
+ const actionBg = useBg('selected')
110
111
  const dimensions = useTerminalDimensions()
111
112
  const gitPane = useAppStore((s) => s.gitPane)
112
113
  const gitPanel = useAppStore((s) => s.gitPanel)
@@ -206,7 +207,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
206
207
  }
207
208
 
208
209
  return (
209
- <box flexDirection="column" flexGrow={1}>
210
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
210
211
  <box flexDirection="row" flexGrow={1}>
211
212
  <box
212
213
  width={fileBarWidth}
@@ -214,25 +215,28 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
214
215
  backgroundColor={sidebarBg}
215
216
  padding={0}
216
217
  gap={0}
218
+ overflow="hidden"
217
219
  >
218
- <text fg={t.palette.primary}>
219
- <strong>aimux · git</strong>
220
- </text>
221
- {gitPanel.branch ? (
222
- <box flexDirection="row">
223
- <text fg={t.palette.primary}>{'\u{e702}'} </text>
224
- <text fg={t.muted}>{gitPanel.branch}</text>
225
- </box>
226
- ) : null}
227
- {gitMode.headOffset > 0 ? (
228
- <box flexDirection="row" gap={1}>
229
- <text fg={t.palette.warning}>
230
- <strong>HEAD~{gitMode.headOffset}</strong>
231
- </text>
232
- <text fg={t.muted}>[ newer · ] older</text>
233
- </box>
234
- ) : null}
235
- <text fg={t.hover}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
220
+ <box flexDirection="column" flexShrink={0}>
221
+ <text fg={t.palette.primary}>
222
+ <strong>aimux · git</strong>
223
+ </text>
224
+ {gitPanel.branch ? (
225
+ <box flexDirection="row">
226
+ <text fg={t.palette.primary}>{'\u{e702}'} </text>
227
+ <text fg={t.muted}>{gitPanel.branch}</text>
228
+ </box>
229
+ ) : null}
230
+ {gitMode.headOffset > 0 ? (
231
+ <box flexDirection="row" gap={1}>
232
+ <text fg={t.palette.warning}>
233
+ <strong>HEAD~{gitMode.headOffset}</strong>
234
+ </text>
235
+ <text fg={t.muted}>[ newer · ] older</text>
236
+ </box>
237
+ ) : null}
238
+ <text fg={t.hover}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
239
+ </box>
236
240
  <GitPanel
237
241
  collapsedFolders={gitMode.collapsedFolders}
238
242
  compact={gitPane.treeCompaction}
@@ -243,14 +247,43 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
243
247
  selectedEntryKey={gitMode.selectedEntryKey}
244
248
  />
245
249
  </box>
246
- <DiffStage
247
- diff={diff}
248
- diffKey={selectedDiffKey}
249
- diffRef={diffRef}
250
- loading={loading}
251
- themeId={themeId}
252
- view={gitMode.diffView}
253
- />
250
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
251
+ <box
252
+ flexDirection="row"
253
+ alignItems="center"
254
+ paddingLeft={1}
255
+ paddingRight={1}
256
+ backgroundColor={sidebarBg}
257
+ >
258
+ <box flexGrow={1}>
259
+ <text fg={selectedFile ? t.palette.ink : t.muted}>
260
+ {selectedFile ? selectedFile.path : 'Diff'}
261
+ </text>
262
+ </box>
263
+ <box
264
+ paddingLeft={1}
265
+ paddingRight={1}
266
+ backgroundColor={actionBg}
267
+ onMouseDown={(event) => {
268
+ event.preventDefault()
269
+ event.stopPropagation()
270
+ dispatchGlobal({ type: 'exit-git-mode' })
271
+ }}
272
+ >
273
+ <text fg={t.palette.ink}>
274
+ <strong>Exit diff</strong>
275
+ </text>
276
+ </box>
277
+ </box>
278
+ <DiffStage
279
+ diff={diff}
280
+ diffKey={selectedDiffKey}
281
+ diffRef={diffRef}
282
+ loading={loading}
283
+ themeId={themeId}
284
+ view={gitMode.diffView}
285
+ />
286
+ </box>
254
287
  </box>
255
288
  {footerNode ? (
256
289
  <box paddingLeft={1} paddingRight={1} backgroundColor={sidebarBg} flexDirection="column">
@@ -1,5 +1,5 @@
1
- import type { GitPaneMode, GitPanePosition, GitPaneState } from '../../state/types'
2
- import type { ContextMenuItem } from '../context-menu/controller'
1
+ import type { GitPaneMode, GitPanePosition, GitPaneState } from '../../../../state/types'
2
+ import type { ContextMenuItem } from '../../../context-menu/controller'
3
3
 
4
4
  const GIT_PANE_MENU_OPTIONS: Array<{
5
5
  label: string
@@ -1,10 +1,10 @@
1
1
  import { memo, useRef } from 'react'
2
2
 
3
- import type { GitPanelState } from '../../state/types'
3
+ import type { GitPanelState } from '../../../../state/types'
4
4
 
5
- import { useGitPanelPolling } from '../../git/git-poller'
6
- import { useAppStore } from '../../state/app-store'
7
- import { GitPanel } from './git-panel'
5
+ import { useGitPanelPolling } from '../../../../git/git-poller'
6
+ import { useAppStore } from '../../../../state/app-store'
7
+ import { GitPanel } from '../git-panel'
8
8
 
9
9
  interface GitPaneWidgetProps {
10
10
  pollingEnabled: boolean
@@ -2,18 +2,22 @@ import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
3
  import { useMemo, useRef, useState } from 'react'
4
4
 
5
- import type { SessionRecord, SessionStatus } from '../../state/types'
5
+ import type { SessionRecord, SessionStatus } from '../../../state/types'
6
6
 
7
- import { useAppStore } from '../../state/app-store'
8
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
7
+ import { useAppStore } from '../../../state/app-store'
8
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
9
9
  // eslint-disable-next-line no-duplicate-imports
10
- import { IDLE_SESSION_STATUS } from '../../state/types'
11
- import { useBusySpinner } from '../hooks/use-busy-spinner'
12
- import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
13
- import { useBg, useTokens } from '../theme'
14
- import { ContextMenuBox } from './context-menu-box'
10
+ import { IDLE_SESSION_STATUS } from '../../../state/types'
11
+ import { useBusySpinner } from '../../hooks/use-busy-spinner'
12
+ import { moveIdToIdPosition, orderSessionsForDisplay } from '../../session-ordering'
13
+ import { useBg, useTokens } from '../../theme'
14
+ import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
15
15
 
16
- export function SessionBar() {
16
+ interface SessionBarProps {
17
+ forceVisible?: boolean
18
+ }
19
+
20
+ export function SessionBar({ forceVisible = false }: SessionBarProps) {
17
21
  const t = useTokens()
18
22
  const headerBg = useBg('elevated')
19
23
  const sessions = useAppStore((s) => s.sessions)
@@ -27,7 +31,7 @@ export function SessionBar() {
27
31
  const chipRefs = useRef(new Map<string, BoxRenderable>())
28
32
 
29
33
  const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
30
- if (!bar.visible || ordered.length === 0) return null
34
+ if ((!bar.visible && !forceVisible) || ordered.length === 0) return null
31
35
 
32
36
  const visibleSessions =
33
37
  dragOrder !== null
@@ -103,6 +107,7 @@ export function SessionBar() {
103
107
  <box
104
108
  width="100%"
105
109
  flexDirection="row"
110
+ flexShrink={0}
106
111
  paddingLeft={1}
107
112
  paddingRight={1}
108
113
  backgroundColor={headerBg}
@@ -205,6 +210,7 @@ function SessionChip({
205
210
  const idleColor = t.palette.success
206
211
  const workingColor = t.palette.primary
207
212
  const waitingColor = t.palette.warning
213
+ const [hovered, setHovered] = useState(false)
208
214
 
209
215
  return (
210
216
  <ContextMenuBox
@@ -214,6 +220,8 @@ function SessionChip({
214
220
  paddingRight={1}
215
221
  backgroundColor={bgColor}
216
222
  rightClickMenu={rightClickMenu}
223
+ onMouseOver={() => setHovered(true)}
224
+ onMouseOut={() => setHovered(false)}
217
225
  onMouseDown={(e) => {
218
226
  e.preventDefault()
219
227
  onMouseDown(e)
@@ -247,9 +255,24 @@ function SessionChip({
247
255
  <text fg={labelColor} selectable={false}>
248
256
  [{index}] {session.name}
249
257
  </text>
250
- <text fg={t.hover} selectable={false}>
251
- {' '}
252
- </text>
258
+ {hovered ? (
259
+ <box
260
+ paddingLeft={1}
261
+ onMouseDown={(event) => {
262
+ event.preventDefault()
263
+ event.stopPropagation()
264
+ runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' })
265
+ }}
266
+ >
267
+ <text fg={t.muted} selectable={false}>
268
+ ×
269
+ </text>
270
+ </box>
271
+ ) : (
272
+ <text fg={t.hover} selectable={false}>
273
+ {' '}
274
+ </text>
275
+ )}
253
276
  </ContextMenuBox>
254
277
  )
255
278
  }
@@ -1,6 +1,6 @@
1
- import type { TabSession } from '../../state/types'
1
+ import type { TabSession } from '../../../../state/types'
2
2
 
3
- import { allLeafIds, type LayoutNode } from '../../state/layout-tree'
3
+ import { allLeafIds, type LayoutNode } from '../../../../state/layout-tree'
4
4
 
5
5
  export interface TabGroupInfo {
6
6
  inLayout: boolean
@@ -5,12 +5,12 @@ import {
5
5
  } from '@opentui/core'
6
6
  import { memo, useMemo, useRef } from 'react'
7
7
 
8
- import { useAppStore } from '../../state/app-store'
9
- import { dispatchGlobal } from '../../state/dispatch-ref'
10
- import { getCurrentTokens, type ThemeTokens, useBg, useTokens } from '../theme'
11
- import { ContextMenuBox } from './context-menu-box'
12
- import { buildGitPaneContextMenu } from './git-pane-context-menu'
13
- import { GitPaneWidget } from './git-pane-widget'
8
+ import { useAppStore } from '../../../../state/app-store'
9
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
10
+ import { getCurrentTokens, type ThemeTokens, useBg, useTokens } from '../../../theme'
11
+ import { buildGitPaneContextMenu } from '../../git/pane/git-pane-context-menu'
12
+ import { GitPaneWidget } from '../../git/pane/git-pane-widget'
13
+ import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
14
14
  import { buildTabGroupInfo } from './sidebar-group-metadata'
15
15
  import { TabItem } from './tab-item'
16
16
  import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
@@ -1,11 +1,11 @@
1
1
  import { useState } from 'react'
2
2
 
3
- import type { TabSession } from '../../state/types'
3
+ import type { TabSession } from '../../../../state/types'
4
4
 
5
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
6
- import { useBusySpinner } from '../hooks/use-busy-spinner'
7
- import { getCurrentTokens, useTokens } from '../theme'
8
- import { ContextMenuBox } from './context-menu-box'
5
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
6
+ import { useBusySpinner } from '../../../hooks/use-busy-spinner'
7
+ import { getCurrentTokens, useTokens } from '../../../theme'
8
+ import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
9
9
 
10
10
  interface TabItemProps {
11
11
  id?: string