@brimveyn/aimux 1.14.3 → 1.14.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +7 -1
- package/src/app-runtime/use-terminal-resize.ts +32 -8
- package/src/app.tsx +16 -1
- package/src/daemon/daemon.ts +55 -1
- package/src/daemon/runtime-paths.ts +9 -0
- package/src/daemon/session-registry.ts +2 -0
- package/src/index.tsx +14 -0
- package/src/integrations/claude-hook-server.ts +99 -0
- package/src/integrations/claude-hooks-install.ts +199 -0
- package/src/ipc/manager-protocol.ts +33 -2
- package/src/pty/assistant-status-arbiter.ts +94 -0
- package/src/pty/assistant-status-detection-loop.ts +20 -1
- package/src/pty/pty-manager.ts +4 -0
- package/src/pty/terminal-snapshot.ts +43 -66
- package/src/session-backend/local-session-backend.ts +58 -6
- package/src/session-backend/remote-session-backend.ts +10 -3
- package/src/session-backend/types.ts +16 -2
- package/src/state/reducers/session-state.ts +40 -8
- package/src/state/reducers/tab-state.ts +18 -3
- package/src/state/session-worktrees.ts +0 -10
- package/src/state/types.ts +8 -0
- package/src/ui/components/git/pane/git-pane-header.tsx +1 -1
- package/src/ui/components/layout/sidebar/workspace-list.tsx +13 -14
- package/src/ui/components/layout/sidebar/worktree-row.tsx +30 -8
- package/src/ui/components/layout/terminal-pane.tsx +56 -4
- package/src/ui/components/layout/top-tab-bar.tsx +3 -1
- package/src/ui/host-palette.ts +75 -0
|
@@ -3,7 +3,7 @@ import type { AppAction, AppState } from '../types'
|
|
|
3
3
|
import { moveIdToIdPosition, orderSessionsForDisplay } from '../../ui/session-ordering'
|
|
4
4
|
import { filterSessions } from '../selectors'
|
|
5
5
|
import { restoreWorkspaceState } from '../session-persistence'
|
|
6
|
-
import { withActiveWorktree } from '../session-worktrees'
|
|
6
|
+
import { filterTabsForActiveWorktree, withActiveWorktree } from '../session-worktrees'
|
|
7
7
|
|
|
8
8
|
const CLOSED_MODAL = {
|
|
9
9
|
editBuffer: null,
|
|
@@ -18,9 +18,24 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
18
18
|
const snapshot =
|
|
19
19
|
action.workspaceSnapshot ??
|
|
20
20
|
state.sessions.find((entry) => entry.id === action.sessionId)?.workspaceSnapshot
|
|
21
|
+
const restored = restoreWorkspaceState(state, snapshot)
|
|
22
|
+
// The session's activeWorktreeId may have been patched right before
|
|
23
|
+
// this load (e.g. the cross-workspace branch of handleCycleSidebarItem
|
|
24
|
+
// sets it to the worktree the user just clicked). The snapshot's
|
|
25
|
+
// activeTabId still reflects the *last* worktree they were on, so
|
|
26
|
+
// honor the patched worktree by filtering the restored tab list.
|
|
27
|
+
const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
|
|
28
|
+
const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
|
|
29
|
+
const activeTabId =
|
|
30
|
+
restored.activeTabId != null &&
|
|
31
|
+
restored.activeTabId !== '' &&
|
|
32
|
+
visible.some((t) => t.id === restored.activeTabId)
|
|
33
|
+
? restored.activeTabId
|
|
34
|
+
: (visible[0]?.id ?? null)
|
|
21
35
|
return {
|
|
22
36
|
...state,
|
|
23
|
-
...
|
|
37
|
+
...restored,
|
|
38
|
+
activeTabId,
|
|
24
39
|
currentSessionId: action.sessionId,
|
|
25
40
|
focusMode: 'navigation',
|
|
26
41
|
modal: CLOSED_MODAL,
|
|
@@ -175,13 +190,30 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
175
190
|
)
|
|
176
191
|
}),
|
|
177
192
|
}
|
|
178
|
-
case 'set-active-worktree':
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
193
|
+
case 'set-active-worktree': {
|
|
194
|
+
const sessions = state.sessions.map((session) =>
|
|
195
|
+
session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
|
|
196
|
+
)
|
|
197
|
+
// Changing the worktree of a non-current session has no effect on
|
|
198
|
+
// which tab the user is looking at — leave activeTabId alone.
|
|
199
|
+
if (action.sessionId !== state.currentSessionId) {
|
|
200
|
+
return { ...state, sessions }
|
|
201
|
+
}
|
|
202
|
+
// For the current session: if the existing activeTabId belongs to
|
|
203
|
+
// the new worktree, keep it. Otherwise hop to the first tab visible
|
|
204
|
+
// under that worktree (or clear it if the worktree is empty — the
|
|
205
|
+
// pane then renders the "this worktree has no tabs" placeholder).
|
|
206
|
+
const updated = sessions.find((s) => s.id === action.sessionId)
|
|
207
|
+
const visible = filterTabsForActiveWorktree(state.tabs, updated)
|
|
208
|
+
const currentStillVisible =
|
|
209
|
+
state.activeTabId != null &&
|
|
210
|
+
state.activeTabId !== '' &&
|
|
211
|
+
visible.some((t) => t.id === state.activeTabId)
|
|
212
|
+
if (currentStillVisible) {
|
|
213
|
+
return { ...state, sessions }
|
|
184
214
|
}
|
|
215
|
+
return { ...state, activeTabId: visible[0]?.id ?? null, sessions }
|
|
216
|
+
}
|
|
185
217
|
case 'update-worktree-record':
|
|
186
218
|
return {
|
|
187
219
|
...state,
|
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
splitNode,
|
|
16
16
|
} from '../layout-tree'
|
|
17
17
|
import { normalizeGroupedTabOrder } from '../session-persistence'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
filterTabsForActiveWorktree,
|
|
20
|
+
orderTabsByWorktree,
|
|
21
|
+
withActiveWorktree,
|
|
22
|
+
} from '../session-worktrees'
|
|
19
23
|
import { createDefaultTerminalModes } from '../terminal-modes'
|
|
20
24
|
|
|
21
25
|
const MAX_BUFFER_LENGTH = 50_000
|
|
@@ -317,12 +321,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
317
321
|
)
|
|
318
322
|
}
|
|
319
323
|
case 'hydrate-workspace': {
|
|
324
|
+
// The session's activeWorktreeId may have just been switched by the
|
|
325
|
+
// user (j/k cycle, sidebar click) before the backend attached. Honor
|
|
326
|
+
// that choice by only considering tabs visible in that worktree —
|
|
327
|
+
// otherwise we'd land the active tab on whatever the backend picked
|
|
328
|
+
// (typically the worktree we *last* persisted, not the current one).
|
|
329
|
+
const currentSession =
|
|
330
|
+
state.currentSessionId != null && state.currentSessionId !== ''
|
|
331
|
+
? state.sessions.find((s) => s.id === state.currentSessionId)
|
|
332
|
+
: undefined
|
|
333
|
+
const visibleForWorktree = filterTabsForActiveWorktree(action.tabs, currentSession)
|
|
334
|
+
const visibleIds = new Set(visibleForWorktree.map((t) => t.id))
|
|
320
335
|
const hydratedActiveTabId =
|
|
321
336
|
action.activeTabId != null &&
|
|
322
337
|
action.activeTabId !== '' &&
|
|
323
|
-
|
|
338
|
+
visibleIds.has(action.activeTabId)
|
|
324
339
|
? action.activeTabId
|
|
325
|
-
: (
|
|
340
|
+
: (visibleForWorktree[0]?.id ?? null)
|
|
326
341
|
const tabIds = new Set(action.tabs.map((t) => t.id))
|
|
327
342
|
|
|
328
343
|
// Restore from new multi-tree format or migrate from legacy single tree
|
|
@@ -8,8 +8,6 @@ import type { BranchDivergence, SessionRecord, TabSession, WorktreeRecord } from
|
|
|
8
8
|
import { createPrefixedId } from '../platform/id'
|
|
9
9
|
import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
|
|
10
10
|
|
|
11
|
-
const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
|
|
12
|
-
|
|
13
11
|
export function formatDivergence(divergence: BranchDivergence | undefined): string {
|
|
14
12
|
if (divergence == null) return ''
|
|
15
13
|
const parts: string[] = []
|
|
@@ -18,14 +16,6 @@ export function formatDivergence(divergence: BranchDivergence | undefined): stri
|
|
|
18
16
|
return parts.join(' ')
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
export function getWorktreeColor(worktreeId: string): string {
|
|
22
|
-
let hash = 0
|
|
23
|
-
for (let i = 0; i < worktreeId.length; i++) {
|
|
24
|
-
hash = (hash * 31 + worktreeId.charCodeAt(i)) >>> 0
|
|
25
|
-
}
|
|
26
|
-
return WORKTREE_COLORS[hash % WORKTREE_COLORS.length] ?? '#7dd3fc'
|
|
27
|
-
}
|
|
28
|
-
|
|
29
19
|
export function createPrimaryWorktree(projectPath: string, now: string): WorktreeRecord {
|
|
30
20
|
const id = createPrefixedId('worktree')
|
|
31
21
|
return {
|
package/src/state/types.ts
CHANGED
|
@@ -50,8 +50,16 @@ export type ModalType =
|
|
|
50
50
|
|
|
51
51
|
export interface TerminalSpan {
|
|
52
52
|
text: string
|
|
53
|
+
/** Hex color for RGB cells, or undefined for the "default" foreground. */
|
|
53
54
|
fg?: string
|
|
55
|
+
/** Hex color for RGB cells, or undefined for the "default" background. */
|
|
54
56
|
bg?: string
|
|
57
|
+
/** ANSI palette index (0-255) when the cell emitted an indexed color.
|
|
58
|
+
* Resolved client-side against the host terminal's queried palette so
|
|
59
|
+
* user themes (Ghostty, iTerm2, …) show through. Wins over `fg` if set. */
|
|
60
|
+
fgPalette?: number
|
|
61
|
+
/** ANSI palette index (0-255). Resolved client-side. Wins over `bg`. */
|
|
62
|
+
bgPalette?: number
|
|
55
63
|
bold?: boolean
|
|
56
64
|
italic?: boolean
|
|
57
65
|
underline?: boolean
|
|
@@ -76,7 +76,7 @@ export const GitPaneHeader = memo(function GitPaneHeader({
|
|
|
76
76
|
</box>
|
|
77
77
|
) : null}
|
|
78
78
|
{showToggle ? (
|
|
79
|
-
<box flexDirection="row" gap={1} onMouseDown={toggleListMode}>
|
|
79
|
+
<box flexDirection="row" gap={1} paddingLeft={1} onMouseDown={toggleListMode}>
|
|
80
80
|
<text
|
|
81
81
|
selectable={false}
|
|
82
82
|
fg={fileListMode === 'tree' ? t.primary : t.textMuted}
|
|
@@ -10,7 +10,7 @@ import type { SessionRecord, SessionStatus, WorktreeRecord } from '../../../../s
|
|
|
10
10
|
|
|
11
11
|
import { useAppStore } from '../../../../state/app-store'
|
|
12
12
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
13
|
-
import { formatDivergence
|
|
13
|
+
import { formatDivergence } from '../../../../state/session-worktrees'
|
|
14
14
|
// eslint-disable-next-line no-duplicate-imports
|
|
15
15
|
import { IDLE_SESSION_STATUS } from '../../../../state/types'
|
|
16
16
|
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
@@ -199,6 +199,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
|
|
|
199
199
|
isActiveItem={workspaceIsActiveItem}
|
|
200
200
|
inCurrentGroup={isCurrentSession}
|
|
201
201
|
primaryWorktree={primaryWorktree}
|
|
202
|
+
hasExtraWorktrees={extraWorktrees.length > 0}
|
|
202
203
|
status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
|
|
203
204
|
dragging={draggingId === session.id}
|
|
204
205
|
contentWidth={contentWidth}
|
|
@@ -210,7 +211,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
|
|
|
210
211
|
onDragCancel={cancelDrag}
|
|
211
212
|
/>
|
|
212
213
|
)
|
|
213
|
-
for (const worktree of extraWorktrees) {
|
|
214
|
+
for (const [wtIdx, worktree] of extraWorktrees.entries()) {
|
|
214
215
|
rows.push(
|
|
215
216
|
<WorktreeRow
|
|
216
217
|
key={`wt:${worktree.id}`}
|
|
@@ -219,6 +220,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
|
|
|
219
220
|
sessionIndex={sessionIndex}
|
|
220
221
|
isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
|
|
221
222
|
inCurrentGroup={isCurrentSession}
|
|
223
|
+
isLast={wtIdx === extraWorktrees.length - 1}
|
|
222
224
|
/>
|
|
223
225
|
)
|
|
224
226
|
}
|
|
@@ -250,6 +252,8 @@ interface WorkspaceRowProps {
|
|
|
250
252
|
inCurrentGroup: boolean
|
|
251
253
|
/** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
|
|
252
254
|
primaryWorktree: WorktreeRecord | undefined
|
|
255
|
+
/** True when at least one non-primary worktree follows — used to draw the tree continuator. */
|
|
256
|
+
hasExtraWorktrees: boolean
|
|
253
257
|
status: SessionStatus
|
|
254
258
|
dragging: boolean
|
|
255
259
|
contentWidth: number
|
|
@@ -265,6 +269,7 @@ interface WorkspaceRowProps {
|
|
|
265
269
|
const WorkspaceRow = memo(function WorkspaceRow({
|
|
266
270
|
contentWidth,
|
|
267
271
|
dragging,
|
|
272
|
+
hasExtraWorktrees,
|
|
268
273
|
inCurrentGroup,
|
|
269
274
|
isActiveItem,
|
|
270
275
|
marginTop,
|
|
@@ -332,16 +337,10 @@ const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
332
337
|
[session.id, session.name]
|
|
333
338
|
)
|
|
334
339
|
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
? (primaryWorktree.color ?? getWorktreeColor(primaryWorktree.id))
|
|
340
|
-
: t.textMuted
|
|
341
|
-
// Working/waiting indicator overrides the colored bar — the user needs to
|
|
342
|
-
// see assistant activity from across the room.
|
|
343
|
-
let leadingGlyph = '▍'
|
|
344
|
-
let leadingColor = barColor
|
|
340
|
+
// Neutral muted marker; working/waiting overrides it. Keeping a glyph
|
|
341
|
+
// in the slot avoids name shifts when state indicators come and go.
|
|
342
|
+
let leadingGlyph = '•'
|
|
343
|
+
let leadingColor = t.textMuted
|
|
345
344
|
if (showWaiting) {
|
|
346
345
|
leadingGlyph = '?'
|
|
347
346
|
leadingColor = waitingColor
|
|
@@ -379,7 +378,7 @@ const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
379
378
|
<text fg={leadingColor} selectable={false} wrapMode="none">
|
|
380
379
|
{leadingGlyph}
|
|
381
380
|
</text>
|
|
382
|
-
<text fg={
|
|
381
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
383
382
|
{' '}
|
|
384
383
|
{nameLabel}
|
|
385
384
|
</text>
|
|
@@ -387,7 +386,7 @@ const WorkspaceRow = memo(function WorkspaceRow({
|
|
|
387
386
|
{showBranch ? (
|
|
388
387
|
<box flexDirection="row">
|
|
389
388
|
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
390
|
-
{' '}
|
|
389
|
+
{hasExtraWorktrees ? '│ ' : ' '}
|
|
391
390
|
{'\u{e702}'} {branchLabel}
|
|
392
391
|
{divergenceText !== '' ? ` ${divergenceText}` : ''}
|
|
393
392
|
</text>
|
|
@@ -6,6 +6,7 @@ import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
|
|
|
6
6
|
|
|
7
7
|
import { useAppStore } from '../../../../state/app-store'
|
|
8
8
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
9
|
+
import { formatDivergence } from '../../../../state/session-worktrees'
|
|
9
10
|
import { useTheme } from '../../../theme'
|
|
10
11
|
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
11
12
|
|
|
@@ -18,11 +19,14 @@ interface WorktreeRowProps {
|
|
|
18
19
|
isActiveItem: boolean
|
|
19
20
|
/** True when this row's workspace is the current session (selection scope). */
|
|
20
21
|
inCurrentGroup: boolean
|
|
22
|
+
/** True when this is the last non-primary worktree of its workspace. Drives └─ vs ├─. */
|
|
23
|
+
isLast: boolean
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export const WorktreeRow = memo(function WorktreeRow({
|
|
24
27
|
inCurrentGroup,
|
|
25
28
|
isActiveItem,
|
|
29
|
+
isLast,
|
|
26
30
|
session,
|
|
27
31
|
sessionIndex,
|
|
28
32
|
worktree,
|
|
@@ -30,6 +34,7 @@ export const WorktreeRow = memo(function WorktreeRow({
|
|
|
30
34
|
const t = useTheme()
|
|
31
35
|
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
32
36
|
const isCurrentSession = session.id === currentSessionId
|
|
37
|
+
const divergence = useAppStore((s) => s.worktreeDivergence[worktree.id])
|
|
33
38
|
|
|
34
39
|
const handleMouseDown = useCallback(
|
|
35
40
|
(event: OtuiMouseEvent) => {
|
|
@@ -70,23 +75,40 @@ export const WorktreeRow = memo(function WorktreeRow({
|
|
|
70
75
|
bgColor = t.backgroundPanel
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
const connector = isLast ? '└─' : '├─'
|
|
79
|
+
const branchCont = isLast ? ' ' : '│ '
|
|
80
|
+
const branchText = worktree.branch ?? ''
|
|
81
|
+
const showBranch = branchText !== ''
|
|
82
|
+
const divergenceText = formatDivergence(divergence)
|
|
83
|
+
|
|
73
84
|
return (
|
|
74
85
|
<ContextMenuBox
|
|
75
86
|
id={`sidebar-wt-${worktree.id}`}
|
|
76
|
-
flexDirection="
|
|
87
|
+
flexDirection="column"
|
|
88
|
+
flexShrink={0}
|
|
77
89
|
paddingLeft={1}
|
|
78
90
|
paddingRight={1}
|
|
79
|
-
alignItems="center"
|
|
80
91
|
backgroundColor={bgColor}
|
|
81
92
|
rightClickMenu={rightClickMenu}
|
|
82
93
|
onMouseDown={handleMouseDown}
|
|
83
94
|
>
|
|
84
|
-
<
|
|
85
|
-
{
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
{
|
|
89
|
-
|
|
95
|
+
<box flexDirection="row" alignItems="center">
|
|
96
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
97
|
+
{connector}{' '}
|
|
98
|
+
</text>
|
|
99
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
100
|
+
{worktree.name}
|
|
101
|
+
</text>
|
|
102
|
+
</box>
|
|
103
|
+
{showBranch ? (
|
|
104
|
+
<box flexDirection="row">
|
|
105
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
106
|
+
{branchCont}
|
|
107
|
+
{'\u{e702}'} {branchText}
|
|
108
|
+
{divergenceText !== '' ? ` ${divergenceText}` : ''}
|
|
109
|
+
</text>
|
|
110
|
+
</box>
|
|
111
|
+
) : null}
|
|
90
112
|
</ContextMenuBox>
|
|
91
113
|
)
|
|
92
114
|
})
|
|
@@ -8,8 +8,10 @@ import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../.
|
|
|
8
8
|
|
|
9
9
|
import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
|
|
10
10
|
import { logInputDebug } from '../../../debug/input-log'
|
|
11
|
+
import { useAppStore } from '../../../state/app-store'
|
|
11
12
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
|
|
12
13
|
import { type ContextMenuItem, openContextMenu } from '../../context-menu/controller'
|
|
14
|
+
import { resolvePaletteIndex } from '../../host-palette'
|
|
13
15
|
import { getCurrentTheme, useTheme } from '../../theme'
|
|
14
16
|
import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
|
|
15
17
|
|
|
@@ -38,10 +40,16 @@ interface TerminalPaneProps {
|
|
|
38
40
|
function getTitle(
|
|
39
41
|
tab: TabSession | undefined,
|
|
40
42
|
isActive: boolean,
|
|
41
|
-
focusMode: TerminalPaneProps['focusMode']
|
|
43
|
+
focusMode: TerminalPaneProps['focusMode'],
|
|
44
|
+
emptyContext: { workspaceName: string; worktreeName: string }
|
|
42
45
|
): string {
|
|
43
46
|
if (!tab) {
|
|
44
|
-
|
|
47
|
+
const { workspaceName, worktreeName } = emptyContext
|
|
48
|
+
if (workspaceName === '' && worktreeName === '') return 'No active workspace'
|
|
49
|
+
if (worktreeName === '' || worktreeName === workspaceName) {
|
|
50
|
+
return `${workspaceName} · no tabs`
|
|
51
|
+
}
|
|
52
|
+
return `${workspaceName} / ${worktreeName} · no tabs`
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
if (isActive && focusMode === 'terminal-input') {
|
|
@@ -76,8 +84,16 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
|
|
|
76
84
|
node = <strong>{node}</strong>
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
// Palette indices are resolved here (not in the daemon) so they pick up
|
|
88
|
+
// the host terminal's actual ANSI palette queried at startup.
|
|
89
|
+
const fg =
|
|
90
|
+
span.fgPalette !== undefined
|
|
91
|
+
? resolvePaletteIndex(span.fgPalette)
|
|
92
|
+
: (span.fg ?? getCurrentTheme().text)
|
|
93
|
+
const bg = span.bgPalette !== undefined ? resolvePaletteIndex(span.bgPalette) : span.bg
|
|
94
|
+
|
|
79
95
|
return (
|
|
80
|
-
<span key={key} fg={
|
|
96
|
+
<span key={key} fg={fg} bg={bg}>
|
|
81
97
|
{node}
|
|
82
98
|
</span>
|
|
83
99
|
)
|
|
@@ -137,6 +153,26 @@ export function TerminalPane({
|
|
|
137
153
|
const setContentBox = usePaneSizeReport(tabId, !!tab, onMeasure)
|
|
138
154
|
const editorBg = t.background
|
|
139
155
|
const paneIsActive = isActive ?? true
|
|
156
|
+
// These are only used when this pane is rendered without a tab (the
|
|
157
|
+
// top-level pane on a worktree with zero tabs). Selectors return plain
|
|
158
|
+
// strings so re-renders are cheap and bounded to actual name changes.
|
|
159
|
+
const emptyWorkspaceName = useAppStore((s) => {
|
|
160
|
+
const id = s.currentSessionId
|
|
161
|
+
if (id == null || id === '') return ''
|
|
162
|
+
return s.sessions.find((sess) => sess.id === id)?.name ?? ''
|
|
163
|
+
})
|
|
164
|
+
const emptyWorktreeName = useAppStore((s) => {
|
|
165
|
+
const id = s.currentSessionId
|
|
166
|
+
if (id == null || id === '') return ''
|
|
167
|
+
const session = s.sessions.find((sess) => sess.id === id)
|
|
168
|
+
if (!session) return ''
|
|
169
|
+
const activeId =
|
|
170
|
+
session.activeWorktreeId != null && session.activeWorktreeId !== ''
|
|
171
|
+
? session.activeWorktreeId
|
|
172
|
+
: session.worktrees?.[0]?.id
|
|
173
|
+
if (activeId == null || activeId === '') return ''
|
|
174
|
+
return session.worktrees?.find((w) => w.id === activeId)?.name ?? ''
|
|
175
|
+
})
|
|
140
176
|
const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
|
|
141
177
|
const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
|
|
142
178
|
const rightClickMenu = useMemo<ContextMenuItem[] | undefined>(
|
|
@@ -332,7 +368,10 @@ export function TerminalPane({
|
|
|
332
368
|
<ContextMenuBox
|
|
333
369
|
border
|
|
334
370
|
borderColor={getBorderColor(paneIsActive, focusMode)}
|
|
335
|
-
title={getTitle(tab, paneIsActive, focusMode
|
|
371
|
+
title={getTitle(tab, paneIsActive, focusMode, {
|
|
372
|
+
workspaceName: emptyWorkspaceName,
|
|
373
|
+
worktreeName: emptyWorktreeName,
|
|
374
|
+
})}
|
|
336
375
|
padding={0}
|
|
337
376
|
flexDirection="column"
|
|
338
377
|
flexGrow={1}
|
|
@@ -347,6 +386,19 @@ export function TerminalPane({
|
|
|
347
386
|
<box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
|
|
348
387
|
<text fg={t.textMuted}>· · ·</text>
|
|
349
388
|
<text fg={t.textMuted}> </text>
|
|
389
|
+
{emptyWorkspaceName !== '' ? (
|
|
390
|
+
<box flexDirection="row">
|
|
391
|
+
<text fg={t.text}>{emptyWorkspaceName}</text>
|
|
392
|
+
{emptyWorktreeName !== '' && emptyWorktreeName !== emptyWorkspaceName ? (
|
|
393
|
+
<>
|
|
394
|
+
<text fg={t.textMuted}> / </text>
|
|
395
|
+
<text fg={t.text}>{emptyWorktreeName}</text>
|
|
396
|
+
</>
|
|
397
|
+
) : null}
|
|
398
|
+
</box>
|
|
399
|
+
) : null}
|
|
400
|
+
<text fg={t.textMuted}>has no tabs</text>
|
|
401
|
+
<text fg={t.textMuted}> </text>
|
|
350
402
|
<box flexDirection="row">
|
|
351
403
|
<text fg={t.textMuted}>Press </text>
|
|
352
404
|
<text fg={t.primary}>Ctrl+n</text>
|
|
@@ -220,7 +220,9 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
|
220
220
|
}, [])
|
|
221
221
|
|
|
222
222
|
if (!bar.visible && !forceVisible) return null
|
|
223
|
-
|
|
223
|
+
// Keep the bar visible even with zero entries — when the active worktree
|
|
224
|
+
// has no tabs, the lone "+" affordance is what tells the user "you're in
|
|
225
|
+
// an empty worktree, click here to start one".
|
|
224
226
|
|
|
225
227
|
const isFocused = focusMode === 'terminal-input' || focusMode === 'navigation'
|
|
226
228
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Resolves ANSI palette indices (0-255) to hex strings using the host
|
|
2
|
+
// terminal's actual palette, queried via OSC 4 at startup (see index.tsx).
|
|
3
|
+
//
|
|
4
|
+
// Before detection completes — or when the host terminal doesn't respond —
|
|
5
|
+
// FALLBACK_PALETTE (xterm defaults) is used. Indices ≥16 fall back to the
|
|
6
|
+
// universal 6×6×6 cube + grayscale ramp, which every terminal agrees on.
|
|
7
|
+
|
|
8
|
+
const FALLBACK_PALETTE: readonly string[] = [
|
|
9
|
+
'#000000',
|
|
10
|
+
'#cd0000',
|
|
11
|
+
'#00cd00',
|
|
12
|
+
'#cdcd00',
|
|
13
|
+
'#0000ee',
|
|
14
|
+
'#cd00cd',
|
|
15
|
+
'#00cdcd',
|
|
16
|
+
'#e5e5e5',
|
|
17
|
+
'#7f7f7f',
|
|
18
|
+
'#ff0000',
|
|
19
|
+
'#00ff00',
|
|
20
|
+
'#ffff00',
|
|
21
|
+
'#5c5cff',
|
|
22
|
+
'#ff00ff',
|
|
23
|
+
'#00ffff',
|
|
24
|
+
'#ffffff',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const CUBE_CHANNELS = [0, 95, 135, 175, 215, 255] as const
|
|
28
|
+
|
|
29
|
+
const hostPalette: string[] = [...FALLBACK_PALETTE]
|
|
30
|
+
|
|
31
|
+
function toHex(value: number): string {
|
|
32
|
+
return `#${value.toString(16).padStart(6, '0')}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function paletteFromFormula(index: number): string {
|
|
36
|
+
if (index >= 232) {
|
|
37
|
+
const shade = 8 + (index - 232) * 10
|
|
38
|
+
return toHex((shade << 16) | (shade << 8) | shade)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const normalized = index - 16
|
|
42
|
+
const r = Math.floor(normalized / 36)
|
|
43
|
+
const g = Math.floor((normalized % 36) / 6)
|
|
44
|
+
const b = normalized % 6
|
|
45
|
+
return toHex(
|
|
46
|
+
((CUBE_CHANNELS[r] ?? 0) << 16) | ((CUBE_CHANNELS[g] ?? 0) << 8) | (CUBE_CHANNELS[b] ?? 0)
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Replace entries 0..N-1 of the resolver with values queried from the host
|
|
51
|
+
* terminal. Null entries keep the existing fallback (terminal didn't respond
|
|
52
|
+
* for that index). Safe to call before any snapshots have been rendered. */
|
|
53
|
+
export function setHostPalette(palette: readonly (string | null)[]): void {
|
|
54
|
+
for (let index = 0; index < palette.length; index += 1) {
|
|
55
|
+
const value = palette[index]
|
|
56
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
57
|
+
hostPalette[index] = value
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve an ANSI palette index (0-255) to a hex color. Indices 0-15 use
|
|
63
|
+
* the host-queried palette (or xterm fallback); 16-255 use the universal
|
|
64
|
+
* cube/grayscale formula unless the host explicitly customized them. */
|
|
65
|
+
const BLACK = '#000000'
|
|
66
|
+
const WHITE = '#ffffff'
|
|
67
|
+
|
|
68
|
+
export function resolvePaletteIndex(index: number): string {
|
|
69
|
+
if (index < 0) return BLACK
|
|
70
|
+
const override = hostPalette[index]
|
|
71
|
+
if (typeof override === 'string' && override.length > 0) return override
|
|
72
|
+
if (index < 16) return FALLBACK_PALETTE.at(index) ?? BLACK
|
|
73
|
+
if (index > 255) return WHITE
|
|
74
|
+
return paletteFromFormula(index)
|
|
75
|
+
}
|