@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,225 @@
1
+ import type { ScrollBoxRenderable } from '@opentui/core'
2
+
3
+ import { memo, useMemo, useRef } from 'react'
4
+
5
+ import type { GitPanelState } from '../../state/types'
6
+
7
+ import { useGitPanelPolling } from '../../git/git-poller'
8
+ import { useAppStore } from '../../state/app-store'
9
+ import { theme } from '../theme'
10
+ import { GitPanel } from './git-panel'
11
+ import { buildTabGroupInfo } from './sidebar-group-metadata'
12
+ import { TabItem } from './tab-item'
13
+ import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
14
+ import { useSidebarBranch } from './use-sidebar-branch'
15
+
16
+ interface SidebarProps {
17
+ onTabActivate?: (tabId: string) => void
18
+ }
19
+
20
+ const GUTTER_START = '╭'
21
+ const GUTTER_MIDDLE = '├'
22
+ const GUTTER_END = '╰'
23
+ const GUTTER_PAD = '│'
24
+
25
+ const SidebarTop = memo(function SidebarTop() {
26
+ const sidebarWidth = useAppStore((s) => s.sidebar.width)
27
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
28
+ const sessions = useAppStore((s) => s.sessions)
29
+ const currentSession = currentSessionId
30
+ ? sessions.find((s) => s.id === currentSessionId)
31
+ : undefined
32
+ const projectPath = currentSession?.projectPath
33
+ const branch = useSidebarBranch(projectPath)
34
+
35
+ return (
36
+ <box flexDirection="column" flexShrink={0} gap={0}>
37
+ <text fg={theme.accent}>
38
+ <strong>aimux</strong>
39
+ </text>
40
+ <text fg={theme.accentAlt}>
41
+ {currentSession ? currentSession.name : 'No session selected'}
42
+ </text>
43
+ {branch ? (
44
+ <box flexDirection="row">
45
+ <text fg={theme.accent}>{'\u{e702}'} </text>
46
+ <text fg={theme.textMuted}>{branch}</text>
47
+ </box>
48
+ ) : null}
49
+ <text fg={theme.dim}>{'·'.repeat(Math.max(0, sidebarWidth - 2))}</text>
50
+ </box>
51
+ )
52
+ })
53
+
54
+ function renderGroupGutter(
55
+ isGroupStart: boolean,
56
+ isGroupMiddle: boolean,
57
+ isGroupEnd: boolean,
58
+ isActive: boolean
59
+ ) {
60
+ return (
61
+ <box flexDirection="column" width={1} overflow="hidden">
62
+ <text fg={theme.accentAlt} bg={isActive ? theme.panelHighlight : undefined}>
63
+ {/* oxlint-disable-next-line no-nested-ternary */}
64
+ {isGroupStart ? GUTTER_START : isGroupMiddle ? GUTTER_MIDDLE : GUTTER_PAD}
65
+ </text>
66
+ <text fg={theme.accentAlt} bg={isActive ? theme.panelHighlight : undefined}>
67
+ {isGroupEnd ? GUTTER_END : GUTTER_PAD}
68
+ </text>
69
+ </box>
70
+ )
71
+ }
72
+
73
+ interface TabsBodyProps {
74
+ onTabActivate?: (tabId: string) => void
75
+ }
76
+
77
+ const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
78
+ const tabs = useAppStore((s) => s.tabs)
79
+ const activeTabId = useAppStore((s) => s.activeTabId)
80
+ const focusMode = useAppStore((s) => s.focusMode)
81
+ const layoutTrees = useAppStore((s) => s.layoutTrees)
82
+ const sidebarVisible = useAppStore((s) => s.sidebar.visible)
83
+
84
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
85
+ const activeIndex = tabs.findIndex((tab) => tab.id === activeTabId)
86
+ const tabGroupInfo = useMemo(() => buildTabGroupInfo(layoutTrees, tabs), [layoutTrees, tabs])
87
+
88
+ useSidebarAutoScroll({
89
+ activeIndex,
90
+ activeTabId,
91
+ scrollRef,
92
+ tabCount: tabs.length,
93
+ visible: sidebarVisible,
94
+ })
95
+
96
+ return (
97
+ <scrollbox
98
+ paddingTop={0}
99
+ ref={scrollRef}
100
+ flexGrow={1}
101
+ scrollY
102
+ viewportCulling
103
+ contentOptions={{ flexDirection: 'column', gap: 0 }}
104
+ >
105
+ {tabs.length === 0 ? (
106
+ <box paddingTop={1}>
107
+ <text fg={theme.textMuted}>No tabs yet. Press Ctrl+n.</text>
108
+ </box>
109
+ ) : (
110
+ tabs.map((tab, index) => {
111
+ const isActive = tab.id === activeTabId
112
+ const info = tabGroupInfo.get(tab.id)
113
+ const inLayout = !!info?.inLayout
114
+ const inGroup = info ? index >= info.groupStart && index <= info.groupEnd : false
115
+ const isGroupStart = info ? index === info.groupStart : false
116
+ const isGroupEnd = info ? index === info.groupEnd : false
117
+ const isGroupMiddle = inGroup && !isGroupStart && !isGroupEnd
118
+
119
+ return (
120
+ <box
121
+ key={tab.id}
122
+ flexDirection="row"
123
+ onMouseDown={onTabActivate ? () => onTabActivate(tab.id) : undefined}
124
+ >
125
+ {inGroup
126
+ ? renderGroupGutter(isGroupStart, isGroupMiddle, isGroupEnd, isActive)
127
+ : null}
128
+ <box flexGrow={1}>
129
+ <TabItem
130
+ id={`sidebar-tab-${tab.id}`}
131
+ tab={tab}
132
+ active={isActive}
133
+ focused={focusMode === 'navigation'}
134
+ isFocusedInput={isActive && focusMode === 'terminal-input'}
135
+ inLayout={inLayout}
136
+ />
137
+ </box>
138
+ </box>
139
+ )
140
+ })
141
+ )}
142
+ </scrollbox>
143
+ )
144
+ })
145
+
146
+ const GitBody = memo(function GitBody() {
147
+ const gitPanel = useAppStore((s) => s.gitPanel)
148
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
149
+ const sessions = useAppStore((s) => s.sessions)
150
+ const sidebarVisible = useAppStore((s) => s.sidebar.visible)
151
+ const gitPanelVisible = useAppStore((s) => s.sidebar.gitPanelVisible)
152
+ const currentSession = currentSessionId
153
+ ? sessions.find((s) => s.id === currentSessionId)
154
+ : undefined
155
+ const projectPath = currentSession?.projectPath
156
+
157
+ useGitPanelPolling({
158
+ enabled: sidebarVisible && gitPanelVisible,
159
+ projectPath,
160
+ })
161
+
162
+ const lastGoodRef = useRef<GitPanelState | null>(null)
163
+ const prevProjectPathRef = useRef(projectPath)
164
+ if (prevProjectPathRef.current !== projectPath) {
165
+ prevProjectPathRef.current = projectPath
166
+ lastGoodRef.current = null
167
+ }
168
+ const isGood = gitPanel.error === null && gitPanel.branch !== null
169
+ if (isGood) {
170
+ lastGoodRef.current = gitPanel
171
+ }
172
+ const display = lastGoodRef.current ?? gitPanel
173
+
174
+ return <GitPanel gitPanel={display} projectPath={projectPath} />
175
+ })
176
+
177
+ export function Sidebar({ onTabActivate }: SidebarProps) {
178
+ const sidebarVisible = useAppStore((s) => s.sidebar.visible)
179
+ const sidebarWidth = useAppStore((s) => s.sidebar.width)
180
+ const gitPanelVisible = useAppStore((s) => s.sidebar.gitPanelVisible)
181
+ const gitPanelRatio = useAppStore((s) => s.sidebar.gitPanelRatio)
182
+
183
+ if (!sidebarVisible) {
184
+ return null
185
+ }
186
+
187
+ // flex-grow scaled by 100 (integer preferred); tabs gets (1-ratio), git gets ratio.
188
+ const tabsGrow = Math.max(1, Math.round((1 - gitPanelRatio) * 100))
189
+ const gitGrow = Math.max(1, Math.round(gitPanelRatio * 100))
190
+
191
+ return (
192
+ <box
193
+ width={sidebarWidth}
194
+ padding={0}
195
+ flexDirection="column"
196
+ backgroundColor={theme.panel}
197
+ gap={0}
198
+ >
199
+ <SidebarTop />
200
+ <box
201
+ flexDirection="column"
202
+ flexGrow={tabsGrow}
203
+ flexShrink={1}
204
+ flexBasis={0}
205
+ overflow="hidden"
206
+ >
207
+ <TabsBody onTabActivate={onTabActivate} />
208
+ </box>
209
+ {gitPanelVisible ? (
210
+ <>
211
+ <text fg={theme.dim}>{'·'.repeat(Math.max(0, sidebarWidth - 2))}</text>
212
+ <box
213
+ flexDirection="column"
214
+ flexGrow={gitGrow}
215
+ flexShrink={1}
216
+ flexBasis={0}
217
+ overflow="hidden"
218
+ >
219
+ <GitBody />
220
+ </box>
221
+ </>
222
+ ) : null}
223
+ </box>
224
+ )
225
+ }
@@ -0,0 +1,39 @@
1
+ import { theme } from '../theme'
2
+ import { uiTokens } from '../ui-tokens'
3
+ import { InputField } from './input-field'
4
+ import { ModalShell } from './modal-shell'
5
+
6
+ interface SnippetEditorModalProps {
7
+ activeField: 'name' | 'content'
8
+ snippetName: string
9
+ snippetContent: string
10
+ isEditing: boolean
11
+ }
12
+
13
+ export function SnippetEditorModal({
14
+ activeField,
15
+ isEditing,
16
+ snippetContent,
17
+ snippetName,
18
+ }: SnippetEditorModalProps) {
19
+ const nameActive = activeField === 'name'
20
+ const contentActive = activeField === 'content'
21
+
22
+ return (
23
+ <ModalShell
24
+ title={isEditing ? 'Edit snippet' : 'Create snippet'}
25
+ help="Tab switch field. Enter on content to save. Esc cancel."
26
+ width={uiTokens.modalWidth.xl}
27
+ >
28
+ <box flexDirection="column">
29
+ <text fg={nameActive ? theme.text : theme.textMuted}>Name</text>
30
+ <InputField active={nameActive} value={snippetName} />
31
+ </box>
32
+
33
+ <box flexDirection="column">
34
+ <text fg={contentActive ? theme.text : theme.textMuted}>Content</text>
35
+ <InputField active={contentActive} value={snippetContent} />
36
+ </box>
37
+ </ModalShell>
38
+ )
39
+ }
@@ -0,0 +1,56 @@
1
+ import type { SnippetRecord } from '../../state/types'
2
+
3
+ import { filterSnippets } from '../../state/selectors'
4
+ import { theme } from '../theme'
5
+ import { uiTokens } from '../ui-tokens'
6
+ import { ListItem } from './list-item'
7
+ import { ModalFilterBar } from './modal-filter-bar'
8
+ import { ModalShell } from './modal-shell'
9
+
10
+ interface SnippetPickerModalProps {
11
+ snippets: SnippetRecord[]
12
+ selectedIndex: number
13
+ filter: string | null
14
+ }
15
+
16
+ const MAX_PREVIEW_LENGTH = 60
17
+
18
+ function truncateContent(content: string): string {
19
+ const normalized = content.replaceAll(/\s+/g, ' ').trim()
20
+ if (normalized.length <= MAX_PREVIEW_LENGTH) return normalized
21
+ return `${normalized.slice(0, MAX_PREVIEW_LENGTH - 3)}...`
22
+ }
23
+
24
+ export function SnippetPickerModal({ filter, selectedIndex, snippets }: SnippetPickerModalProps) {
25
+ const filtered = filterSnippets(snippets, filter)
26
+
27
+ return (
28
+ <ModalShell
29
+ title="Snippets"
30
+ help="j/k move, Enter send, n new, e edit, d delete, / filter, Esc cancel."
31
+ width={uiTokens.modalWidth.xl}
32
+ footer={<ModalFilterBar filter={filter} />}
33
+ >
34
+ {filtered.length === 0 ? (
35
+ <text fg={theme.textMuted}>
36
+ {filter ? 'No matching snippets.' : 'No snippets yet. Press n to create one.'}
37
+ </text>
38
+ ) : null}
39
+ {filtered.map((snippet, index) => {
40
+ const active = index === selectedIndex
41
+ return (
42
+ <ListItem
43
+ key={snippet.id}
44
+ active={active}
45
+ title={
46
+ <text fg={active ? theme.text : theme.textMuted}>
47
+ <strong>{snippet.name}</strong>
48
+ </text>
49
+ }
50
+ subtitle={<text fg={theme.textMuted}>{truncateContent(snippet.content)}</text>}
51
+ />
52
+ )
53
+ })}
54
+ </ModalShell>
55
+ )
56
+ }
@@ -0,0 +1,201 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import type { TerminalContentOrigin } from '../../input/raw-input-handler'
4
+ import type { FocusMode, TabSession } from '../../state/types'
5
+
6
+ import { logInputDebug } from '../../debug/input-log'
7
+ import {
8
+ computePaneRects,
9
+ type LayoutNode,
10
+ PANE_BORDER,
11
+ type PaneRect,
12
+ type SplitDirection,
13
+ } from '../../state/layout-tree'
14
+ import { theme } from '../theme'
15
+ import { TerminalPane } from './terminal-pane'
16
+
17
+ const PANE_CHROME = PANE_BORDER
18
+
19
+ interface SplitLayoutProps {
20
+ node: LayoutNode
21
+ tabs: TabSession[]
22
+ activeTabId: string | null
23
+ focusMode: FocusMode
24
+ mouseForwardingEnabled: boolean
25
+ localScrollbackEnabled: boolean
26
+ onTerminalMouseEvent: (event: OtuiMouseEvent, origin: TerminalContentOrigin) => void
27
+ onTerminalScrollEvent: (event: OtuiMouseEvent) => void
28
+ onTerminalClick?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
29
+ onPaneActivate?: (tabId: string) => void
30
+ onSplitResize?: (tabId: string, ratio: number, axis: SplitDirection) => void
31
+ onSeparatorDragStart?: (info: {
32
+ tabId: string
33
+ direction: SplitDirection
34
+ screenStart: number
35
+ totalSize: number
36
+ }) => void
37
+ onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
38
+ onSeparatorDragEnd?: () => void
39
+ contentOrigin: TerminalContentOrigin
40
+ bounds: PaneRect
41
+ }
42
+
43
+ export function SplitLayout({
44
+ activeTabId,
45
+ bounds,
46
+ contentOrigin,
47
+ focusMode,
48
+ localScrollbackEnabled,
49
+ mouseForwardingEnabled,
50
+ node,
51
+ onPaneActivate,
52
+ onSeparatorDrag,
53
+ onSeparatorDragEnd,
54
+ onSeparatorDragStart,
55
+ onSplitResize,
56
+ onTerminalClick,
57
+ onTerminalMouseEvent,
58
+ onTerminalScrollEvent,
59
+ tabs,
60
+ }: SplitLayoutProps) {
61
+ if (node.type === 'leaf') {
62
+ const tab = tabs.find((t) => t.id === node.tabId)
63
+ const isActive = node.tabId === activeTabId
64
+ const paneOrigin: TerminalContentOrigin = {
65
+ cols: Math.max(1, bounds.cols - PANE_CHROME * 2),
66
+ rows: Math.max(1, bounds.rows - PANE_CHROME * 2),
67
+ x: contentOrigin.x + bounds.x + PANE_CHROME,
68
+ y: contentOrigin.y + bounds.y + PANE_CHROME,
69
+ }
70
+ logInputDebug('split.paneOrigin', {
71
+ boundsCols: bounds.cols,
72
+ boundsRows: bounds.rows,
73
+ boundsX: bounds.x,
74
+ boundsY: bounds.y,
75
+ contentOriginX: contentOrigin.x,
76
+ contentOriginY: contentOrigin.y,
77
+ paneChrome: PANE_CHROME,
78
+ paneOriginX: paneOrigin.x,
79
+ paneOriginY: paneOrigin.y,
80
+ tabId: node.tabId,
81
+ })
82
+ return (
83
+ <TerminalPane
84
+ tab={tab}
85
+ tabId={node.tabId}
86
+ focusMode={focusMode}
87
+ isActive={isActive}
88
+ contentOrigin={paneOrigin}
89
+ mouseForwardingEnabled={isActive && mouseForwardingEnabled}
90
+ localScrollbackEnabled={isActive && localScrollbackEnabled}
91
+ onTerminalMouseEvent={onTerminalMouseEvent}
92
+ onTerminalScrollEvent={onTerminalScrollEvent}
93
+ onTerminalClick={onTerminalClick}
94
+ onPaneActivate={onPaneActivate}
95
+ onSeparatorDrag={onSeparatorDrag}
96
+ onSeparatorDragEnd={onSeparatorDragEnd}
97
+ />
98
+ )
99
+ }
100
+
101
+ const flexDir = node.direction === 'vertical' ? 'row' : 'column'
102
+ const firstGrow = Math.round(node.ratio * 100)
103
+ const secondGrow = 100 - firstGrow
104
+
105
+ // Compute sub-bounds for each child
106
+ const rects = computePaneRects(node, bounds)
107
+
108
+ // Compute the bounding rect for each subtree
109
+ const firstBounds = subtreeBounds(node.first, rects, bounds)
110
+ const secondBounds = subtreeBounds(node.second, rects, bounds)
111
+
112
+ return (
113
+ <box flexDirection={flexDir} flexGrow={1} gap={0}>
114
+ <box flexGrow={firstGrow} flexDirection="column" overflow="hidden">
115
+ <SplitLayout
116
+ node={node.first}
117
+ tabs={tabs}
118
+ activeTabId={activeTabId}
119
+ focusMode={focusMode}
120
+ mouseForwardingEnabled={mouseForwardingEnabled}
121
+ localScrollbackEnabled={localScrollbackEnabled}
122
+ onTerminalMouseEvent={onTerminalMouseEvent}
123
+ onTerminalScrollEvent={onTerminalScrollEvent}
124
+ onTerminalClick={onTerminalClick}
125
+ onPaneActivate={onPaneActivate}
126
+ onSplitResize={onSplitResize}
127
+ onSeparatorDragStart={onSeparatorDragStart}
128
+ onSeparatorDrag={onSeparatorDrag}
129
+ onSeparatorDragEnd={onSeparatorDragEnd}
130
+ contentOrigin={contentOrigin}
131
+ bounds={firstBounds}
132
+ />
133
+ </box>
134
+ <box
135
+ minWidth={node.direction === 'vertical' ? 1 : undefined}
136
+ minHeight={node.direction === 'horizontal' ? 1 : undefined}
137
+ backgroundColor={theme.border}
138
+ onMouseDown={(e: OtuiMouseEvent) => {
139
+ e.preventDefault()
140
+ if (!onSeparatorDragStart) return
141
+ const leafId = getFirstLeafId(node.first)
142
+ if (!leafId) return
143
+ onSeparatorDragStart({
144
+ direction: node.direction,
145
+ screenStart:
146
+ node.direction === 'vertical'
147
+ ? contentOrigin.x + bounds.x
148
+ : contentOrigin.y + bounds.y,
149
+ tabId: leafId,
150
+ totalSize: node.direction === 'vertical' ? bounds.cols : bounds.rows,
151
+ })
152
+ }}
153
+ />
154
+ <box flexGrow={secondGrow} flexDirection="column" overflow="hidden">
155
+ <SplitLayout
156
+ node={node.second}
157
+ tabs={tabs}
158
+ activeTabId={activeTabId}
159
+ focusMode={focusMode}
160
+ mouseForwardingEnabled={mouseForwardingEnabled}
161
+ localScrollbackEnabled={localScrollbackEnabled}
162
+ onTerminalMouseEvent={onTerminalMouseEvent}
163
+ onTerminalScrollEvent={onTerminalScrollEvent}
164
+ onTerminalClick={onTerminalClick}
165
+ onPaneActivate={onPaneActivate}
166
+ onSplitResize={onSplitResize}
167
+ onSeparatorDragStart={onSeparatorDragStart}
168
+ onSeparatorDrag={onSeparatorDrag}
169
+ onSeparatorDragEnd={onSeparatorDragEnd}
170
+ contentOrigin={contentOrigin}
171
+ bounds={secondBounds}
172
+ />
173
+ </box>
174
+ </box>
175
+ )
176
+ }
177
+
178
+ function getFirstLeafId(node: LayoutNode): string | null {
179
+ if (node.type === 'leaf') return node.tabId
180
+ return getFirstLeafId(node.first)
181
+ }
182
+
183
+ function subtreeBounds(
184
+ node: LayoutNode,
185
+ rects: Map<string, PaneRect>,
186
+ fallback: PaneRect
187
+ ): PaneRect {
188
+ if (node.type === 'leaf') {
189
+ return rects.get(node.tabId) ?? fallback
190
+ }
191
+ const firstBounds = subtreeBounds(node.first, rects, fallback)
192
+ const lastBounds = subtreeBounds(node.second, rects, fallback)
193
+ const x = Math.min(firstBounds.x, lastBounds.x)
194
+ const y = Math.min(firstBounds.y, lastBounds.y)
195
+ return {
196
+ cols: Math.max(firstBounds.x + firstBounds.cols, lastBounds.x + lastBounds.cols) - x,
197
+ rows: Math.max(firstBounds.y + firstBounds.rows, lastBounds.y + lastBounds.rows) - y,
198
+ x,
199
+ y,
200
+ }
201
+ }
@@ -0,0 +1,60 @@
1
+ import type { AppState } from '../../state/types'
2
+
3
+ import { useAppStore } from '../../state/app-store'
4
+ import { getStatusBarModel } from '../status-bar-model'
5
+ import { theme } from '../theme'
6
+
7
+ function getModeColor(focusMode: AppState['focusMode']): string {
8
+ switch (focusMode) {
9
+ case 'terminal-input':
10
+ return theme.accent
11
+ case 'layout':
12
+ return theme.warning
13
+ case 'modal':
14
+ return theme.warning
15
+ case 'navigation':
16
+ default:
17
+ return theme.accentAlt
18
+ }
19
+ }
20
+
21
+ function getModeLabel(focusMode: AppState['focusMode']): string {
22
+ switch (focusMode) {
23
+ case 'terminal-input':
24
+ return 'input'
25
+ case 'layout':
26
+ return 'layout'
27
+ case 'modal':
28
+ return 'modal'
29
+ case 'navigation':
30
+ default:
31
+ return 'nav'
32
+ }
33
+ }
34
+
35
+ export function StatusBar() {
36
+ const state = useAppStore((s) => s)
37
+ const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
38
+ const model = getStatusBarModel(state, activeTab)
39
+
40
+ return (
41
+ <box
42
+ minHeight={2}
43
+ paddingLeft={1}
44
+ paddingRight={1}
45
+ paddingTop={0}
46
+ paddingBottom={0}
47
+ flexDirection="column"
48
+ backgroundColor={theme.panelMuted}
49
+ >
50
+ <box width="100%" flexDirection="row">
51
+ <text fg={getModeColor(state.focusMode)}>[{getModeLabel(state.focusMode)}]</text>
52
+ <text> </text>
53
+ <text fg={theme.text}>{model.left}</text>
54
+ </box>
55
+ <box width="100%">
56
+ <text fg={theme.textMuted}>{model.right}</text>
57
+ </box>
58
+ </box>
59
+ )
60
+ }
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { theme } from '../theme'
4
+
5
+ type SurfaceTone = 'muted' | 'elevated' | 'selected' | 'input' | 'inputActive'
6
+
7
+ function getSurfaceColor(tone: SurfaceTone): string {
8
+ switch (tone) {
9
+ case 'elevated':
10
+ return theme.panel
11
+ case 'selected':
12
+ return theme.panelHighlight
13
+ case 'input':
14
+ return theme.background
15
+ case 'inputActive':
16
+ return theme.panelHighlight
17
+ case 'muted':
18
+ default:
19
+ return theme.panelMuted
20
+ }
21
+ }
22
+
23
+ interface SurfaceProps {
24
+ children: ReactNode
25
+ flexDirection?: 'row' | 'column'
26
+ gap?: number
27
+ padding?: number
28
+ paddingLeft?: number
29
+ paddingRight?: number
30
+ paddingTop?: number
31
+ paddingBottom?: number
32
+ tone?: SurfaceTone
33
+ width?: number | `${number}%`
34
+ }
35
+
36
+ export function Surface({
37
+ children,
38
+ flexDirection = 'column',
39
+ gap = 0,
40
+ padding,
41
+ paddingBottom,
42
+ paddingLeft,
43
+ paddingRight,
44
+ paddingTop,
45
+ tone = 'muted',
46
+ width,
47
+ }: SurfaceProps) {
48
+ return (
49
+ <box
50
+ backgroundColor={getSurfaceColor(tone)}
51
+ flexDirection={flexDirection}
52
+ gap={gap}
53
+ padding={padding}
54
+ paddingBottom={paddingBottom}
55
+ paddingLeft={paddingLeft}
56
+ paddingRight={paddingRight}
57
+ paddingTop={paddingTop}
58
+ width={width}
59
+ >
60
+ {children}
61
+ </box>
62
+ )
63
+ }