@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.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +79 -0
- package/src/app-runtime/backend-attach-runtime.ts +135 -0
- package/src/app-runtime/backend-runtime-events.ts +78 -0
- package/src/app-runtime/click-selection-resolver.ts +130 -0
- package/src/app-runtime/pty-write.ts +32 -0
- package/src/app-runtime/render-invalidation.ts +20 -0
- package/src/app-runtime/selection-clipboard.ts +69 -0
- package/src/app-runtime/selection-scroll.ts +143 -0
- package/src/app-runtime/session-actions.ts +170 -0
- package/src/app-runtime/side-effects.ts +434 -0
- package/src/app-runtime/snippet-actions.ts +90 -0
- package/src/app-runtime/split-drag-controller.ts +15 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
- package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
- package/src/app-runtime/use-backend-runtime.ts +99 -0
- package/src/app-runtime/use-directory-search.ts +52 -0
- package/src/app-runtime/use-mouse-handlers.ts +183 -0
- package/src/app-runtime/use-renderer-bindings.ts +163 -0
- package/src/app-runtime/use-terminal-resize.ts +141 -0
- package/src/app-runtime/use-workspace-autosave.ts +39 -0
- package/src/app.tsx +247 -0
- package/src/config/loader.ts +36 -0
- package/src/config.ts +146 -0
- package/src/daemon/daemon.ts +236 -0
- package/src/daemon/runtime-paths.ts +69 -0
- package/src/daemon/session-manager.ts +109 -0
- package/src/daemon/session-registry.ts +207 -0
- package/src/debug/input-log.ts +29 -0
- package/src/doctor.ts +106 -0
- package/src/git/git-poller.ts +49 -0
- package/src/git/git-status.ts +183 -0
- package/src/index.tsx +56 -0
- package/src/input/keymap/build-handlers.ts +34 -0
- package/src/input/keymap/key-chord.ts +201 -0
- package/src/input/keymap/keymap-mode-handler.ts +88 -0
- package/src/input/keymap/sequence-resolver.ts +117 -0
- package/src/input/keymap/trie.ts +103 -0
- package/src/input/modes/bridge.ts +58 -0
- package/src/input/modes/handlers/index.ts +18 -0
- package/src/input/modes/handlers/shared.ts +70 -0
- package/src/input/modes/registry.ts +34 -0
- package/src/input/modes/transitions.ts +37 -0
- package/src/input/modes/types.ts +63 -0
- package/src/input/mouse-forwarding.ts +98 -0
- package/src/input/multi-click-detector.ts +55 -0
- package/src/input/paste.ts +12 -0
- package/src/input/raw-input-handler.ts +191 -0
- package/src/input/terminal-text-extraction.ts +78 -0
- package/src/ipc/protocol.ts +341 -0
- package/src/platform/clipboard.ts +13 -0
- package/src/platform/daemon-control.ts +62 -0
- package/src/platform/id.ts +7 -0
- package/src/platform/project-search.ts +75 -0
- package/src/pty/command-registry.ts +69 -0
- package/src/pty/pty-manager.ts +268 -0
- package/src/pty/terminal-snapshot.ts +210 -0
- package/src/restart-daemon.ts +28 -0
- package/src/session-backend/bootstrap.ts +107 -0
- package/src/session-backend/local-session-backend.ts +164 -0
- package/src/session-backend/remote-session-backend.ts +373 -0
- package/src/session-backend/types.ts +47 -0
- package/src/state/app-store.ts +19 -0
- package/src/state/layout-resize.ts +56 -0
- package/src/state/layout-tree.ts +323 -0
- package/src/state/reducers/git-panel-state.ts +102 -0
- package/src/state/reducers/modal-state.ts +358 -0
- package/src/state/reducers/session-state.ts +86 -0
- package/src/state/reducers/tab-state.ts +531 -0
- package/src/state/reducers/ui-state.ts +29 -0
- package/src/state/selectors.ts +30 -0
- package/src/state/session-catalog.ts +96 -0
- package/src/state/session-persistence.ts +210 -0
- package/src/state/snippet-catalog.ts +90 -0
- package/src/state/store.ts +93 -0
- package/src/state/terminal-modes.ts +11 -0
- package/src/state/types.ts +367 -0
- package/src/state/validation.ts +154 -0
- package/src/state/workspace-save.ts +33 -0
- package/src/ui/components/create-session-modal.tsx +100 -0
- package/src/ui/components/git-panel.tsx +200 -0
- package/src/ui/components/help-modal.tsx +79 -0
- package/src/ui/components/input-field.tsx +18 -0
- package/src/ui/components/list-item.tsx +30 -0
- package/src/ui/components/modal-filter-bar.tsx +18 -0
- package/src/ui/components/modal-shell.tsx +47 -0
- package/src/ui/components/new-tab-modal.tsx +52 -0
- package/src/ui/components/pending-chord-indicator.tsx +41 -0
- package/src/ui/components/session-name-modal.tsx +15 -0
- package/src/ui/components/session-picker-modal.tsx +93 -0
- package/src/ui/components/sidebar-group-metadata.ts +44 -0
- package/src/ui/components/sidebar-scroll.ts +33 -0
- package/src/ui/components/sidebar.tsx +225 -0
- package/src/ui/components/snippet-editor-modal.tsx +39 -0
- package/src/ui/components/snippet-picker-modal.tsx +56 -0
- package/src/ui/components/split-layout.tsx +201 -0
- package/src/ui/components/status-bar.tsx +60 -0
- package/src/ui/components/surface.tsx +63 -0
- package/src/ui/components/tab-item.tsx +118 -0
- package/src/ui/components/terminal-pane.tsx +215 -0
- package/src/ui/components/theme-picker-modal.tsx +35 -0
- package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
- package/src/ui/components/use-sidebar-branch.ts +36 -0
- package/src/ui/directory-search.ts +1 -0
- package/src/ui/git-branch.ts +11 -0
- package/src/ui/path-format.ts +6 -0
- package/src/ui/root.tsx +261 -0
- package/src/ui/status-bar-model.ts +87 -0
- package/src/ui/theme.ts +9 -0
- package/src/ui/themes.ts +255 -0
- package/src/ui/ui-tokens.ts +11 -0
- 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
|
+
}
|