@brimveyn/aimux 1.4.1 → 1.5.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.
Files changed (46) hide show
  1. package/README.md +142 -176
  2. package/package.json +6 -3
  3. package/src/app-runtime/use-renderer-bindings.ts +1 -1
  4. package/src/app-runtime/use-terminal-resize.ts +12 -2
  5. package/src/app.tsx +20 -3
  6. package/src/config.ts +53 -17
  7. package/src/index.tsx +2 -2
  8. package/src/input/keymap/build-handlers.ts +1 -0
  9. package/src/input/keymap/help-entries.ts +44 -0
  10. package/src/input/keymap/keymap-ref.ts +11 -0
  11. package/src/input/keymap/sequence-resolver.ts +35 -2
  12. package/src/input/keymap/trie.ts +1 -0
  13. package/src/input/modes/bridge.ts +1 -1
  14. package/src/input/modes/handlers/shared.ts +0 -16
  15. package/src/input/modes/transitions.ts +4 -4
  16. package/src/input/modes/types.ts +1 -1
  17. package/src/platform/daemon-control.ts +0 -8
  18. package/src/state/reducers/git-panel-state.ts +35 -8
  19. package/src/state/reducers/modal-state.ts +48 -3
  20. package/src/state/selectors.ts +1 -5
  21. package/src/state/session-persistence.ts +1 -20
  22. package/src/state/store.ts +38 -5
  23. package/src/state/types.ts +27 -13
  24. package/src/state/validation.ts +0 -2
  25. package/src/state/workspace-save.ts +6 -2
  26. package/src/ui/components/create-session-modal.tsx +5 -3
  27. package/src/ui/components/git-commit-modal.tsx +1 -3
  28. package/src/ui/components/git-pane-widget.tsx +46 -0
  29. package/src/ui/components/git-panel.tsx +72 -25
  30. package/src/ui/components/help-modal.tsx +156 -42
  31. package/src/ui/components/modal-keybinds-overlay.tsx +39 -0
  32. package/src/ui/components/modal-shell.tsx +15 -3
  33. package/src/ui/components/new-tab-modal.tsx +9 -10
  34. package/src/ui/components/pending-chord-overlay.tsx +2 -1
  35. package/src/ui/components/session-name-modal.tsx +1 -3
  36. package/src/ui/components/session-picker-modal.tsx +1 -3
  37. package/src/ui/components/sidebar.tsx +24 -50
  38. package/src/ui/components/snippet-editor-modal.tsx +1 -3
  39. package/src/ui/components/snippet-picker-modal.tsx +1 -3
  40. package/src/ui/components/status-bar.tsx +0 -4
  41. package/src/ui/components/theme-picker-modal.tsx +6 -3
  42. package/src/ui/components/update-available-modal.tsx +7 -4
  43. package/src/ui/keymap-context.ts +1 -6
  44. package/src/ui/root.tsx +29 -1
  45. package/src/ui/status-bar-model.ts +0 -5
  46. package/src/ui/directory-search.ts +0 -1
@@ -0,0 +1,44 @@
1
+ import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
2
+
3
+ import { describeBindings, type DescribedBinding } from './describe-bindings'
4
+
5
+ export interface HelpEntry extends DescribedBinding {
6
+ mode: ModeId
7
+ modeLabel: string
8
+ }
9
+
10
+ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
11
+ { label: 'Navigation', modeId: 'navigation' },
12
+ { label: 'Terminal input', modeId: 'terminal-input' },
13
+ { label: 'Git mode', modeId: 'git-mode' },
14
+ { label: 'Git commit', modeId: 'modal.git-commit' },
15
+ { label: 'New tab', modeId: 'modal.new-tab' },
16
+ { label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
17
+ { label: 'Session picker', modeId: 'modal.session-picker' },
18
+ { label: 'Session picker — filter', modeId: 'modal.session-picker.filtering' },
19
+ { label: 'Session name', modeId: 'modal.session-name' },
20
+ { label: 'Create session', modeId: 'modal.create-session' },
21
+ { label: 'Rename tab', modeId: 'modal.rename-tab' },
22
+ { label: 'Snippet picker', modeId: 'modal.snippet-picker' },
23
+ { label: 'Snippet picker — filter', modeId: 'modal.snippet-picker.filtering' },
24
+ { label: 'Snippet editor', modeId: 'modal.snippet-editor' },
25
+ { label: 'Theme picker', modeId: 'modal.theme-picker' },
26
+ { label: 'Split picker', modeId: 'modal.split-picker' },
27
+ { label: 'Help', modeId: 'modal.help' },
28
+ { label: 'Help — filter', modeId: 'modal.help.filtering' },
29
+ { label: 'Update available', modeId: 'modal.update-available' },
30
+ ]
31
+
32
+ export function collectHelpEntries(config: ResolvedKeymapConfig): HelpEntry[] {
33
+ const entries: HelpEntry[] = []
34
+ for (const { label, modeId } of HELP_MODE_LABELS) {
35
+ const bindings = describeBindings(config, modeId, {
36
+ dedupeByDescription: true,
37
+ withDescriptionOnly: true,
38
+ })
39
+ for (const binding of bindings) {
40
+ entries.push({ ...binding, mode: modeId, modeLabel: label })
41
+ }
42
+ }
43
+ return entries
44
+ }
@@ -0,0 +1,11 @@
1
+ import type { ResolvedKeymapConfig } from '@brimveyn/aimux-config'
2
+
3
+ let activeKeymap: ResolvedKeymapConfig | null = null
4
+
5
+ export function setActiveKeymap(config: ResolvedKeymapConfig | null): void {
6
+ activeKeymap = config
7
+ }
8
+
9
+ export function getActiveKeymap(): ResolvedKeymapConfig | null {
10
+ return activeKeymap
11
+ }
@@ -17,6 +17,8 @@ export class SequenceResolver {
17
17
  private timeoutHandle: ReturnType<typeof setTimeout> | null = null
18
18
  private onTimeout: ((binding: TrieBinding) => void) | null = null
19
19
  private onPendingChange: ((chords: KeyChord[] | null) => void) | null = null
20
+ private repeatTerminal: KeyChord | null = null
21
+ private repeatBinding: TrieBinding | null = null
20
22
 
21
23
  constructor(
22
24
  private readonly trie: KeyTrie,
@@ -37,16 +39,29 @@ export class SequenceResolver {
37
39
  feed(chord: KeyChord): ResolveResult {
38
40
  this.clearTimeout()
39
41
 
42
+ // Repeat: when not mid-sequence and the chord matches the last repeatable
43
+ // sequence's terminal key, re-fire it.
44
+ if (
45
+ this.currentNode === null &&
46
+ this.repeatTerminal !== null &&
47
+ this.repeatBinding !== null &&
48
+ chord === this.repeatTerminal
49
+ ) {
50
+ return { binding: this.repeatBinding, type: 'resolved' }
51
+ }
52
+
40
53
  const fromNode = this.currentNode ?? this.trie.root
41
54
  const match = this.trie.lookup(chord, fromNode)
42
55
 
43
56
  switch (match.type) {
44
57
  case 'exact': {
45
58
  this.resetState()
59
+ this.updateRepeatOnResolve(chord, match.binding)
46
60
  return { binding: match.binding, type: 'resolved' }
47
61
  }
48
62
 
49
63
  case 'prefix': {
64
+ this.clearRepeat()
50
65
  this.currentNode = match.node
51
66
  this.pendingBinding = null
52
67
  this.pendingChords.push(chord)
@@ -55,11 +70,12 @@ export class SequenceResolver {
55
70
  }
56
71
 
57
72
  case 'exact+prefix': {
73
+ this.clearRepeat()
58
74
  this.currentNode = match.node
59
75
  this.pendingBinding = match.binding
60
76
  this.pendingChords.push(chord)
61
77
  this.emitPendingChange()
62
- this.startTimeout(match.binding)
78
+ this.startTimeout(chord, match.binding)
63
79
  return { type: 'pending' }
64
80
  }
65
81
 
@@ -69,6 +85,7 @@ export class SequenceResolver {
69
85
  this.resetState()
70
86
  return this.feed(chord)
71
87
  }
88
+ this.clearRepeat()
72
89
  return { type: 'passthrough' }
73
90
  }
74
91
  }
@@ -77,6 +94,7 @@ export class SequenceResolver {
77
94
  reset(): void {
78
95
  this.clearTimeout()
79
96
  this.resetState()
97
+ this.clearRepeat()
80
98
  }
81
99
 
82
100
  setTimeoutCallback(cb: (binding: TrieBinding) => void): void {
@@ -107,11 +125,26 @@ export class SequenceResolver {
107
125
  }
108
126
  }
109
127
 
110
- private startTimeout(binding: TrieBinding): void {
128
+ private startTimeout(chord: KeyChord, binding: TrieBinding): void {
111
129
  this.timeoutHandle = setTimeout(() => {
112
130
  this.timeoutHandle = null
113
131
  this.resetState()
132
+ this.updateRepeatOnResolve(chord, binding)
114
133
  this.onTimeout?.(binding)
115
134
  }, this.config.timeoutMs)
116
135
  }
136
+
137
+ private updateRepeatOnResolve(chord: KeyChord, binding: TrieBinding): void {
138
+ if (binding.repeatable) {
139
+ this.repeatTerminal = chord
140
+ this.repeatBinding = binding
141
+ } else {
142
+ this.clearRepeat()
143
+ }
144
+ }
145
+
146
+ private clearRepeat(): void {
147
+ this.repeatTerminal = null
148
+ this.repeatBinding = null
149
+ }
117
150
  }
@@ -6,6 +6,7 @@ export type ActionFn = (ctx: ModeContext) => KeyResult | null
6
6
  export interface TrieBinding {
7
7
  result: KeyResult | ActionFn
8
8
  group?: string
9
+ repeatable?: boolean
9
10
  }
10
11
 
11
12
  export interface TrieNode {
@@ -5,7 +5,6 @@ type SupportedModalType = Exclude<ModalType, null>
5
5
 
6
6
  const DIRECT_FOCUS_MODE_IDS: Partial<Record<FocusMode, ModeId>> = {
7
7
  'git': 'git-mode',
8
- 'layout': 'layout',
9
8
  'navigation': 'navigation',
10
9
  'terminal-input': 'terminal-input',
11
10
  }
@@ -13,6 +12,7 @@ const DIRECT_FOCUS_MODE_IDS: Partial<Record<FocusMode, ModeId>> = {
13
12
  const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
14
13
  'create-session': 'modal.create-session',
15
14
  'git-commit': 'modal.git-commit',
15
+ 'help': 'modal.help.filtering',
16
16
  'new-tab': 'modal.new-tab.command-edit',
17
17
  'rename-tab': 'modal.rename-tab',
18
18
  'session-name': 'modal.session-name',
@@ -68,19 +68,3 @@ export function handleCtrlNavigation(key: KeyInput): KeyResult | null {
68
68
 
69
69
  return null
70
70
  }
71
-
72
- export function handleCursorNavigation(key: KeyInput): KeyResult | null {
73
- if (key.name === 'left' && !key.ctrl && !key.meta) {
74
- return result([{ delta: -1, type: 'move-modal-cursor' }])
75
- }
76
- if (key.name === 'right' && !key.ctrl && !key.meta) {
77
- return result([{ delta: 1, type: 'move-modal-cursor' }])
78
- }
79
- if (key.name === 'home' || (key.ctrl && key.name === 'a')) {
80
- return result([{ to: 'home', type: 'move-modal-cursor' }])
81
- }
82
- if (key.name === 'end' || (key.ctrl && key.name === 'e')) {
83
- return result([{ to: 'end', type: 'move-modal-cursor' }])
84
- }
85
- return null
86
- }
@@ -2,10 +2,10 @@ import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
4
  'git-mode': ['navigation', 'modal.git-commit'],
5
- 'layout': ['terminal-input', 'navigation', 'modal.split-picker'],
6
5
  'modal.create-session': ['navigation', 'modal.session-picker'],
7
6
  'modal.git-commit': ['git-mode'],
8
- 'modal.help': ['navigation'],
7
+ 'modal.help': ['navigation', 'modal.help.filtering'],
8
+ 'modal.help.filtering': ['modal.help'],
9
9
  'modal.new-tab': ['navigation', 'modal.new-tab.command-edit'],
10
10
  'modal.new-tab.command-edit': ['modal.new-tab'],
11
11
  'modal.rename-tab': ['navigation'],
@@ -20,7 +20,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
20
20
  'modal.snippet-editor': ['navigation', 'modal.snippet-picker'],
21
21
  'modal.snippet-picker': ['navigation', 'modal.snippet-picker.filtering', 'modal.snippet-editor'],
22
22
  'modal.snippet-picker.filtering': ['modal.snippet-picker'],
23
- 'modal.split-picker': ['navigation'],
23
+ 'modal.split-picker': ['navigation', 'terminal-input'],
24
24
  'modal.theme-picker': ['navigation'],
25
25
  'modal.update-available': ['navigation'],
26
26
  'navigation': [
@@ -34,7 +34,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
34
34
  'modal.update-available',
35
35
  'git-mode',
36
36
  ],
37
- 'terminal-input': ['navigation', 'layout'],
37
+ 'terminal-input': ['navigation', 'modal.split-picker'],
38
38
  }
39
39
 
40
40
  export function isValidTransition(from: ModeId, to: ModeId): boolean {
@@ -5,7 +5,6 @@ import type { AppAction, AppState, TabSession } from '../../state/types'
5
5
  export type ModeId =
6
6
  | 'navigation'
7
7
  | 'terminal-input'
8
- | 'layout'
9
8
  | 'git-mode'
10
9
  | 'modal.new-tab'
11
10
  | 'modal.new-tab.command-edit'
@@ -19,6 +18,7 @@ export type ModeId =
19
18
  | 'modal.snippet-editor'
20
19
  | 'modal.theme-picker'
21
20
  | 'modal.help'
21
+ | 'modal.help.filtering'
22
22
  | 'modal.split-picker'
23
23
  | 'modal.git-commit'
24
24
  | 'modal.update-available'
@@ -21,10 +21,6 @@ export async function findSocketProcessPid(socketPath: string): Promise<number |
21
21
  }
22
22
  }
23
23
 
24
- export async function findDaemonPid(socketPath: string): Promise<number | null> {
25
- return findSocketProcessPid(socketPath)
26
- }
27
-
28
24
  export async function findIpcDaemonPid(): Promise<number | null> {
29
25
  return findSocketProcessPid(getIpcDaemonSocketPath())
30
26
  }
@@ -53,10 +49,6 @@ export async function killProcess(pid: number): Promise<void> {
53
49
  }
54
50
  }
55
51
 
56
- export async function killDaemon(pid: number): Promise<void> {
57
- await killProcess(pid)
58
- }
59
-
60
52
  async function waitForSocket(socketPath: string): Promise<boolean> {
61
53
  const deadline = Date.now() + 2_000
62
54
  while (Date.now() < deadline) {
@@ -49,18 +49,45 @@ function sameFiles(a: GitFileEntry[], b: GitFileEntry[]): boolean {
49
49
 
50
50
  export function reduceGitPanelState(state: AppState, action: AppAction): AppState | null {
51
51
  switch (action.type) {
52
- case 'toggle-git-panel': {
53
- const nextGitVisible = !state.sidebar.gitPanelVisible
54
- const nextVisible = state.sidebar.visible || nextGitVisible
52
+ case 'toggle-git-pane': {
53
+ const nextVisible = !state.gitPane.visible
54
+ const sidebarMustShow = state.gitPane.mode === 'embedded' && nextVisible
55
55
  return {
56
56
  ...state,
57
- sidebar: { ...state.sidebar, gitPanelVisible: nextGitVisible, visible: nextVisible },
57
+ gitPane: { ...state.gitPane, visible: nextVisible },
58
+ sidebar: sidebarMustShow ? { ...state.sidebar, visible: true } : state.sidebar,
58
59
  }
59
60
  }
60
- case 'resize-git-panel': {
61
- const nextRatio = clampRatio(state.sidebar.gitPanelRatio + action.delta)
62
- if (nextRatio === state.sidebar.gitPanelRatio) return state
63
- return { ...state, sidebar: { ...state.sidebar, gitPanelRatio: nextRatio } }
61
+ case 'resize-git-pane': {
62
+ const nextRatio = clampRatio(state.gitPane.ratio + action.delta)
63
+ if (nextRatio === state.gitPane.ratio) return state
64
+ return { ...state, gitPane: { ...state.gitPane, ratio: nextRatio } }
65
+ }
66
+ case 'set-git-pane-mode': {
67
+ if (state.gitPane.mode === action.mode) return state
68
+ const isEmbedded = action.mode === 'embedded'
69
+ const isValidEmbedded =
70
+ state.gitPane.position === 'top' || state.gitPane.position === 'bottom'
71
+ const isValidPane = state.gitPane.position === 'left' || state.gitPane.position === 'right'
72
+ let nextPosition: typeof state.gitPane.position
73
+ if (isEmbedded) {
74
+ nextPosition = isValidEmbedded ? state.gitPane.position : 'bottom'
75
+ } else {
76
+ nextPosition = isValidPane ? state.gitPane.position : 'left'
77
+ }
78
+ return {
79
+ ...state,
80
+ gitPane: { ...state.gitPane, mode: action.mode, position: nextPosition },
81
+ }
82
+ }
83
+ case 'set-git-pane-position': {
84
+ const validForMode =
85
+ state.gitPane.mode === 'embedded'
86
+ ? action.position === 'top' || action.position === 'bottom'
87
+ : action.position === 'left' || action.position === 'right'
88
+ if (!validForMode) return state
89
+ if (state.gitPane.position === action.position) return state
90
+ return { ...state, gitPane: { ...state.gitPane, position: action.position } }
64
91
  }
65
92
  case 'git-refresh-success': {
66
93
  const prev = state.gitPanel
@@ -2,6 +2,8 @@ import { basename } from 'node:path'
2
2
 
3
3
  import type { AppAction, AppState } from '../types'
4
4
 
5
+ import { collectHelpEntries } from '../../input/keymap/help-entries'
6
+ import { getActiveKeymap } from '../../input/keymap/keymap-ref'
5
7
  import { getAllAssistantOptions } from '../../pty/command-registry'
6
8
  import { THEME_IDS } from '../../ui/themes'
7
9
  import { filterSessions, filterSnippets } from '../selectors'
@@ -38,18 +40,22 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
38
40
  type: 'new-tab',
39
41
  },
40
42
  }
41
- case 'open-help-modal':
43
+ case 'open-help-modal': {
44
+ const keymap = getActiveKeymap()
45
+ const entryCount = keymap ? collectHelpEntries(keymap).length : 0
42
46
  return {
43
47
  ...state,
44
48
  focusMode: 'modal',
45
49
  modal: {
46
50
  cursorPos: 0,
47
51
  editBuffer: null,
52
+ entryCount,
48
53
  selectedIndex: 0,
49
54
  sessionTargetId: null,
50
55
  type: 'help',
51
56
  },
52
57
  }
58
+ }
53
59
  case 'open-split-picker':
54
60
  return {
55
61
  ...state,
@@ -200,12 +206,45 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
200
206
  modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
201
207
  }
202
208
  }
209
+ case 'set-help-entry-count': {
210
+ if (state.modal.type !== 'help') return state
211
+ if (state.modal.entryCount === action.count) {
212
+ // Still clamp selectedIndex in case the count shrank below it.
213
+ const clamped = Math.min(state.modal.selectedIndex, Math.max(0, action.count - 1))
214
+ if (clamped === state.modal.selectedIndex) return state
215
+ return { ...state, modal: { ...state.modal, selectedIndex: clamped } }
216
+ }
217
+ const clamped = Math.min(state.modal.selectedIndex, Math.max(0, action.count - 1))
218
+ return {
219
+ ...state,
220
+ modal: { ...state.modal, entryCount: action.count, selectedIndex: clamped },
221
+ }
222
+ }
223
+ case 'begin-help-filter': {
224
+ if (state.modal.type !== 'help') {
225
+ return state
226
+ }
227
+ const buf = state.modal.editBuffer ?? ''
228
+ return {
229
+ ...state,
230
+ focusMode: 'command-edit',
231
+ modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
232
+ }
233
+ }
203
234
  case 'close-modal': {
204
235
  const nextFocus: AppState['focusMode'] =
205
236
  state.modal.type === 'git-commit' ? 'git' : 'navigation'
206
237
  return { ...state, focusMode: nextFocus, modal: emptyModal() }
207
238
  }
208
239
  case 'move-modal-selection': {
240
+ if (state.modal.type === 'help') {
241
+ const count = state.modal.entryCount
242
+ if (count <= 0) return state
243
+ const raw = state.modal.selectedIndex + action.delta
244
+ const nextIndex = ((raw % count) + count) % count
245
+ if (nextIndex === state.modal.selectedIndex) return state
246
+ return { ...state, modal: { ...state.modal, selectedIndex: nextIndex } }
247
+ }
209
248
  if (
210
249
  state.modal.type !== 'new-tab' &&
211
250
  state.modal.type !== 'session-picker' &&
@@ -293,7 +332,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
293
332
  nextCursor = cursor + action.char.length
294
333
  }
295
334
  const resetIndex =
296
- state.modal.type === 'session-picker' || state.modal.type === 'snippet-picker'
335
+ state.modal.type === 'session-picker' ||
336
+ state.modal.type === 'snippet-picker' ||
337
+ state.modal.type === 'help'
297
338
  ? 0
298
339
  : state.modal.selectedIndex
299
340
  return {
@@ -360,7 +401,11 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
360
401
  if (state.modal.type === 'create-session' || state.modal.type === 'snippet-editor') {
361
402
  return { ...state, focusMode: 'navigation', modal: emptyModal() }
362
403
  }
363
- if (state.modal.type === 'session-picker' || state.modal.type === 'snippet-picker') {
404
+ if (
405
+ state.modal.type === 'session-picker' ||
406
+ state.modal.type === 'snippet-picker' ||
407
+ state.modal.type === 'help'
408
+ ) {
364
409
  return {
365
410
  ...state,
366
411
  focusMode: 'modal',
@@ -1,4 +1,4 @@
1
- import type { AppState, SessionRecord, SnippetRecord, TabSession } from './types'
1
+ import type { SessionRecord, SnippetRecord } from './types'
2
2
 
3
3
  export function filterSessions(sessions: SessionRecord[], filter: string | null): SessionRecord[] {
4
4
  if (!filter) {
@@ -24,7 +24,3 @@ export function filterSnippets(snippets: SnippetRecord[], filter: string | null)
24
24
  snippet.name.toLowerCase().includes(lower) || snippet.content.toLowerCase().includes(lower)
25
25
  )
26
26
  }
27
-
28
- export function getActiveTab(state: AppState): TabSession | undefined {
29
- return state.activeTabId ? state.tabs.find((tab) => tab.id === state.activeTabId) : undefined
30
- }
@@ -1,10 +1,4 @@
1
- import {
2
- allLeafIds,
3
- createGroupId,
4
- createLeaf,
5
- type LayoutNode,
6
- pruneLayoutTree,
7
- } from './layout-tree'
1
+ import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './layout-tree'
8
2
  import {
9
3
  type AppState,
10
4
  DEFAULT_SCROLL_INTENT,
@@ -83,19 +77,6 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
83
77
  }))
84
78
  }
85
79
 
86
- export function restoreLayoutTree(
87
- snapshot: WorkspaceSnapshotV1 | undefined,
88
- tabs: TabSession[]
89
- ): LayoutNode | null {
90
- if (snapshot?.layoutTree) {
91
- const validTabIds = new Set(tabs.map((t) => t.id))
92
- const pruned = pruneLayoutTree(snapshot.layoutTree, validTabIds)
93
- if (pruned) return pruned
94
- }
95
- // Fallback: single leaf for the first tab
96
- return tabs[0] ? createLeaf(tabs[0].id) : null
97
- }
98
-
99
80
  export function restoreLayoutTrees(
100
81
  snapshot: WorkspaceSnapshotV1 | undefined,
101
82
  tabs: TabSession[]
@@ -1,4 +1,13 @@
1
- import type { AppAction, AppState, SessionBarPosition, SessionRecord, SnippetRecord } from './types'
1
+ import type {
2
+ AppAction,
3
+ AppState,
4
+ GitPaneMode,
5
+ GitPanePosition,
6
+ GitPaneState,
7
+ SessionBarPosition,
8
+ SessionRecord,
9
+ SnippetRecord,
10
+ } from './types'
2
11
 
3
12
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
4
13
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
@@ -15,12 +24,27 @@ const DEFAULT_TERMINAL_COLS = 80
15
24
  const DEFAULT_TERMINAL_ROWS = 24
16
25
 
17
26
  export interface InitialStateOverrides {
18
- gitPanelVisible?: boolean
19
- gitPanelRatio?: number
27
+ gitPane?: Partial<GitPaneState>
20
28
  sessionBarVisible?: boolean
21
29
  sessionBarPosition?: SessionBarPosition
22
30
  }
23
31
 
32
+ const DEFAULT_GIT_PANE: GitPaneState = {
33
+ diffCount: { enabled: true },
34
+ mode: 'embedded',
35
+ path: { enabled: true },
36
+ position: 'bottom',
37
+ ratio: 0.5,
38
+ visible: true,
39
+ }
40
+
41
+ function resolveGitPanePosition(mode: GitPaneMode, position: GitPanePosition): GitPanePosition {
42
+ if (mode === 'embedded') {
43
+ return position === 'top' || position === 'bottom' ? position : 'bottom'
44
+ }
45
+ return position === 'left' || position === 'right' ? position : 'left'
46
+ }
47
+
24
48
  export function createInitialState(
25
49
  customCommands: Record<string, string> = {},
26
50
  sessions: SessionRecord[] = [],
@@ -28,12 +52,23 @@ export function createInitialState(
28
52
  showSessionPicker = false,
29
53
  overrides: InitialStateOverrides = {}
30
54
  ): AppState {
55
+ const gitPaneMode = overrides.gitPane?.mode ?? DEFAULT_GIT_PANE.mode
56
+ const gitPanePosition = resolveGitPanePosition(
57
+ gitPaneMode,
58
+ overrides.gitPane?.position ?? DEFAULT_GIT_PANE.position
59
+ )
31
60
  return {
32
61
  activeTabId: null,
33
62
  currentSessionId: null,
34
63
  customCommands,
35
64
  focusMode: showSessionPicker ? 'modal' : 'navigation',
36
65
  gitMode: emptyGitMode(),
66
+ gitPane: {
67
+ ...DEFAULT_GIT_PANE,
68
+ ...overrides.gitPane,
69
+ mode: gitPaneMode,
70
+ position: gitPanePosition,
71
+ },
37
72
  gitPanel: emptyGitPanel(),
38
73
  layout: {
39
74
  terminalCols: DEFAULT_TERMINAL_COLS,
@@ -51,8 +86,6 @@ export function createInitialState(
51
86
  sessions,
52
87
  sessionsBusy: {},
53
88
  sidebar: {
54
- gitPanelRatio: overrides.gitPanelRatio ?? 0.5,
55
- gitPanelVisible: overrides.gitPanelVisible ?? true,
56
89
  maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
57
90
  minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
58
91
  visible: true,
@@ -6,13 +6,7 @@ export type TabStatus = 'starting' | 'running' | 'disconnected' | 'exited' | 'er
6
6
 
7
7
  export type TabActivity = 'busy' | 'idle'
8
8
 
9
- export type FocusMode =
10
- | 'navigation'
11
- | 'terminal-input'
12
- | 'modal'
13
- | 'command-edit'
14
- | 'layout'
15
- | 'git'
9
+ export type FocusMode = 'navigation' | 'terminal-input' | 'modal' | 'command-edit' | 'git'
16
10
 
17
11
  export type ModalType =
18
12
  | 'new-tab'
@@ -89,8 +83,6 @@ export interface WorkspaceSnapshotV1 {
89
83
  sidebar: {
90
84
  visible: boolean
91
85
  width: number
92
- gitPanelVisible?: boolean
93
- gitPanelRatio?: number
94
86
  }
95
87
  tabs: PersistedTabSnapshot[]
96
88
  layoutTree?: import('./layout-tree').LayoutNode
@@ -136,8 +128,24 @@ export interface SidebarState {
136
128
  width: number
137
129
  minWidth: number
138
130
  maxWidth: number
139
- gitPanelVisible: boolean
140
- gitPanelRatio: number
131
+ }
132
+
133
+ export type GitPaneMode = 'embedded' | 'pane'
134
+ export type GitPanePosition = 'top' | 'bottom' | 'left' | 'right'
135
+
136
+ export type GitPanePathConfig =
137
+ | { enabled: false }
138
+ | { enabled: true; pathFn?: (path: string) => string }
139
+
140
+ export type GitPaneDiffCountConfig = { enabled: boolean }
141
+
142
+ export interface GitPaneState {
143
+ visible: boolean
144
+ mode: GitPaneMode
145
+ position: GitPanePosition
146
+ ratio: number
147
+ path: GitPanePathConfig
148
+ diffCount: GitPaneDiffCountConfig
141
149
  }
142
150
 
143
151
  export type GitFileStatus = 'M' | 'A' | 'D' | 'R' | 'C' | 'U' | '?'
@@ -222,6 +230,7 @@ export interface ModalThemePicker extends ModalBase {
222
230
 
223
231
  export interface ModalHelp extends ModalBase {
224
232
  type: 'help'
233
+ entryCount: number
225
234
  }
226
235
 
227
236
  export interface ModalSplitPicker extends ModalBase {
@@ -300,6 +309,7 @@ export interface AppState {
300
309
  snippets: SnippetRecord[]
301
310
  focusMode: FocusMode
302
311
  sidebar: SidebarState
312
+ gitPane: GitPaneState
303
313
  modal: ModalState
304
314
  layout: LayoutState
305
315
  customCommands: Record<AssistantId, string>
@@ -332,6 +342,8 @@ export type ModalAction =
332
342
  | { type: 'open-snippet-picker' }
333
343
  | { type: 'open-snippet-editor'; snippetId?: string }
334
344
  | { type: 'begin-snippet-filter' }
345
+ | { type: 'begin-help-filter' }
346
+ | { type: 'set-help-entry-count'; count: number }
335
347
  | { type: 'open-theme-picker' }
336
348
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
337
349
 
@@ -407,8 +419,10 @@ export type UIAction =
407
419
  | { type: 'resize-sidebar'; delta: number }
408
420
  | { type: 'set-focus-mode'; focusMode: FocusMode }
409
421
  | { type: 'set-terminal-size'; cols: number; rows: number }
410
- | { type: 'toggle-git-panel' }
411
- | { type: 'resize-git-panel'; delta: number }
422
+ | { type: 'toggle-git-pane' }
423
+ | { type: 'resize-git-pane'; delta: number }
424
+ | { type: 'set-git-pane-mode'; mode: GitPaneMode }
425
+ | { type: 'set-git-pane-position'; position: GitPanePosition }
412
426
  | { type: 'set-pending-chords'; chords: string[] | null }
413
427
  | { type: 'toggle-session-bar' }
414
428
  | { type: 'set-session-bar-position'; position: SessionBarPosition }
@@ -114,8 +114,6 @@ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapsho
114
114
  isObjectRecord(value.sidebar) &&
115
115
  isBoolean(value.sidebar.visible) &&
116
116
  isFiniteNumber(value.sidebar.width) &&
117
- (value.sidebar.gitPanelVisible === undefined || isBoolean(value.sidebar.gitPanelVisible)) &&
118
- (value.sidebar.gitPanelRatio === undefined || isFiniteNumber(value.sidebar.gitPanelRatio)) &&
119
117
  Array.isArray(value.tabs) &&
120
118
  value.tabs.every(
121
119
  (tab) =>
@@ -24,8 +24,12 @@ export function saveCurrentWorkspace(state: AppState): void {
24
24
  saveConfig({
25
25
  ...loadConfig(),
26
26
  customCommands: state.customCommands,
27
- gitPanelRatio: state.sidebar.gitPanelRatio,
28
- gitPanelVisible: state.sidebar.gitPanelVisible,
27
+ gitPane: {
28
+ mode: state.gitPane.mode,
29
+ position: state.gitPane.position,
30
+ ratio: state.gitPane.ratio,
31
+ visible: state.gitPane.visible,
32
+ },
29
33
  sessionBarPosition: state.sessionBar.position,
30
34
  sessionBarVisible: state.sessionBar.visible,
31
35
  })