Skip to content

Commit 2ef92c7

Browse files
committed
feat(tui): wrap vertical tab titles
1 parent 7c45559 commit 2ef92c7

4 files changed

Lines changed: 95 additions & 54 deletions

File tree

‎PATCH.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ Plugin (`custom.tool.subagent.resume`): a subagent `sessionID` that is not a chi
8181

8282
Session tabs default to vertical in the TUI (`tabs.layout`) and desktop app (`appearance.tabLayout`).
8383

84+
## Vertical Tab Title Wrapping (TUI)
85+
86+
Expanded vertical tab titles word-wrap onto as many rows as they need instead of cropping, and the tab grows to fit; the compact rail and project line still crop.
87+
8488
## Session List Preview (TUI) [NOT MIGRATED]
8589

8690
The session list (`<leader>l`) previews the highlighted session's latest messages on terminals 128 columns or wider.

‎packages/tui/src/component/session-tabs.tsx‎

Lines changed: 36 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,6 @@ function VerticalSessionTabs(props: {
563563
const width = () => props.width ?? SESSION_SIDEBAR_WIDTH
564564
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
565565
const tooltipWidth = () => Math.min(54, dimensions().width - width())
566-
const stride = () => (compact() ? 2 : 3)
567566
const unreadColor = () => theme.hue.accent[200]
568567
const activeNumber = () => theme.hue.interactive[200]
569568
const idleNumber = () => tint(theme.text.formfield.base, background(), 0.55)
@@ -604,14 +603,25 @@ function VerticalSessionTabs(props: {
604603
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
605604
})
606605
const items = ordered
606+
// Wrapped titles make tab heights vary, so positions come from the laid-out boxes.
607+
const boxes = new Map<string, BoxRenderable>()
608+
let addRow: BoxRenderable | undefined
609+
const indexAt = (y: number) => {
610+
const index = items().findIndex((tab) => {
611+
const box = boxes.get(tab.sessionID)
612+
return box !== undefined && y < box.screenY + box.height + 1
613+
})
614+
return index === -1 ? items().length - 1 : index
615+
}
607616
const highlightColor = createMemo(() =>
608617
tint(background(), actionHovered(), actionHovered().a),
609618
)
610619
const highlighted = (sessionID: string | undefined) =>
611620
sessionID !== undefined && (activeID() === sessionID || hovered() === sessionID || dragging() === sessionID)
612621
const addHighlighted = () => newTab() || addHovered()
613622
const belowHighlighted = createMemo(() => {
614-
const tab = items()[Math.floor(scrollTop() / stride())]
623+
// Only the compact rail shows controls, and its tabs are a fixed two cells apart.
624+
const tab = items()[Math.floor(scrollTop() / 2)]
615625
return tab ? highlighted(tab.sessionID) : addHighlighted()
616626
})
617627
createEffect(() => {
@@ -659,11 +669,14 @@ function VerticalSessionTabs(props: {
659669
dimensions()
660670
const index = newTab() ? items().length : items().findIndex((tab) => tab.sessionID === activeID())
661671
if (index === -1) return
662-
const top = index * stride()
663-
const height = compact() ? 3 : newTab() ? 1 : 2
664672
// Scroll after layout: newly opened tabs do not contribute to the scroll range yet.
665673
const reveal = () => {
666674
if (!scroll) return
675+
const box = newTab() ? addRow : boxes.get(activeID()!)
676+
if (!box) return
677+
// Compact tabs also reveal the half rows drawn above and below them.
678+
const top = box.screenY - scroll.content.screenY - (compact() ? 1 : 0)
679+
const height = compact() ? 3 : box.height
667680
if (top < scroll.scrollTop) return scroll.scrollTo(top)
668681
if (top + height > scroll.scrollTop + scroll.viewport.height) {
669682
scroll.scrollTo(top + height - scroll.viewport.height)
@@ -693,10 +706,7 @@ function VerticalSessionTabs(props: {
693706
0,
694707
Math.min(
695708
tabs.tabs().length - 1,
696-
Math.floor(
697-
(event.y - (scroll?.viewport.screenY ?? rail.screenY + 1) - (compact() ? 1 : 0) + (scroll?.scrollTop ?? 0)) /
698-
stride(),
699-
),
709+
indexAt(event.y),
700710
),
701711
)
702712
const sourceIndex = items().findIndex((item) => item.sessionID === source)
@@ -752,21 +762,7 @@ function VerticalSessionTabs(props: {
752762
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
753763
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
754764
const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
755-
const scrolling = () => marquee.active() === tab.sessionID
756-
const visibleTitleParts = createMemo(() =>
757-
scrolling()
758-
? marqueeTextParts(title(), titleWidth(), marquee.offset())
759-
: Locale.graphemes(Locale.takeWidth(title(), titleWidth())).map((value) => ({
760-
value,
761-
separator: false,
762-
})),
763-
)
764-
const visibleTitle = createMemo(() =>
765-
visibleTitleParts()
766-
.map((part) => part.value)
767-
.join(""),
768-
)
769-
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
765+
const titleParts = createMemo(() => Locale.graphemes(title()))
770766
const tabDetail = createMemo(() => detail(tab.sessionID))
771767
const visibleDetail = createMemo(() => Locale.takeWidth(tabDetail(), titleWidth()))
772768
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
@@ -861,33 +857,24 @@ function VerticalSessionTabs(props: {
861857
const separatorLowerColor = createMemo(() =>
862858
tint(background(), glowHue(), 0.12 * glowLevel()),
863859
)
864-
const titleColor = (index: number, separator: boolean) => {
860+
const titleColor = (index: number) => {
865861
const level = titleGlow.value().level
866-
const color =
867-
level === 0
868-
? foreground()
869-
: glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width(), level)
870-
const faded = titleFades()
871-
? fadeTitleColor(
872-
color,
873-
pulseBackground(),
874-
index,
875-
visibleTitleParts().length,
876-
scrolling() ? marquee.leading() : 0,
877-
)
878-
: color
879-
return separator ? tint(faded, pulseBackground(), 0.55) : faded
862+
return level === 0
863+
? foreground()
864+
: glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width(), level)
880865
}
866+
onCleanup(() => boxes.delete(tab.sessionID))
881867
return (
882868
<box
883-
height={compact() ? 1 : 2}
869+
ref={(element: BoxRenderable) => boxes.set(tab.sessionID, element)}
870+
height={compact() ? 1 : undefined}
884871
width="100%"
885872
position="relative"
886873
flexDirection="column"
887874
backgroundColor={tabBackground()}
888875
onMouseOver={(event) => {
889876
setHoverY(event.y)
890-
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
877+
marquee.enter(tab.sessionID, title(), Infinity)
891878
}}
892879
onMouseOut={() => marquee.leave(tab.sessionID)}
893880
onMouseDown={(event) => {
@@ -914,7 +901,7 @@ function VerticalSessionTabs(props: {
914901
return
915902
}
916903
didDrag = false
917-
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
904+
marquee.enter(tab.sessionID, title(), Infinity)
918905
setDragging(tab.sessionID)
919906
}}
920907
>
@@ -1000,7 +987,7 @@ function VerticalSessionTabs(props: {
1000987
/>
1001988
<Show when={index() === items().length - 1}>
1002989
<TabPulse
1003-
top={2}
990+
bottom={-1}
1004991
edge="below"
1005992
enabled={animations()}
1006993
active={runs()}
@@ -1025,7 +1012,7 @@ function VerticalSessionTabs(props: {
10251012
backgroundColor={background()}
10261013
/>
10271014
</Show>
1028-
<box height={1} width="100%" flexDirection="row" position="relative">
1015+
<box width="100%" flexDirection="row" position="relative">
10291016
<TabPulse
10301017
enabled={animations()}
10311018
active={runs()}
@@ -1057,13 +1044,12 @@ function VerticalSessionTabs(props: {
10571044
attributes={selected() ? TextAttributes.BOLD : undefined}
10581045
/>
10591046
<title_shimmer
1060-
width={titleWidth()}
1061-
height={1}
1047+
width={hoveredTitleWidth()}
10621048
fg={foreground()}
10631049
rename={{ pending: status().renaming, title: title() }}
10641050
enabled={animations()}
10651051
backdrop={pulseBackground()}
1066-
wrapMode="none"
1052+
wrapMode="word"
10671053
selectable={false}
10681054
attributes={
10691055
status().renaming && !animations()
@@ -1073,14 +1059,9 @@ function VerticalSessionTabs(props: {
10731059
: undefined
10741060
}
10751061
>
1076-
<Show
1077-
when={scrolling() || titleGlow.value().level > 0 || titleFades()}
1078-
fallback={visibleTitle()}
1079-
>
1080-
<Index each={visibleTitleParts()}>
1081-
{(part, index) => (
1082-
<span style={{ fg: titleColor(index, part().separator) }}>{part().value}</span>
1083-
)}
1062+
<Show when={titleGlow.value().level > 0} fallback={title()}>
1063+
<Index each={titleParts()}>
1064+
{(part, index) => <span style={{ fg: titleColor(index) }}>{part()}</span>}
10841065
</Index>
10851066
</Show>
10861067
</title_shimmer>
@@ -1145,6 +1126,7 @@ function VerticalSessionTabs(props: {
11451126
active new-session tab, instead of spawning a separate pseudo tab above itself. */}
11461127
<Show when={tabs.add || newTab()}>
11471128
<box
1129+
ref={(element: BoxRenderable) => (addRow = element)}
11481130
height={1}
11491131
width="100%"
11501132
position="relative"

‎packages/tui/src/component/tab-pulse.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,7 @@ extend({ tab_pulse: TabPulseRenderable })
757757

758758
export function TabPulse(props: {
759759
top?: number
760+
bottom?: number
760761
width?: number
761762
edge?: "above" | "below"
762763
enabled?: boolean
@@ -786,6 +787,7 @@ export function TabPulse(props: {
786787
<tab_pulse
787788
position="absolute"
788789
top={props.top}
790+
bottom={props.bottom}
789791
edge={props.edge}
790792
zIndex={0}
791793
width={props.width ?? "100%"}

‎packages/tui/test/component/session-tabs-rail.test.tsx‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,56 @@ test("compact rail renders and controls session tabs", async () => {
123123
app.renderer.destroy()
124124
}
125125
})
126+
127+
test("expanded rail wraps long titles and keeps drag targets aligned", async () => {
128+
const [items, setItems] = createSignal<SessionTab[]>([
129+
{ sessionID: "first", title: "Short" },
130+
{ sessionID: "second", title: "alpha bravo charlie delta echo foxtrot golf hotel india juliet" },
131+
{ sessionID: "third", title: "Third" },
132+
])
133+
const controller = {
134+
tabs: items,
135+
current: () => "first",
136+
add() {},
137+
select() {},
138+
close() {},
139+
move(sessionID: string, index: number) {
140+
setItems((items) => moveSessionTab(items, sessionID, index))
141+
},
142+
detail: () => "project",
143+
status: () => EMPTY_SESSION_TAB_STATUS,
144+
} satisfies SessionTabsController
145+
const app = await testRender(
146+
() => (
147+
<TestTuiContexts>
148+
<ConfigProvider config={createTuiResolvedConfig({ tabs: { indicators: "status" } })}>
149+
<Keymap.Provider>
150+
<ThemeProvider mode="dark" source={emptyThemeSource}>
151+
<box width="100%" height="100%" flexDirection="row">
152+
<SessionTabs controller={controller} orientation="vertical" animations={false} width={24} />
153+
</box>
154+
</ThemeProvider>
155+
</Keymap.Provider>
156+
</ConfigProvider>
157+
</TestTuiContexts>
158+
),
159+
{ width: 40, height: 14 },
160+
)
161+
162+
try {
163+
app.renderer.start()
164+
await app.waitForFrame((frame) => frame.includes("Third"))
165+
const rows = app.captureCharFrame().split("\n")
166+
expect(rows[4]).toContain("alpha bravo charlie")
167+
expect(rows[5]).toContain("delta echo foxtrot")
168+
expect(rows[6]).toContain("golf hotel india")
169+
expect(rows[7]).toContain("juliet")
170+
expect(rows[8]).toContain("project")
171+
expect(rows[10]).toContain("Third")
172+
173+
await app.mockMouse.drag(2, 10, 2, 1)
174+
expect(items().map((tab) => tab.sessionID)).toEqual(["third", "first", "second"])
175+
} finally {
176+
app.renderer.destroy()
177+
}
178+
})

0 commit comments

Comments
 (0)