@brimveyn/aimux 1.7.3 → 1.7.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.4.5",
63
+ "@brimveyn/aimux-config": "0.4.6",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@xterm/headless": "^6.0.0",
@@ -477,14 +477,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
477
477
  case 'persist-git-diff-mode-ratio': {
478
478
  const config = loadConfig()
479
479
  const persistedGitPane = config.gitPane
480
+ const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
481
+ const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
480
482
  saveConfig({
481
483
  ...config,
482
484
  gitPane: {
483
485
  diffModeRatio: effect.ratio,
486
+ embeddedRatio,
484
487
  fileListMode: persistedGitPane?.fileListMode,
485
488
  mode: persistedGitPane?.mode ?? 'embedded',
489
+ paneRatio,
486
490
  position: persistedGitPane?.position ?? 'bottom',
487
- ratio: persistedGitPane?.ratio ?? 0.5,
488
491
  treeCompaction: persistedGitPane?.treeCompaction,
489
492
  visible: persistedGitPane?.visible ?? true,
490
493
  },
@@ -494,14 +497,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
494
497
  case 'persist-git-file-list-mode': {
495
498
  const config = loadConfig()
496
499
  const persistedGitPane = config.gitPane
500
+ const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
501
+ const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
497
502
  saveConfig({
498
503
  ...config,
499
504
  gitPane: {
500
505
  diffModeRatio: persistedGitPane?.diffModeRatio,
506
+ embeddedRatio,
501
507
  fileListMode: effect.mode,
502
508
  mode: persistedGitPane?.mode ?? 'embedded',
509
+ paneRatio,
503
510
  position: persistedGitPane?.position ?? 'bottom',
504
- ratio: persistedGitPane?.ratio ?? 0.5,
505
511
  treeCompaction: persistedGitPane?.treeCompaction,
506
512
  visible: persistedGitPane?.visible ?? true,
507
513
  },
@@ -511,14 +517,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
511
517
  case 'persist-git-tree-compaction': {
512
518
  const config = loadConfig()
513
519
  const persistedGitPane = config.gitPane
520
+ const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
521
+ const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
514
522
  saveConfig({
515
523
  ...config,
516
524
  gitPane: {
517
525
  diffModeRatio: persistedGitPane?.diffModeRatio,
526
+ embeddedRatio,
518
527
  fileListMode: persistedGitPane?.fileListMode,
519
528
  mode: persistedGitPane?.mode ?? 'embedded',
529
+ paneRatio,
520
530
  position: persistedGitPane?.position ?? 'bottom',
521
- ratio: persistedGitPane?.ratio ?? 0.5,
522
531
  treeCompaction: effect.enabled,
523
532
  visible: persistedGitPane?.visible ?? true,
524
533
  },
@@ -2,6 +2,8 @@ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
3
  import type { SplitDirection } from '../state/layout-tree'
4
4
 
5
+ type ScreenAxis = 'x' | 'y'
6
+
5
7
  export interface SplitDragState {
6
8
  tabId: string
7
9
  direction: SplitDirection
@@ -9,7 +11,35 @@ export interface SplitDragState {
9
11
  totalSize: number
10
12
  }
11
13
 
14
+ export interface AxisDragState {
15
+ axis: ScreenAxis
16
+ screenStart: number
17
+ }
18
+
19
+ export interface AnchoredRatioDragState {
20
+ anchor: 'start' | 'end'
21
+ axis: ScreenAxis
22
+ screenStart: number
23
+ totalSize: number
24
+ }
25
+
26
+ function getScreenPosition(event: OtuiMouseEvent, axis: ScreenAxis): number {
27
+ return axis === 'x' ? event.x : event.y
28
+ }
29
+
12
30
  export function getSplitRatioFromDrag(event: OtuiMouseEvent, drag: SplitDragState): number {
13
- const position = drag.direction === 'vertical' ? event.x : event.y
31
+ const position = getScreenPosition(event, drag.direction === 'vertical' ? 'x' : 'y')
14
32
  return (position - drag.screenStart) / drag.totalSize
15
33
  }
34
+
35
+ export function getAxisDeltaFromDrag(event: OtuiMouseEvent, drag: AxisDragState): number {
36
+ return getScreenPosition(event, drag.axis) - drag.screenStart
37
+ }
38
+
39
+ export function getAnchoredRatioFromDrag(
40
+ event: OtuiMouseEvent,
41
+ drag: AnchoredRatioDragState
42
+ ): number {
43
+ const offset = getScreenPosition(event, drag.axis) - drag.screenStart
44
+ return drag.anchor === 'start' ? offset / drag.totalSize : 1 - offset / drag.totalSize
45
+ }
@@ -16,9 +16,22 @@ import {
16
16
  resolveClickSelection,
17
17
  } from './click-selection-resolver'
18
18
  import { requestRenderUpTree } from './render-invalidation'
19
- import { getSplitRatioFromDrag, type SplitDragState } from './split-drag-controller'
19
+ import {
20
+ type AnchoredRatioDragState,
21
+ type AxisDragState,
22
+ getAnchoredRatioFromDrag,
23
+ getAxisDeltaFromDrag,
24
+ getSplitRatioFromDrag,
25
+ type SplitDragState,
26
+ } from './split-drag-controller'
20
27
  import { getForwardedMouseSequence, getScrollViewportDelta } from './terminal-mouse-adapter'
21
28
 
29
+ type ResizeDragState =
30
+ | ({ kind: 'split' } & SplitDragState)
31
+ | ({ initialWidth: number; kind: 'sidebar' } & AxisDragState)
32
+ | ({ initialWidth: number; kind: 'git-pane'; side: 'left' | 'right' } & AxisDragState)
33
+ | ({ kind: 'embedded-git'; position: 'top' | 'bottom' } & AnchoredRatioDragState)
34
+
22
35
  interface UseMouseHandlersOptions {
23
36
  state: AppState
24
37
  dispatch: (action: AppAction) => void
@@ -68,7 +81,7 @@ export function useMouseHandlers({
68
81
  renderer,
69
82
  state,
70
83
  }: UseMouseHandlersOptions) {
71
- const separatorDragRef = useRef<SplitDragState | null>(null)
84
+ const separatorDragRef = useRef<ResizeDragState | null>(null)
72
85
  const multiClickRef = useRef(new MultiClickDetector())
73
86
 
74
87
  const handleTerminalMouseEvent = (event: OtuiMouseEvent, origin: TerminalContentOrigin) => {
@@ -113,7 +126,45 @@ export function useMouseHandlers({
113
126
  screenStart: number
114
127
  totalSize: number
115
128
  }) => {
116
- separatorDragRef.current = info
129
+ separatorDragRef.current = { kind: 'split', ...info }
130
+ }
131
+
132
+ const handleSidebarResizeStart = (info: { initialWidth: number; screenStart: number }) => {
133
+ separatorDragRef.current = {
134
+ axis: 'x',
135
+ initialWidth: info.initialWidth,
136
+ kind: 'sidebar',
137
+ screenStart: info.screenStart,
138
+ }
139
+ }
140
+
141
+ const handleGitPaneResizeStart = (info: {
142
+ initialWidth: number
143
+ screenStart: number
144
+ side: 'left' | 'right'
145
+ }) => {
146
+ separatorDragRef.current = {
147
+ axis: 'x',
148
+ initialWidth: info.initialWidth,
149
+ kind: 'git-pane',
150
+ screenStart: info.screenStart,
151
+ side: info.side,
152
+ }
153
+ }
154
+
155
+ const handleEmbeddedGitResizeStart = (info: {
156
+ containerStart: number
157
+ position: 'top' | 'bottom'
158
+ totalSize: number
159
+ }) => {
160
+ separatorDragRef.current = {
161
+ anchor: info.position === 'top' ? 'start' : 'end',
162
+ axis: 'y',
163
+ kind: 'embedded-git',
164
+ position: info.position,
165
+ screenStart: info.containerStart,
166
+ totalSize: info.totalSize,
167
+ }
117
168
  }
118
169
 
119
170
  const handleSeparatorDrag = (event: OtuiMouseEvent): boolean => {
@@ -122,8 +173,42 @@ export function useMouseHandlers({
122
173
  return false
123
174
  }
124
175
 
125
- const newRatio = getSplitRatioFromDrag(event, drag)
126
- dispatch({ axis: drag.direction, ratio: newRatio, tabId: drag.tabId, type: 'set-split-ratio' })
176
+ switch (drag.kind) {
177
+ case 'split': {
178
+ const newRatio = getSplitRatioFromDrag(event, drag)
179
+ dispatch({
180
+ axis: drag.direction,
181
+ ratio: newRatio,
182
+ tabId: drag.tabId,
183
+ type: 'set-split-ratio',
184
+ })
185
+ break
186
+ }
187
+ case 'sidebar': {
188
+ const nextWidth = Math.round(drag.initialWidth + getAxisDeltaFromDrag(event, drag))
189
+ dispatch({ type: 'set-sidebar-width', width: nextWidth })
190
+ break
191
+ }
192
+ case 'git-pane': {
193
+ const delta = getAxisDeltaFromDrag(event, drag)
194
+ const direction = drag.side === 'left' ? 1 : -1
195
+ const nextWidth = drag.initialWidth + delta * direction
196
+ dispatch({
197
+ ratio: nextWidth / 80,
198
+ target: 'pane',
199
+ type: 'set-git-pane-ratio',
200
+ })
201
+ break
202
+ }
203
+ case 'embedded-git': {
204
+ dispatch({
205
+ ratio: getAnchoredRatioFromDrag(event, drag),
206
+ target: 'embedded',
207
+ type: 'set-git-pane-ratio',
208
+ })
209
+ break
210
+ }
211
+ }
127
212
  return true
128
213
  }
129
214
 
@@ -171,10 +256,13 @@ export function useMouseHandlers({
171
256
  }
172
257
 
173
258
  return {
259
+ handleEmbeddedGitResizeStart,
260
+ handleGitPaneResizeStart,
174
261
  handlePaneActivate,
175
262
  handleSeparatorDrag,
176
263
  handleSeparatorDragEnd,
177
264
  handleSeparatorDragStart,
265
+ handleSidebarResizeStart,
178
266
  handleSplitResize,
179
267
  handleTerminalClick,
180
268
  handleTerminalMouseEvent,
@@ -4,6 +4,7 @@ import type { TerminalContentOrigin } from '../input/raw-input-handler'
4
4
  import type { SessionBackend } from '../session-backend/types'
5
5
  import type { AppAction, AppState, ScrollIntent } from '../state/types'
6
6
 
7
+ import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
7
8
  import {
8
9
  createTerminalBounds,
9
10
  forEachSplitPaneRect,
@@ -140,8 +141,7 @@ export function useTerminalResize({
140
141
  STATUS_BAR_HEIGHT +
141
142
  TERMINAL_PANE_VERTICAL_CHROME +
142
143
  sessionBarRows
143
- const gitPaneRaw = gitPaneInPaneMode ? Math.round(state.gitPane.ratio * 80) : 0
144
- const gitPaneWidth = gitPaneInPaneMode ? Math.max(20, Math.min(80, gitPaneRaw)) : 0
144
+ const gitPaneWidth = gitPaneInPaneMode ? getGitPaneWidthFromRatio(state.gitPane.paneRatio) : 0
145
145
  const gitOnLeft = gitPaneInPaneMode && state.gitPane.position === 'left'
146
146
  const cols = Math.max(
147
147
  MIN_TERMINAL_COLS,
@@ -167,7 +167,7 @@ export function useTerminalResize({
167
167
  state.sessionBar.position,
168
168
  gitPaneInPaneMode,
169
169
  state.gitPane.position,
170
- state.gitPane.ratio,
170
+ state.gitPane.paneRatio,
171
171
  ])
172
172
 
173
173
  useLayoutEffect(() => {
@@ -196,7 +196,7 @@ export function useTerminalResize({
196
196
  state.sessionBar.position,
197
197
  gitPaneInPaneMode,
198
198
  state.gitPane.position,
199
- state.gitPane.ratio,
199
+ state.gitPane.paneRatio,
200
200
  ])
201
201
 
202
202
  useEffect(() => {
package/src/app.tsx CHANGED
@@ -88,16 +88,25 @@ export function App({
88
88
  const diffModeRatio = userGitPane?.diffModeRatio ?? json.gitPane?.diffModeRatio ?? 0.35
89
89
  const treeCompaction = userGitPane?.treeCompaction ?? json.gitPane?.treeCompaction ?? true
90
90
  const prefetchRadius = userGitPane?.prefetchRadius ?? json.gitPane?.prefetchRadius ?? 5
91
+ const persistedPaneRatio = json.gitPane?.paneRatio ?? json.gitPane?.ratio ?? 0.5
92
+ const persistedEmbeddedRatio = json.gitPane?.embeddedRatio ?? json.gitPane?.ratio ?? 0.5
91
93
  const gitPaneOverrides = {
92
94
  ...json.gitPane,
93
95
  diffModeRatio,
96
+ embeddedRatio:
97
+ userGitPane?.mode === 'embedded' && userGitPane?.ratio !== undefined
98
+ ? userGitPane.ratio
99
+ : persistedEmbeddedRatio,
94
100
  fileListMode,
101
+ paneRatio:
102
+ userGitPane?.mode === 'pane' && userGitPane?.ratio !== undefined
103
+ ? userGitPane.ratio
104
+ : persistedPaneRatio,
95
105
  prefetchRadius,
96
106
  treeCompaction,
97
107
  ...(userGitPane?.visible !== undefined ? { visible: userGitPane.visible } : {}),
98
108
  ...(userGitPane?.mode !== undefined ? { mode: userGitPane.mode } : {}),
99
109
  ...(userGitPane?.position !== undefined ? { position: userGitPane.position } : {}),
100
- ...(userGitPane?.ratio !== undefined ? { ratio: userGitPane.ratio } : {}),
101
110
  ...(userGitPane?.diffModeRatio !== undefined
102
111
  ? { diffModeRatio: userGitPane.diffModeRatio }
103
112
  : {}),
@@ -210,10 +219,13 @@ export function App({
210
219
  })
211
220
 
212
221
  const {
222
+ handleEmbeddedGitResizeStart,
223
+ handleGitPaneResizeStart,
213
224
  handlePaneActivate,
214
225
  handleSeparatorDrag,
215
226
  handleSeparatorDragEnd,
216
227
  handleSeparatorDragStart,
228
+ handleSidebarResizeStart,
217
229
  handleSplitResize,
218
230
  handleTerminalClick,
219
231
  handleTerminalMouseEvent,
@@ -349,9 +361,12 @@ export function App({
349
361
  onTerminalClick={handleTerminalClick}
350
362
  onPaneActivate={handlePaneActivate}
351
363
  onSplitResize={handleSplitResize}
364
+ onEmbeddedGitResizeStart={handleEmbeddedGitResizeStart}
365
+ onGitPaneResizeStart={handleGitPaneResizeStart}
352
366
  onSeparatorDragStart={handleSeparatorDragStart}
353
367
  onSeparatorDrag={handleSeparatorDrag}
354
368
  onSeparatorDragEnd={handleSeparatorDragEnd}
369
+ onSidebarResizeStart={handleSidebarResizeStart}
355
370
  terminalCols={terminalSize.cols}
356
371
  terminalRows={terminalSize.rows}
357
372
  />
package/src/config.ts CHANGED
@@ -22,7 +22,9 @@ export interface PersistedGitPane {
22
22
  visible: boolean
23
23
  mode: 'embedded' | 'pane'
24
24
  position: 'top' | 'bottom' | 'left' | 'right'
25
- ratio: number
25
+ paneRatio?: number
26
+ embeddedRatio?: number
27
+ ratio?: number
26
28
  }
27
29
 
28
30
  export interface AimuxConfig {
@@ -47,7 +49,20 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
47
49
  v.position === 'left' ||
48
50
  v.position === 'right'
49
51
  const ratioOk =
50
- typeof v.ratio === 'number' && Number.isFinite(v.ratio) && v.ratio > 0 && v.ratio < 1
52
+ v.ratio === undefined ||
53
+ (typeof v.ratio === 'number' && Number.isFinite(v.ratio) && v.ratio > 0 && v.ratio < 1)
54
+ const paneRatioOk =
55
+ v.paneRatio === undefined ||
56
+ (typeof v.paneRatio === 'number' &&
57
+ Number.isFinite(v.paneRatio) &&
58
+ v.paneRatio > 0 &&
59
+ v.paneRatio < 1)
60
+ const embeddedRatioOk =
61
+ v.embeddedRatio === undefined ||
62
+ (typeof v.embeddedRatio === 'number' &&
63
+ Number.isFinite(v.embeddedRatio) &&
64
+ v.embeddedRatio > 0 &&
65
+ v.embeddedRatio < 1)
51
66
  const diffModeRatioOk =
52
67
  v.diffModeRatio === undefined ||
53
68
  (typeof v.diffModeRatio === 'number' &&
@@ -68,6 +83,8 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
68
83
  !modeOk ||
69
84
  !positionOk ||
70
85
  !ratioOk ||
86
+ !paneRatioOk ||
87
+ !embeddedRatioOk ||
71
88
  !diffModeRatioOk ||
72
89
  !visibleOk ||
73
90
  !fileListModeOk ||
@@ -169,7 +186,9 @@ export function loadConfigResult(): ConfigLoadResult {
169
186
  : undefined
170
187
  if (legacyVisible !== undefined || legacyRatio !== undefined) {
171
188
  validGitPane = {
189
+ embeddedRatio: legacyRatio ?? 0.5,
172
190
  mode: 'embedded',
191
+ paneRatio: legacyRatio ?? 0.5,
173
192
  position: 'bottom',
174
193
  ratio: legacyRatio ?? 0.5,
175
194
  visible: legacyVisible ?? true,
@@ -0,0 +1,15 @@
1
+ export const GIT_PANE_MIN_RATIO = 0.2
2
+ export const GIT_PANE_MAX_RATIO = 0.8
3
+ export const GIT_PANE_MIN_WIDTH = 20
4
+ export const GIT_PANE_MAX_WIDTH = 80
5
+
6
+ export function clampGitPaneRatio(value: number): number {
7
+ return Math.max(GIT_PANE_MIN_RATIO, Math.min(GIT_PANE_MAX_RATIO, value))
8
+ }
9
+
10
+ export function getGitPaneWidthFromRatio(ratio: number): number {
11
+ return Math.max(
12
+ GIT_PANE_MIN_WIDTH,
13
+ Math.min(GIT_PANE_MAX_WIDTH, Math.round(clampGitPaneRatio(ratio) * GIT_PANE_MAX_WIDTH))
14
+ )
15
+ }
@@ -1,11 +1,9 @@
1
1
  import type { AppAction, AppState, GitFileEntry, GitFileSection, GitPanelState } from '../types'
2
2
 
3
+ import { clampGitPaneRatio } from '../git-pane-sizing'
3
4
  import { reconcileSelectedGitEntryKey } from '../git-tree'
4
5
  import { clearDiffCacheForPaths } from './diff-cache'
5
6
 
6
- export const GIT_PANEL_MIN_RATIO = 0.2
7
- export const GIT_PANEL_MAX_RATIO = 0.8
8
-
9
7
  const SECTION_RANK: Record<GitFileSection, number> = {
10
8
  historical: 0,
11
9
  staged: 1,
@@ -23,7 +21,7 @@ export function sortFilesBySection(files: GitFileEntry[]): GitFileEntry[] {
23
21
  }
24
22
 
25
23
  function clampRatio(value: number): number {
26
- return Math.max(GIT_PANEL_MIN_RATIO, Math.min(GIT_PANEL_MAX_RATIO, value))
24
+ return clampGitPaneRatio(value)
27
25
  }
28
26
 
29
27
  export function emptyGitPanel(): GitPanelState {
@@ -93,9 +91,16 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
93
91
  }
94
92
  }
95
93
  case 'resize-git-pane': {
96
- const nextRatio = clampRatio(state.gitPane.ratio + action.delta)
97
- if (nextRatio === state.gitPane.ratio) return state
98
- return { ...state, gitPane: { ...state.gitPane, ratio: nextRatio } }
94
+ const target = state.gitPane.mode === 'pane' ? 'paneRatio' : 'embeddedRatio'
95
+ const nextRatio = clampRatio(state.gitPane[target] + action.delta)
96
+ if (nextRatio === state.gitPane[target]) return state
97
+ return { ...state, gitPane: { ...state.gitPane, [target]: nextRatio } }
98
+ }
99
+ case 'set-git-pane-ratio': {
100
+ const key = action.target === 'pane' ? 'paneRatio' : 'embeddedRatio'
101
+ const nextRatio = clampRatio(action.ratio)
102
+ if (nextRatio === state.gitPane[key]) return state
103
+ return { ...state, gitPane: { ...state.gitPane, [key]: nextRatio } }
99
104
  }
100
105
  case 'resize-git-diff-pane': {
101
106
  const nextRatio = clampRatio(state.gitPane.diffModeRatio + action.delta)
@@ -9,6 +9,12 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
9
9
  state.sidebar.maxWidth,
10
10
  Math.max(state.sidebar.minWidth, state.sidebar.width + action.delta)
11
11
  )
12
+ if (width === state.sidebar.width) return state
13
+ return { ...state, sidebar: { ...state.sidebar, width } }
14
+ }
15
+ case 'set-sidebar-width': {
16
+ const width = Math.min(state.sidebar.maxWidth, Math.max(state.sidebar.minWidth, action.width))
17
+ if (width === state.sidebar.width) return state
12
18
  return { ...state, sidebar: { ...state.sidebar, width } }
13
19
  }
14
20
  case 'set-focus-mode':
@@ -34,12 +34,13 @@ export interface InitialStateOverrides {
34
34
  const DEFAULT_GIT_PANE: GitPaneState = {
35
35
  diffCount: { enabled: true },
36
36
  diffModeRatio: 0.35,
37
+ embeddedRatio: 0.5,
37
38
  fileListMode: 'tree',
38
39
  mode: 'embedded',
40
+ paneRatio: 0.5,
39
41
  path: { enabled: true },
40
42
  position: 'bottom',
41
43
  prefetchRadius: 5,
42
- ratio: 0.5,
43
44
  treeCompaction: true,
44
45
  visible: true,
45
46
  }
@@ -166,7 +166,8 @@ export interface GitPaneState {
166
166
  visible: boolean
167
167
  mode: GitPaneMode
168
168
  position: GitPanePosition
169
- ratio: number
169
+ paneRatio: number
170
+ embeddedRatio: number
170
171
  diffModeRatio: number
171
172
  fileListMode: GitFileListMode
172
173
  treeCompaction: boolean
@@ -500,10 +501,12 @@ export type LayoutAction =
500
501
  export type UIAction =
501
502
  | { type: 'toggle-sidebar' }
502
503
  | { type: 'resize-sidebar'; delta: number }
504
+ | { type: 'set-sidebar-width'; width: number }
503
505
  | { type: 'set-focus-mode'; focusMode: FocusMode }
504
506
  | { type: 'set-terminal-size'; cols: number; rows: number }
505
507
  | { type: 'toggle-git-pane' }
506
508
  | { type: 'resize-git-pane'; delta: number }
509
+ | { type: 'set-git-pane-ratio'; target: 'pane' | 'embedded'; ratio: number }
507
510
  | { type: 'resize-git-diff-pane'; delta: number }
508
511
  | { type: 'set-git-pane-mode'; mode: GitPaneMode }
509
512
  | { type: 'set-git-pane-position'; position: GitPanePosition }
@@ -26,10 +26,11 @@ export function saveCurrentWorkspace(state: AppState): void {
26
26
  customCommands: state.customCommands,
27
27
  gitPane: {
28
28
  diffModeRatio: state.gitPane.diffModeRatio,
29
+ embeddedRatio: state.gitPane.embeddedRatio,
29
30
  fileListMode: state.gitPane.fileListMode,
30
31
  mode: state.gitPane.mode,
32
+ paneRatio: state.gitPane.paneRatio,
31
33
  position: state.gitPane.position,
32
- ratio: state.gitPane.ratio,
33
34
  visible: state.gitPane.visible,
34
35
  },
35
36
  sessionBarPosition: state.sessionBar.position,
@@ -53,8 +53,10 @@ export function ContextMenuOverlay() {
53
53
  const maxLabel = Math.max(...menu.items.map(([label]) => label.length))
54
54
  const width = maxLabel + 4
55
55
  const height = menu.items.length + 2
56
- const left = Math.max(0, Math.min(menu.anchorX, terminalCols - width))
57
- const top = Math.max(0, Math.min(menu.anchorY, terminalRows - height))
56
+ const left =
57
+ menu.anchorX + width <= terminalCols ? menu.anchorX : Math.max(0, menu.anchorX - width)
58
+ const top =
59
+ menu.anchorY + height <= terminalRows ? menu.anchorY : Math.max(0, menu.anchorY - height)
58
60
 
59
61
  return (
60
62
  <box position="absolute" top={0} left={0} width="100%" height="100%">
@@ -0,0 +1,26 @@
1
+ import type { GitPaneMode, GitPanePosition, GitPaneState } from '../../state/types'
2
+ import type { ContextMenuItem } from '../context-menu/controller'
3
+
4
+ const GIT_PANE_MENU_OPTIONS: Array<{
5
+ label: string
6
+ mode: GitPaneMode
7
+ position: GitPanePosition
8
+ }> = [
9
+ { label: 'Move to top', mode: 'embedded', position: 'top' },
10
+ { label: 'Move to bottom', mode: 'embedded', position: 'bottom' },
11
+ { label: 'Move to left', mode: 'pane', position: 'left' },
12
+ { label: 'Move to right', mode: 'pane', position: 'right' },
13
+ ]
14
+
15
+ export function buildGitPaneContextMenu(
16
+ gitPane: Pick<GitPaneState, 'mode' | 'position'>,
17
+ onToggle: () => void,
18
+ onMove: (mode: GitPaneMode, position: GitPanePosition) => void
19
+ ): ContextMenuItem[] {
20
+ return [
21
+ ['Toggle', onToggle],
22
+ ...GIT_PANE_MENU_OPTIONS.filter(
23
+ ({ mode, position }) => !(gitPane.mode === mode && gitPane.position === position)
24
+ ).map(({ label, mode, position }) => [label, () => onMove(mode, position)] as ContextMenuItem),
25
+ ]
26
+ }
@@ -1,10 +1,15 @@
1
- import { type ScrollBoxRenderable } from '@opentui/core'
1
+ import {
2
+ type BoxRenderable,
3
+ type MouseEvent as OtuiMouseEvent,
4
+ type ScrollBoxRenderable,
5
+ } from '@opentui/core'
2
6
  import { memo, useMemo, useRef } from 'react'
3
7
 
4
8
  import { useAppStore } from '../../state/app-store'
5
9
  import { dispatchGlobal } from '../../state/dispatch-ref'
6
10
  import { getCurrentTokens, type ThemeTokens, useBg, useTokens } from '../theme'
7
11
  import { ContextMenuBox } from './context-menu-box'
12
+ import { buildGitPaneContextMenu } from './git-pane-context-menu'
8
13
  import { GitPaneWidget } from './git-pane-widget'
9
14
  import { buildTabGroupInfo } from './sidebar-group-metadata'
10
15
  import { TabItem } from './tab-item'
@@ -13,12 +18,21 @@ import { useSidebarBranch } from './use-sidebar-branch'
13
18
 
14
19
  interface SidebarProps {
15
20
  onTabActivate?: (tabId: string) => void
21
+ onResizeDrag?: (event: OtuiMouseEvent) => boolean
22
+ onResizeDragEnd?: () => void
23
+ onSidebarResizeStart?: (info: { initialWidth: number; screenStart: number }) => void
24
+ onEmbeddedGitResizeStart?: (info: {
25
+ containerStart: number
26
+ position: 'top' | 'bottom'
27
+ totalSize: number
28
+ }) => void
16
29
  }
17
30
 
18
31
  const GUTTER_START = '╭'
19
32
  const GUTTER_MIDDLE = '├'
20
33
  const GUTTER_END = '╰'
21
34
  const GUTTER_PAD = '│'
35
+ const RESIZE_HANDLE = '│'
22
36
 
23
37
  function getRowBackground({
24
38
  alternate,
@@ -34,9 +48,8 @@ function getRowBackground({
34
48
  return undefined
35
49
  }
36
50
 
37
- const SidebarTop = memo(function SidebarTop() {
51
+ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: number }) {
38
52
  const tokens = useTokens()
39
- const sidebarWidth = useAppStore((s) => s.sidebar.width)
40
53
  const currentSessionId = useAppStore((s) => s.currentSessionId)
41
54
  const sessions = useAppStore((s) => s.sessions)
42
55
  const currentSession = currentSessionId
@@ -70,7 +83,7 @@ const SidebarTop = memo(function SidebarTop() {
70
83
  >
71
84
  <text fg={tokens.palette.ink}>+ New assistant</text>
72
85
  </box>
73
- <text fg={tokens.hover}>{'·'.repeat(Math.max(0, sidebarWidth - 2))}</text>
86
+ <text fg={tokens.hover}>{'·'.repeat(Math.max(0, contentWidth - 2))}</text>
74
87
  </box>
75
88
  )
76
89
  })
@@ -164,13 +177,20 @@ const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
164
177
  )
165
178
  })
166
179
 
167
- export function Sidebar({ onTabActivate }: SidebarProps) {
180
+ export function Sidebar({
181
+ onEmbeddedGitResizeStart,
182
+ onResizeDrag,
183
+ onResizeDragEnd,
184
+ onSidebarResizeStart,
185
+ onTabActivate,
186
+ }: SidebarProps) {
168
187
  const tokens = useTokens()
169
188
  const sidebarBg = useBg('elevated')
170
189
  const sidebarVisible = useAppStore((s) => s.sidebar.visible)
171
190
  const sidebarWidth = useAppStore((s) => s.sidebar.width)
172
191
  const gitPane = useAppStore((s) => s.gitPane)
173
192
  const focusMode = useAppStore((s) => s.focusMode)
193
+ const bodyRef = useRef<BoxRenderable | null>(null)
174
194
 
175
195
  if (!sidebarVisible) {
176
196
  return null
@@ -179,15 +199,51 @@ export function Sidebar({ onTabActivate }: SidebarProps) {
179
199
  const gitEmbedded = gitPane.mode === 'embedded' && gitPane.visible
180
200
  const gitOnTop = gitEmbedded && gitPane.position === 'top'
181
201
  const gitOnBottom = gitEmbedded && gitPane.position === 'bottom'
202
+ const contentWidth = Math.max(1, sidebarWidth - 1)
203
+ const gitPaneMenu = buildGitPaneContextMenu(
204
+ gitPane,
205
+ () => dispatchGlobal({ type: 'toggle-git-pane' }),
206
+ (mode, position) => {
207
+ dispatchGlobal({ mode, type: 'set-git-pane-mode' })
208
+ dispatchGlobal({ position, type: 'set-git-pane-position' })
209
+ }
210
+ )
182
211
 
183
212
  // flex-grow scaled by 100 (integer preferred); tabs gets (1-ratio), git gets ratio.
184
- const tabsGrow = gitEmbedded ? Math.max(1, Math.round((1 - gitPane.ratio) * 100)) : 1
185
- const gitGrow = gitEmbedded ? Math.max(1, Math.round(gitPane.ratio * 100)) : 0
213
+ const tabsGrow = gitEmbedded ? Math.max(1, Math.round((1 - gitPane.embeddedRatio) * 100)) : 1
214
+ const gitGrow = gitEmbedded ? Math.max(1, Math.round(gitPane.embeddedRatio * 100)) : 0
186
215
 
187
- const separator = <text fg={tokens.hover}>{'·'.repeat(Math.max(0, sidebarWidth - 2))}</text>
216
+ const separator = <text fg={tokens.hover}>{'·'.repeat(Math.max(0, contentWidth - 2))}</text>
188
217
  const gitBody = gitEmbedded ? (
189
- <box flexDirection="column" flexGrow={gitGrow} flexShrink={1} flexBasis={0} overflow="hidden">
218
+ <ContextMenuBox
219
+ flexDirection="column"
220
+ flexGrow={gitGrow}
221
+ flexShrink={1}
222
+ flexBasis={0}
223
+ overflow="hidden"
224
+ rightClickMenu={gitPaneMenu}
225
+ >
190
226
  <GitPaneWidget pollingEnabled={gitPane.visible} />
227
+ </ContextMenuBox>
228
+ ) : null
229
+ const embeddedHandle = gitEmbedded ? (
230
+ <box
231
+ minHeight={1}
232
+ flexShrink={0}
233
+ backgroundColor={tokens.border}
234
+ onMouseDown={(event) => {
235
+ const body = bodyRef.current
236
+ if (!body) return
237
+ event.preventDefault()
238
+ event.stopPropagation()
239
+ onEmbeddedGitResizeStart?.({
240
+ containerStart: body.y,
241
+ position: gitPane.position === 'top' ? 'top' : 'bottom',
242
+ totalSize: Math.max(1, body.height),
243
+ })
244
+ }}
245
+ >
246
+ <text fg={tokens.border}>{RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}</text>
191
247
  </box>
192
248
  ) : null
193
249
 
@@ -198,6 +254,7 @@ export function Sidebar({ onTabActivate }: SidebarProps) {
198
254
  flexDirection="column"
199
255
  backgroundColor={sidebarBg}
200
256
  gap={0}
257
+ overflow="hidden"
201
258
  rightClickMenu={[
202
259
  ['Hide sidebar', () => dispatchGlobal({ type: 'toggle-sidebar' })],
203
260
  ['Toggle git pane', () => dispatchGlobal({ type: 'toggle-git-pane' })],
@@ -214,29 +271,56 @@ export function Sidebar({ onTabActivate }: SidebarProps) {
214
271
  dispatchGlobal({ focusMode: 'navigation', type: 'set-focus-mode' })
215
272
  }
216
273
  }}
274
+ onMouseDrag={(event) => {
275
+ if (onResizeDrag?.(event)) {
276
+ event.preventDefault()
277
+ event.stopPropagation()
278
+ }
279
+ }}
280
+ onMouseUp={() => {
281
+ onResizeDragEnd?.()
282
+ }}
217
283
  >
218
- <SidebarTop />
219
- {gitOnTop ? (
220
- <>
221
- {gitBody}
222
- {separator}
223
- </>
224
- ) : null}
225
- <box
226
- flexDirection="column"
227
- flexGrow={tabsGrow}
228
- flexShrink={1}
229
- flexBasis={0}
230
- overflow="hidden"
231
- >
232
- <TabsBody onTabActivate={onTabActivate} />
284
+ <box flexDirection="row" width={sidebarWidth} flexGrow={1} overflow="hidden">
285
+ <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
286
+ <SidebarTop contentWidth={contentWidth} />
287
+ <box ref={bodyRef} flexDirection="column" flexGrow={1} overflow="hidden">
288
+ {gitOnTop ? (
289
+ <>
290
+ {gitBody}
291
+ {embeddedHandle}
292
+ </>
293
+ ) : null}
294
+ <box
295
+ flexDirection="column"
296
+ flexGrow={tabsGrow}
297
+ flexShrink={1}
298
+ flexBasis={0}
299
+ overflow="hidden"
300
+ >
301
+ <TabsBody onTabActivate={onTabActivate} />
302
+ </box>
303
+ {gitOnBottom ? (
304
+ <>
305
+ {embeddedHandle}
306
+ {gitBody}
307
+ </>
308
+ ) : null}
309
+ </box>
310
+ {!gitEmbedded ? separator : null}
311
+ </box>
312
+ <box
313
+ height="100%"
314
+ width={1}
315
+ flexShrink={0}
316
+ backgroundColor={tokens.border}
317
+ onMouseDown={(event) => {
318
+ event.preventDefault()
319
+ event.stopPropagation()
320
+ onSidebarResizeStart?.({ initialWidth: sidebarWidth, screenStart: event.x })
321
+ }}
322
+ />
233
323
  </box>
234
- {gitOnBottom ? (
235
- <>
236
- {separator}
237
- {gitBody}
238
- </>
239
- ) : null}
240
324
  </ContextMenuBox>
241
325
  )
242
326
  }
package/src/ui/root.tsx CHANGED
@@ -5,10 +5,14 @@ import type { ModalState, SessionRecord, SnippetRecord } from '../state/types'
5
5
  import type { ThemeId } from './themes'
6
6
 
7
7
  import { useAppStore } from '../state/app-store'
8
+ import { dispatchGlobal } from '../state/dispatch-ref'
9
+ import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
8
10
  import { getTreeForTab, PANE_BORDER, type SplitDirection } from '../state/layout-tree'
11
+ import { ContextMenuBox } from './components/context-menu-box'
9
12
  import { ContextMenuOverlay } from './components/context-menu-overlay'
10
13
  import { CreateSessionModal } from './components/create-session-modal'
11
14
  import { GitCommitModal } from './components/git-commit-modal'
15
+ import { buildGitPaneContextMenu } from './components/git-pane-context-menu'
12
16
  import { GitPaneWidget } from './components/git-pane-widget'
13
17
  import { GitView } from './components/git-view'
14
18
  import { HelpModal } from './components/help-modal'
@@ -25,7 +29,7 @@ import { StatusBar } from './components/status-bar'
25
29
  import { TerminalPane } from './components/terminal-pane'
26
30
  import { ThemePickerModal } from './components/theme-picker-modal'
27
31
  import { UpdateAvailableModal } from './components/update-available-modal'
28
- import { useBg } from './theme'
32
+ import { useBg, useTokens } from './theme'
29
33
 
30
34
  function getCreateSessionFields(modal: ModalState) {
31
35
  if (modal.type !== 'create-session') {
@@ -195,6 +199,17 @@ interface RootViewProps {
195
199
  onTerminalClick?: (event: MouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
196
200
  onPaneActivate?: (tabId: string) => void
197
201
  onSplitResize?: (tabId: string, ratio: number, axis: SplitDirection) => void
202
+ onSidebarResizeStart?: (info: { initialWidth: number; screenStart: number }) => void
203
+ onGitPaneResizeStart?: (info: {
204
+ initialWidth: number
205
+ screenStart: number
206
+ side: 'left' | 'right'
207
+ }) => void
208
+ onEmbeddedGitResizeStart?: (info: {
209
+ containerStart: number
210
+ position: 'top' | 'bottom'
211
+ totalSize: number
212
+ }) => void
198
213
  onSeparatorDragStart?: (info: {
199
214
  tabId: string
200
215
  direction: SplitDirection
@@ -211,10 +226,13 @@ export function RootView({
211
226
  contentOrigin,
212
227
  localScrollbackEnabled,
213
228
  mouseForwardingEnabled,
229
+ onEmbeddedGitResizeStart,
230
+ onGitPaneResizeStart,
214
231
  onPaneActivate,
215
232
  onSeparatorDrag,
216
233
  onSeparatorDragEnd,
217
234
  onSeparatorDragStart,
235
+ onSidebarResizeStart,
218
236
  onSplitResize,
219
237
  onTerminalClick,
220
238
  onTerminalMouseEvent,
@@ -238,7 +256,7 @@ export function RootView({
238
256
  const gitPaneMode = useAppStore((s) => s.gitPane.mode)
239
257
  const gitPaneVisible = useAppStore((s) => s.gitPane.visible)
240
258
  const gitPanePosition = useAppStore((s) => s.gitPane.position)
241
- const gitPaneRatio = useAppStore((s) => s.gitPane.ratio)
259
+ const gitPaneRatio = useAppStore((s) => s.gitPane.paneRatio)
242
260
 
243
261
  const activeTab = tabs.find((tab) => tab.id === activeTabId)
244
262
  const activeTree = activeTabId ? getTreeForTab(layoutTrees, tabGroupMap, activeTabId) : null
@@ -269,12 +287,36 @@ export function RootView({
269
287
  }
270
288
 
271
289
  return (
272
- <box flexDirection="column" width="100%" height="100%" backgroundColor={editorBg}>
290
+ <box
291
+ flexDirection="column"
292
+ width="100%"
293
+ height="100%"
294
+ backgroundColor={editorBg}
295
+ onMouseDrag={(event) => {
296
+ if (onSeparatorDrag?.(event)) {
297
+ event.preventDefault()
298
+ event.stopPropagation()
299
+ }
300
+ }}
301
+ onMouseUp={() => {
302
+ onSeparatorDragEnd?.()
303
+ }}
304
+ >
273
305
  {sessionBarPosition === 'top' && <SessionBar />}
274
306
  <box flexDirection="row" gap={0} padding={0} flexGrow={1}>
275
- <Sidebar onTabActivate={onPaneActivate} />
307
+ <Sidebar
308
+ onTabActivate={onPaneActivate}
309
+ onEmbeddedGitResizeStart={onEmbeddedGitResizeStart}
310
+ onResizeDrag={onSeparatorDrag}
311
+ onResizeDragEnd={onSeparatorDragEnd}
312
+ onSidebarResizeStart={onSidebarResizeStart}
313
+ />
276
314
  {gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'left' ? (
277
- <GitPaneInPaneMode ratio={gitPaneRatio} />
315
+ <GitPaneInPaneMode
316
+ position="left"
317
+ ratio={gitPaneRatio}
318
+ onGitPaneResizeStart={onGitPaneResizeStart}
319
+ />
278
320
  ) : null}
279
321
  {activeTree && activeTree.type === 'split' ? (
280
322
  <SplitLayout
@@ -321,7 +363,11 @@ export function RootView({
321
363
  />
322
364
  )}
323
365
  {gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'right' ? (
324
- <GitPaneInPaneMode ratio={gitPaneRatio} />
366
+ <GitPaneInPaneMode
367
+ position="right"
368
+ ratio={gitPaneRatio}
369
+ onGitPaneResizeStart={onGitPaneResizeStart}
370
+ />
325
371
  ) : null}
326
372
  </box>
327
373
  {sessionBarPosition === 'bottom' && <SessionBar />}
@@ -342,14 +388,58 @@ export function RootView({
342
388
  )
343
389
  }
344
390
 
345
- function GitPaneInPaneMode({ ratio }: { ratio: number }) {
391
+ function GitPaneInPaneMode({
392
+ onGitPaneResizeStart,
393
+ position,
394
+ ratio,
395
+ }: {
396
+ ratio: number
397
+ position: 'left' | 'right'
398
+ onGitPaneResizeStart?: (info: {
399
+ initialWidth: number
400
+ screenStart: number
401
+ side: 'left' | 'right'
402
+ }) => void
403
+ }) {
346
404
  const bg = useBg('elevated')
347
- // Ratio maps to a fixed column count (20..80), mirroring the reservation in
348
- // use-terminal-resize so the terminal-content area stays in sync.
349
- const width = Math.max(20, Math.min(80, Math.round(ratio * 80)))
405
+ const tokens = useTokens()
406
+ const gitPane = useAppStore((s) => s.gitPane)
407
+ const width = getGitPaneWidthFromRatio(ratio)
408
+ const contentWidth = Math.max(1, width - 1)
409
+ const gitPaneMenu = buildGitPaneContextMenu(
410
+ gitPane,
411
+ () => dispatchGlobal({ type: 'toggle-git-pane' }),
412
+ (mode, nextPosition) => {
413
+ dispatchGlobal({ mode, type: 'set-git-pane-mode' })
414
+ dispatchGlobal({ position: nextPosition, type: 'set-git-pane-position' })
415
+ }
416
+ )
417
+ const handle = (
418
+ <box
419
+ width={1}
420
+ flexShrink={0}
421
+ backgroundColor={tokens.border}
422
+ onMouseDown={(event) => {
423
+ event.preventDefault()
424
+ event.stopPropagation()
425
+ onGitPaneResizeStart?.({ initialWidth: width, screenStart: event.x, side: position })
426
+ }}
427
+ />
428
+ )
350
429
  return (
351
430
  <box flexDirection="column" width={width} flexShrink={0} backgroundColor={bg} overflow="hidden">
352
- <GitPaneWidget pollingEnabled />
431
+ <box flexDirection="row" flexGrow={1} overflow="hidden">
432
+ {position === 'right' ? handle : null}
433
+ <ContextMenuBox
434
+ width={contentWidth}
435
+ flexGrow={1}
436
+ overflow="hidden"
437
+ rightClickMenu={gitPaneMenu}
438
+ >
439
+ <GitPaneWidget pollingEnabled />
440
+ </ContextMenuBox>
441
+ {position === 'left' ? handle : null}
442
+ </box>
353
443
  </box>
354
444
  )
355
445
  }