@brimveyn/aimux 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/package.json +79 -0
  4. package/src/app-runtime/backend-attach-runtime.ts +135 -0
  5. package/src/app-runtime/backend-runtime-events.ts +78 -0
  6. package/src/app-runtime/click-selection-resolver.ts +130 -0
  7. package/src/app-runtime/pty-write.ts +32 -0
  8. package/src/app-runtime/render-invalidation.ts +20 -0
  9. package/src/app-runtime/selection-clipboard.ts +69 -0
  10. package/src/app-runtime/selection-scroll.ts +143 -0
  11. package/src/app-runtime/session-actions.ts +170 -0
  12. package/src/app-runtime/side-effects.ts +434 -0
  13. package/src/app-runtime/snippet-actions.ts +90 -0
  14. package/src/app-runtime/split-drag-controller.ts +15 -0
  15. package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
  16. package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
  17. package/src/app-runtime/use-backend-runtime.ts +99 -0
  18. package/src/app-runtime/use-directory-search.ts +52 -0
  19. package/src/app-runtime/use-mouse-handlers.ts +183 -0
  20. package/src/app-runtime/use-renderer-bindings.ts +163 -0
  21. package/src/app-runtime/use-terminal-resize.ts +141 -0
  22. package/src/app-runtime/use-workspace-autosave.ts +39 -0
  23. package/src/app.tsx +247 -0
  24. package/src/config/loader.ts +36 -0
  25. package/src/config.ts +146 -0
  26. package/src/daemon/daemon.ts +236 -0
  27. package/src/daemon/runtime-paths.ts +69 -0
  28. package/src/daemon/session-manager.ts +109 -0
  29. package/src/daemon/session-registry.ts +207 -0
  30. package/src/debug/input-log.ts +29 -0
  31. package/src/doctor.ts +106 -0
  32. package/src/git/git-poller.ts +49 -0
  33. package/src/git/git-status.ts +183 -0
  34. package/src/index.tsx +56 -0
  35. package/src/input/keymap/build-handlers.ts +34 -0
  36. package/src/input/keymap/key-chord.ts +201 -0
  37. package/src/input/keymap/keymap-mode-handler.ts +88 -0
  38. package/src/input/keymap/sequence-resolver.ts +117 -0
  39. package/src/input/keymap/trie.ts +103 -0
  40. package/src/input/modes/bridge.ts +58 -0
  41. package/src/input/modes/handlers/index.ts +18 -0
  42. package/src/input/modes/handlers/shared.ts +70 -0
  43. package/src/input/modes/registry.ts +34 -0
  44. package/src/input/modes/transitions.ts +37 -0
  45. package/src/input/modes/types.ts +63 -0
  46. package/src/input/mouse-forwarding.ts +98 -0
  47. package/src/input/multi-click-detector.ts +55 -0
  48. package/src/input/paste.ts +12 -0
  49. package/src/input/raw-input-handler.ts +191 -0
  50. package/src/input/terminal-text-extraction.ts +78 -0
  51. package/src/ipc/protocol.ts +341 -0
  52. package/src/platform/clipboard.ts +13 -0
  53. package/src/platform/daemon-control.ts +62 -0
  54. package/src/platform/id.ts +7 -0
  55. package/src/platform/project-search.ts +75 -0
  56. package/src/pty/command-registry.ts +69 -0
  57. package/src/pty/pty-manager.ts +268 -0
  58. package/src/pty/terminal-snapshot.ts +210 -0
  59. package/src/restart-daemon.ts +28 -0
  60. package/src/session-backend/bootstrap.ts +107 -0
  61. package/src/session-backend/local-session-backend.ts +164 -0
  62. package/src/session-backend/remote-session-backend.ts +373 -0
  63. package/src/session-backend/types.ts +47 -0
  64. package/src/state/app-store.ts +19 -0
  65. package/src/state/layout-resize.ts +56 -0
  66. package/src/state/layout-tree.ts +323 -0
  67. package/src/state/reducers/git-panel-state.ts +102 -0
  68. package/src/state/reducers/modal-state.ts +358 -0
  69. package/src/state/reducers/session-state.ts +86 -0
  70. package/src/state/reducers/tab-state.ts +531 -0
  71. package/src/state/reducers/ui-state.ts +29 -0
  72. package/src/state/selectors.ts +30 -0
  73. package/src/state/session-catalog.ts +96 -0
  74. package/src/state/session-persistence.ts +210 -0
  75. package/src/state/snippet-catalog.ts +90 -0
  76. package/src/state/store.ts +93 -0
  77. package/src/state/terminal-modes.ts +11 -0
  78. package/src/state/types.ts +367 -0
  79. package/src/state/validation.ts +154 -0
  80. package/src/state/workspace-save.ts +33 -0
  81. package/src/ui/components/create-session-modal.tsx +100 -0
  82. package/src/ui/components/git-panel.tsx +200 -0
  83. package/src/ui/components/help-modal.tsx +79 -0
  84. package/src/ui/components/input-field.tsx +18 -0
  85. package/src/ui/components/list-item.tsx +30 -0
  86. package/src/ui/components/modal-filter-bar.tsx +18 -0
  87. package/src/ui/components/modal-shell.tsx +47 -0
  88. package/src/ui/components/new-tab-modal.tsx +52 -0
  89. package/src/ui/components/pending-chord-indicator.tsx +41 -0
  90. package/src/ui/components/session-name-modal.tsx +15 -0
  91. package/src/ui/components/session-picker-modal.tsx +93 -0
  92. package/src/ui/components/sidebar-group-metadata.ts +44 -0
  93. package/src/ui/components/sidebar-scroll.ts +33 -0
  94. package/src/ui/components/sidebar.tsx +225 -0
  95. package/src/ui/components/snippet-editor-modal.tsx +39 -0
  96. package/src/ui/components/snippet-picker-modal.tsx +56 -0
  97. package/src/ui/components/split-layout.tsx +201 -0
  98. package/src/ui/components/status-bar.tsx +60 -0
  99. package/src/ui/components/surface.tsx +63 -0
  100. package/src/ui/components/tab-item.tsx +118 -0
  101. package/src/ui/components/terminal-pane.tsx +215 -0
  102. package/src/ui/components/theme-picker-modal.tsx +35 -0
  103. package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
  104. package/src/ui/components/use-sidebar-branch.ts +36 -0
  105. package/src/ui/directory-search.ts +1 -0
  106. package/src/ui/git-branch.ts +11 -0
  107. package/src/ui/path-format.ts +6 -0
  108. package/src/ui/root.tsx +261 -0
  109. package/src/ui/status-bar-model.ts +87 -0
  110. package/src/ui/theme.ts +9 -0
  111. package/src/ui/themes.ts +255 -0
  112. package/src/ui/ui-tokens.ts +11 -0
  113. package/src/update.ts +62 -0
@@ -0,0 +1,200 @@
1
+ import { memo, type ReactNode, useMemo } from 'react'
2
+
3
+ import type { GitFileEntry, GitFileSection, GitPanelState } from '../../state/types'
4
+
5
+ import { theme } from '../theme'
6
+
7
+ interface GitPanelProps {
8
+ gitPanel: GitPanelState
9
+ projectPath: string | undefined
10
+ }
11
+
12
+ const SECTION_ORDER: { key: GitFileSection; title: string }[] = [
13
+ { key: 'staged', title: 'Staged Changes' },
14
+ { key: 'unstaged', title: 'Changes' },
15
+ { key: 'untracked', title: 'Untracked' },
16
+ ]
17
+
18
+ const STATUS_COLORS: Record<GitFileEntry['status'], string> = {
19
+ '?': theme.textMuted,
20
+ 'A': theme.success,
21
+ 'C': theme.accent,
22
+ 'D': theme.danger,
23
+ 'M': theme.warning,
24
+ 'R': theme.accent,
25
+ 'U': theme.danger,
26
+ }
27
+
28
+ function groupBySection(files: GitFileEntry[]): Record<GitFileSection, GitFileEntry[]> {
29
+ const groups: Record<GitFileSection, GitFileEntry[]> = {
30
+ staged: [],
31
+ unstaged: [],
32
+ untracked: [],
33
+ }
34
+ for (const file of files) groups[file.section].push(file)
35
+ return groups
36
+ }
37
+
38
+ function maxDigitWidth(files: GitFileEntry[]): { added: number; removed: number } {
39
+ let addedMax = 1
40
+ let removedMax = 1
41
+ for (const f of files) {
42
+ if (f.added !== null) addedMax = Math.max(addedMax, String(f.added).length)
43
+ if (f.removed !== null) removedMax = Math.max(removedMax, String(f.removed).length)
44
+ }
45
+ return { added: addedMax, removed: removedMax }
46
+ }
47
+
48
+ function padRight(value: number | null, width: number): string {
49
+ return String(value ?? 0).padStart(width, ' ')
50
+ }
51
+
52
+ export function splitPath(path: string): { prefix: string; basename: string } {
53
+ const slash = path.lastIndexOf('/')
54
+ if (slash < 0) return { basename: path, prefix: '' }
55
+ return { basename: path.slice(slash + 1), prefix: path.slice(0, slash + 1) }
56
+ }
57
+
58
+ function stripTrailingSlash(prefix: string): string {
59
+ return prefix.endsWith('/') ? prefix.slice(0, -1) : prefix
60
+ }
61
+
62
+ function renderPath(file: GitFileEntry): ReactNode {
63
+ const { basename, prefix } = splitPath(file.path)
64
+ const dir = stripTrailingSlash(prefix)
65
+ if (file.renamedFrom) {
66
+ const renamed = splitPath(file.renamedFrom)
67
+ const renamedDir = stripTrailingSlash(renamed.prefix)
68
+ return (
69
+ <text wrapMode="none">
70
+ <span fg={theme.text}>{renamed.basename}</span>
71
+ {renamedDir ? <span fg={theme.textMuted}> {renamedDir}</span> : null}
72
+ <span fg={theme.textMuted}> → </span>
73
+ <span fg={theme.text}>{basename}</span>
74
+ {dir ? <span fg={theme.textMuted}> {dir}</span> : null}
75
+ </text>
76
+ )
77
+ }
78
+ return (
79
+ <text wrapMode="none">
80
+ <span fg={theme.text}>{basename}</span>
81
+ {dir ? <span fg={theme.textMuted}> {dir}</span> : null}
82
+ </text>
83
+ )
84
+ }
85
+
86
+ function renderFileRow(
87
+ file: GitFileEntry,
88
+ key: string,
89
+ addedW: number,
90
+ removedW: number
91
+ ): ReactNode {
92
+ const hasNumstat = file.added !== null || file.removed !== null
93
+ return (
94
+ <box key={key} flexDirection="row" gap={1} paddingLeft={1}>
95
+ <text fg={STATUS_COLORS[file.status]}>
96
+ <strong>{file.status}</strong>
97
+ </text>
98
+ <box flexGrow={1} overflow="hidden">
99
+ {renderPath(file)}
100
+ </box>
101
+ {hasNumstat ? (
102
+ <box flexDirection="row" flexShrink={0}>
103
+ <text fg={theme.success}>{`+${padRight(file.added, addedW)}`}</text>
104
+ <text fg={theme.dim}> </text>
105
+ <text fg={theme.danger}>{`−${padRight(file.removed, removedW)}`}</text>
106
+ </box>
107
+ ) : (
108
+ <text fg={theme.textMuted} flexShrink={0}>
109
+
110
+ </text>
111
+ )}
112
+ </box>
113
+ )
114
+ }
115
+
116
+ function renderSection(
117
+ section: GitFileSection,
118
+ title: string,
119
+ files: GitFileEntry[],
120
+ addedW: number,
121
+ removedW: number
122
+ ): ReactNode {
123
+ if (files.length === 0) return null
124
+ return (
125
+ <box key={section} flexDirection="column">
126
+ <text fg={theme.accentAlt}>
127
+ <strong>
128
+ {title} ({files.length})
129
+ </strong>
130
+ </text>
131
+ {files.map((file, i) => renderFileRow(file, `${section}-${i}`, addedW, removedW))}
132
+ </box>
133
+ )
134
+ }
135
+
136
+ interface StatusPlaceholder {
137
+ label: string
138
+ labelColor: string
139
+ }
140
+
141
+ function renderStatus(gitPanel: GitPanelState, hasProjectPath: boolean): ReactNode | null {
142
+ const placeholder = computeStatusPlaceholder(gitPanel, hasProjectPath)
143
+ if (!placeholder) return null
144
+ return (
145
+ <box flexGrow={1} flexDirection="column" alignItems="center" paddingTop={1}>
146
+ <text fg={placeholder.labelColor}>{placeholder.label}</text>
147
+ </box>
148
+ )
149
+ }
150
+
151
+ function computeStatusPlaceholder(
152
+ gitPanel: GitPanelState,
153
+ hasProjectPath: boolean
154
+ ): StatusPlaceholder | null {
155
+ if (!hasProjectPath) {
156
+ return { label: 'No active session', labelColor: theme.textMuted }
157
+ }
158
+ if (gitPanel.error === 'not-a-repo') {
159
+ return { label: 'Not a git repository', labelColor: theme.textMuted }
160
+ }
161
+ if (gitPanel.error === 'unknown') {
162
+ return { label: 'Git error', labelColor: theme.danger }
163
+ }
164
+ if (gitPanel.files.length === 0) {
165
+ return { label: 'Working tree clean', labelColor: theme.textMuted }
166
+ }
167
+ return null
168
+ }
169
+
170
+ export const GitPanel = memo(function GitPanel({ gitPanel, projectPath }: GitPanelProps) {
171
+ const groups = useMemo(() => groupBySection(gitPanel.files), [gitPanel.files])
172
+ const { added: addedW, removed: removedW } = useMemo(
173
+ () => maxDigitWidth(gitPanel.files),
174
+ [gitPanel.files]
175
+ )
176
+
177
+ const statusNode = renderStatus(gitPanel, !!projectPath)
178
+
179
+ const hasRemoteTracking = gitPanel.ahead > 0 || gitPanel.behind > 0
180
+
181
+ return (
182
+ <box flexDirection="column" flexGrow={1} gap={0}>
183
+ {hasRemoteTracking ? (
184
+ <text fg={theme.textMuted}>
185
+ ↑{gitPanel.ahead} ↓{gitPanel.behind}
186
+ </text>
187
+ ) : null}
188
+ {statusNode ?? (
189
+ <scrollbox
190
+ flexGrow={1}
191
+ scrollY
192
+ viewportCulling
193
+ contentOptions={{ flexDirection: 'column', gap: 0 }}
194
+ >
195
+ {SECTION_ORDER.map((s) => renderSection(s.key, s.title, groups[s.key], addedW, removedW))}
196
+ </scrollbox>
197
+ )}
198
+ </box>
199
+ )
200
+ })
@@ -0,0 +1,79 @@
1
+ import { theme } from '../theme'
2
+ import { uiTokens } from '../ui-tokens'
3
+ import { ModalShell } from './modal-shell'
4
+
5
+ const SECTIONS = [
6
+ {
7
+ bindings: [
8
+ ['j / k', 'Move between tabs'],
9
+ ['Shift+J / K', 'Reorder tabs'],
10
+ ['i', 'Focus terminal input'],
11
+ ['r', 'Rename tab'],
12
+ ['?', 'Show this help'],
13
+ ],
14
+ title: 'Navigation',
15
+ },
16
+ {
17
+ bindings: [
18
+ ['Ctrl+n', 'New tab'],
19
+ ['Ctrl+w', 'Close tab'],
20
+ ['Ctrl+r', 'Restart tab'],
21
+ ['Ctrl+g', 'Session picker'],
22
+ ['Ctrl+s', 'Snippet picker'],
23
+ ['Ctrl+t', 'Theme picker'],
24
+ ],
25
+ title: 'Tabs & Sessions',
26
+ },
27
+ {
28
+ bindings: [
29
+ ['Ctrl+b', 'Toggle sidebar'],
30
+ ['Ctrl+h / l', 'Resize sidebar'],
31
+ ['Shift+G', 'Toggle git panel'],
32
+ ['Ctrl+j / k', 'Resize git panel'],
33
+ ],
34
+ title: 'Sidebar',
35
+ },
36
+ {
37
+ bindings: [
38
+ ['Ctrl+z', 'Back to navigation'],
39
+ ['Ctrl+w', 'Enter layout mode'],
40
+ ['Ctrl+b', 'Toggle sidebar'],
41
+ ],
42
+ title: 'Terminal Input',
43
+ },
44
+ {
45
+ bindings: [
46
+ ['|', 'Split vertical'],
47
+ ['-', 'Split horizontal'],
48
+ ['h / j / k / l', 'Focus pane'],
49
+ ['Shift+H/J/K/L', 'Resize pane'],
50
+ ['q', 'Close pane'],
51
+ ['Esc', 'Cancel'],
52
+ ],
53
+ title: 'Layout Mode (Ctrl+w)',
54
+ },
55
+ {
56
+ bindings: [['Ctrl+c', 'Quit']],
57
+ title: 'General',
58
+ },
59
+ ] as const
60
+
61
+ export function HelpModal() {
62
+ return (
63
+ <ModalShell title="Keybindings" help="Press Esc to close." width={uiTokens.modalWidth.lg}>
64
+ {SECTIONS.map((section) => (
65
+ <box key={section.title} flexDirection="column">
66
+ <text fg={theme.text}>{section.title}</text>
67
+ {section.bindings.map(([key, desc]) => (
68
+ <box key={key} flexDirection="row">
69
+ <box width={18}>
70
+ <text fg={theme.accentAlt}> {key}</text>
71
+ </box>
72
+ <text fg={theme.textMuted}>{desc}</text>
73
+ </box>
74
+ ))}
75
+ </box>
76
+ ))}
77
+ </ModalShell>
78
+ )
79
+ }
@@ -0,0 +1,18 @@
1
+ import { theme } from '../theme'
2
+ import { Surface } from './surface'
3
+
4
+ interface InputFieldProps {
5
+ active: boolean
6
+ value: string
7
+ }
8
+
9
+ export function InputField({ active, value }: InputFieldProps) {
10
+ return (
11
+ <Surface tone={active ? 'inputActive' : 'input'} padding={1}>
12
+ <text fg={active ? theme.text : theme.textMuted}>
13
+ {value}
14
+ {active ? '_' : ''}
15
+ </text>
16
+ </Surface>
17
+ )
18
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { theme } from '../theme'
4
+ import { Surface } from './surface'
5
+
6
+ interface ListItemProps {
7
+ active: boolean
8
+ leading?: ReactNode
9
+ subtitle?: ReactNode
10
+ title: ReactNode
11
+ trailing?: ReactNode
12
+ }
13
+
14
+ export function ListItem({ active, leading, subtitle, title, trailing }: ListItemProps) {
15
+ return (
16
+ <Surface tone={active ? 'selected' : 'elevated'} paddingLeft={1} paddingRight={1}>
17
+ <box flexDirection="column">
18
+ <box flexDirection="row">
19
+ <text fg={active ? theme.accent : theme.dim}>{active ? '›' : '·'}</text>
20
+ <text> </text>
21
+ {leading}
22
+ {leading ? <text> </text> : null}
23
+ <box flexGrow={1}>{title}</box>
24
+ {trailing ? <box>{trailing}</box> : null}
25
+ </box>
26
+ {subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
27
+ </box>
28
+ </Surface>
29
+ )
30
+ }
@@ -0,0 +1,18 @@
1
+ import { theme } from '../theme'
2
+ import { Surface } from './surface'
3
+
4
+ interface ModalFilterBarProps {
5
+ filter: string | null
6
+ }
7
+
8
+ export function ModalFilterBar({ filter }: ModalFilterBarProps) {
9
+ if (filter === null) {
10
+ return null
11
+ }
12
+
13
+ return (
14
+ <Surface tone="input" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1}>
15
+ <text fg={theme.text}>/{filter}_</text>
16
+ </Surface>
17
+ )
18
+ }
@@ -0,0 +1,47 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { theme } from '../theme'
4
+ import { Surface } from './surface'
5
+
6
+ interface ModalShellProps {
7
+ children: ReactNode
8
+ footer?: ReactNode
9
+ listGap?: number
10
+ help?: string
11
+ title: string
12
+ width: number | `${number}%`
13
+ }
14
+
15
+ export function ModalShell({ children, footer, help, listGap = 1, title, width }: ModalShellProps) {
16
+ return (
17
+ <box
18
+ position="absolute"
19
+ top={0}
20
+ left={0}
21
+ width="100%"
22
+ height="100%"
23
+ justifyContent="center"
24
+ alignItems="center"
25
+ >
26
+ <box
27
+ position="absolute"
28
+ top={0}
29
+ left={0}
30
+ width="100%"
31
+ height="100%"
32
+ backgroundColor={theme.overlay}
33
+ opacity={0.7}
34
+ />
35
+ <Surface tone="elevated" padding={1} gap={1} width={width}>
36
+ <box width="100%" flexDirection="column" gap={listGap}>
37
+ <box flexDirection="column">
38
+ <text fg={theme.accentAlt}>{title}</text>
39
+ {help ? <text fg={theme.textMuted}>{help}</text> : null}
40
+ </box>
41
+ {children}
42
+ {footer ? <box>{footer}</box> : null}
43
+ </box>
44
+ </Surface>
45
+ </box>
46
+ )
47
+ }
@@ -0,0 +1,52 @@
1
+ import { getAllAssistantOptions } from '../../pty/command-registry'
2
+ import { theme } from '../theme'
3
+ import { uiTokens } from '../ui-tokens'
4
+ import { InputField } from './input-field'
5
+ import { ListItem } from './list-item'
6
+ import { ModalShell } from './modal-shell'
7
+
8
+ interface NewTabModalProps {
9
+ selectedIndex: number
10
+ customCommands: Record<string, string>
11
+ editBuffer: string | null
12
+ }
13
+
14
+ export function NewTabModal({ customCommands, editBuffer, selectedIndex }: NewTabModalProps) {
15
+ const options = getAllAssistantOptions(customCommands)
16
+ const selectedOption = options[selectedIndex]
17
+
18
+ return (
19
+ <ModalShell
20
+ title="New assistant tab"
21
+ help={
22
+ editBuffer !== null
23
+ ? `Editing command for ${selectedOption?.label}. Enter to confirm, Esc to cancel.`
24
+ : 'Use j/k or arrows, Enter to confirm, e to edit command, Esc to cancel.'
25
+ }
26
+ width={uiTokens.modalWidth.md}
27
+ >
28
+ {editBuffer !== null ? (
29
+ <InputField active value={editBuffer} />
30
+ ) : (
31
+ options.map((option, index) => {
32
+ const active = index === selectedIndex
33
+ const customCmd = customCommands[option.id]
34
+
35
+ return (
36
+ <ListItem
37
+ key={option.id}
38
+ active={active}
39
+ title={<text fg={active ? theme.text : theme.textMuted}>{option.label}</text>}
40
+ subtitle={
41
+ <box flexDirection="column">
42
+ <text fg={theme.textMuted}>{option.description}</text>
43
+ {customCmd ? <text fg={theme.accent}>{customCmd}</text> : null}
44
+ </box>
45
+ }
46
+ />
47
+ )
48
+ })
49
+ )}
50
+ </ModalShell>
51
+ )
52
+ }
@@ -0,0 +1,41 @@
1
+ import { useAppStore } from '../../state/app-store'
2
+ import { theme } from '../theme'
3
+
4
+ function formatChord(chord: string): string {
5
+ if (chord === 'space') return '␣'
6
+ if (chord === 'return') return '⏎'
7
+ if (chord === 'escape') return 'Esc'
8
+ if (chord === 'tab') return 'Tab'
9
+ if (chord === 'backspace') return '⌫'
10
+ if (chord === 'up') return '↑'
11
+ if (chord === 'down') return '↓'
12
+ if (chord === 'left') return '←'
13
+ if (chord === 'right') return '→'
14
+ return chord
15
+ }
16
+
17
+ function formatSequence(chords: string[]): string {
18
+ return chords.map(formatChord).join(' ')
19
+ }
20
+
21
+ export function PendingChordIndicator() {
22
+ const pendingChords = useAppStore((s) => s.pendingChords)
23
+
24
+ if (!pendingChords || pendingChords.length === 0) return null
25
+
26
+ const label = formatSequence(pendingChords)
27
+
28
+ return (
29
+ <box
30
+ position="absolute"
31
+ bottom={2}
32
+ right={2}
33
+ paddingLeft={1}
34
+ paddingRight={1}
35
+ backgroundColor={theme.accent}
36
+ borderColor={theme.borderActive}
37
+ >
38
+ <text fg={theme.background}>{label}…</text>
39
+ </box>
40
+ )
41
+ }
@@ -0,0 +1,15 @@
1
+ import { uiTokens } from '../ui-tokens'
2
+ import { InputField } from './input-field'
3
+ import { ModalShell } from './modal-shell'
4
+
5
+ export function SessionNameModal({ title, value }: { title: string; value: string }) {
6
+ return (
7
+ <ModalShell
8
+ title={title}
9
+ help="Type a session name. Enter confirm, Esc cancel."
10
+ width={uiTokens.modalWidth.md}
11
+ >
12
+ <InputField active value={value} />
13
+ </ModalShell>
14
+ )
15
+ }
@@ -0,0 +1,93 @@
1
+ import type { SessionRecord } from '../../state/types'
2
+
3
+ import { filterSessions } from '../../state/selectors'
4
+ import { abbreviatePath } from '../path-format'
5
+ import { theme } from '../theme'
6
+ import { uiTokens } from '../ui-tokens'
7
+ import { ListItem } from './list-item'
8
+ import { ModalFilterBar } from './modal-filter-bar'
9
+ import { ModalShell } from './modal-shell'
10
+
11
+ interface SessionPickerModalProps {
12
+ sessions: SessionRecord[]
13
+ selectedIndex: number
14
+ currentSessionId: string | null
15
+ currentTabCount: number
16
+ filter: string | null
17
+ }
18
+
19
+ function formatSessionLine(
20
+ session: SessionRecord,
21
+ currentSessionId: string | null,
22
+ currentTabCount: number
23
+ ): string {
24
+ const tabCount =
25
+ session.id === currentSessionId
26
+ ? currentTabCount
27
+ : (session.workspaceSnapshot?.tabs.length ?? 0)
28
+ return `${session.name} (${tabCount} tab${tabCount === 1 ? '' : 's'})`
29
+ }
30
+
31
+ function getEmptyStateMessage(hasFilter: boolean): string {
32
+ if (hasFilter) {
33
+ return 'No matching sessions.'
34
+ }
35
+
36
+ return 'No sessions yet. Press Enter or n to create your first session.'
37
+ }
38
+
39
+ export function SessionPickerModal({
40
+ currentSessionId,
41
+ currentTabCount,
42
+ filter,
43
+ selectedIndex,
44
+ sessions,
45
+ }: SessionPickerModalProps) {
46
+ const filtered = filterSessions(sessions, filter)
47
+ const hasFilter = !!filter
48
+ const showFilteredEmptyState = filtered.length === 0 && sessions.length > 0
49
+ const showInitialEmptyState = filtered.length === 0 && sessions.length === 0
50
+
51
+ return (
52
+ <ModalShell
53
+ title="Sessions"
54
+ help="j/k move, Enter resume, n new, r rename, d delete, / filter, Esc cancel."
55
+ width={uiTokens.modalWidth.lg}
56
+ footer={<ModalFilterBar filter={filter} />}
57
+ >
58
+ {showFilteredEmptyState ? (
59
+ <text fg={theme.textMuted}>{getEmptyStateMessage(hasFilter)}</text>
60
+ ) : null}
61
+ {showInitialEmptyState ? (
62
+ <text fg={theme.textMuted}>{getEmptyStateMessage(false)}</text>
63
+ ) : null}
64
+ {filtered.map((session, index) => {
65
+ const active = index === selectedIndex
66
+ return (
67
+ <ListItem
68
+ key={session.id}
69
+ active={active}
70
+ title={
71
+ <text fg={active ? theme.text : theme.textMuted}>
72
+ {formatSessionLine(session, currentSessionId, currentTabCount)}
73
+ </text>
74
+ }
75
+ subtitle={
76
+ session.projectPath ? (
77
+ <text fg={theme.textMuted}>{abbreviatePath(session.projectPath)}</text>
78
+ ) : undefined
79
+ }
80
+ />
81
+ )
82
+ })}
83
+ <ListItem
84
+ active={selectedIndex === filtered.length}
85
+ title={
86
+ <text fg={selectedIndex === filtered.length ? theme.text : theme.textMuted}>
87
+ Create new session
88
+ </text>
89
+ }
90
+ />
91
+ </ModalShell>
92
+ )
93
+ }
@@ -0,0 +1,44 @@
1
+ import type { TabSession } from '../../state/types'
2
+
3
+ import { allLeafIds, type LayoutNode } from '../../state/layout-tree'
4
+
5
+ export interface TabGroupInfo {
6
+ inLayout: boolean
7
+ groupStart: number
8
+ groupEnd: number
9
+ }
10
+
11
+ export function buildTabGroupInfo(
12
+ layoutTrees: Record<string, LayoutNode>,
13
+ tabs: TabSession[]
14
+ ): Map<string, TabGroupInfo> {
15
+ const tabGroupInfo = new Map<string, TabGroupInfo>()
16
+
17
+ for (const tree of Object.values(layoutTrees)) {
18
+ const ids = allLeafIds(tree)
19
+ if (ids.length <= 1) {
20
+ continue
21
+ }
22
+
23
+ const idSet = new Set(ids)
24
+ let groupStart = -1
25
+ let groupEnd = -1
26
+
27
+ for (const [index, tab] of tabs.entries()) {
28
+ if (!idSet.has(tab.id)) {
29
+ continue
30
+ }
31
+
32
+ if (groupStart === -1) {
33
+ groupStart = index
34
+ }
35
+ groupEnd = index
36
+ }
37
+
38
+ for (const id of ids) {
39
+ tabGroupInfo.set(id, { groupEnd, groupStart, inLayout: true })
40
+ }
41
+ }
42
+
43
+ return tabGroupInfo
44
+ }
@@ -0,0 +1,33 @@
1
+ export type SidebarScrollTarget = 'none' | 'active-item' | 'top' | 'bottom'
2
+
3
+ interface SidebarScrollTargetOptions {
4
+ previousActiveIndex: number
5
+ nextActiveIndex: number
6
+ tabCount: number
7
+ }
8
+
9
+ export function getSidebarScrollTarget({
10
+ nextActiveIndex,
11
+ previousActiveIndex,
12
+ tabCount,
13
+ }: SidebarScrollTargetOptions): SidebarScrollTarget {
14
+ if (tabCount === 0 || nextActiveIndex < 0) {
15
+ return 'none'
16
+ }
17
+
18
+ if (previousActiveIndex < 0 || previousActiveIndex === nextActiveIndex) {
19
+ return previousActiveIndex < 0 ? 'active-item' : 'none'
20
+ }
21
+
22
+ const lastIndex = tabCount - 1
23
+
24
+ if (previousActiveIndex === lastIndex && nextActiveIndex === 0) {
25
+ return 'top'
26
+ }
27
+
28
+ if (previousActiveIndex === 0 && nextActiveIndex === lastIndex) {
29
+ return 'bottom'
30
+ }
31
+
32
+ return 'active-item'
33
+ }