From 30d4ab6a61b3912408012bd0db9fdfcc12416593 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:38:06 -0400 Subject: [PATCH 1/3] tui3: show the hovered tab's full title on the head's blank row, not over the rule Co-Authored-By: Claude Opus 5.5 --- internal/tui3/tabtitle.go | 9 +++++---- internal/tui3/tabtitle_test.go | 11 ++++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/internal/tui3/tabtitle.go b/internal/tui3/tabtitle.go index bca8d2a361..231c5348eb 100644 --- a/internal/tui3/tabtitle.go +++ b/internal/tui3/tabtitle.go @@ -5,9 +5,10 @@ import ( "strings" ) -// tabTitlePreview reveals the full name beneath the hovered label. It overlays -// existing rows so neither the tab targets nor the transcript scroll position -// move. Reading text stays still, including in the reduced-motion tier. +// tabTitlePreview reveals the full name beneath the hovered label, on the head's +// blank row under the rule (head.go) so the rule that seals the head stays drawn. +// It overlays existing rows so neither the tab targets nor the transcript scroll +// position move. Reading text stays still, including in the reduced-motion tier. func (a *app) tabTitlePreview(frame string) string { hit, ok := a.hotTab() if !ok || (hit.kind != tabHere && hit.kind != tabOther) || hit.tab.start { @@ -29,7 +30,7 @@ func (a *app) tabTitlePreview(frame string) string { } preview := strings.Split(ansi.Wrap(title, width-2*headLabelAt, ""), "\n") for i, line := range preview { - at := placeTabRow + 1 + i + at := placeHeadRows - 1 + i if at >= len(rows) { break } diff --git a/internal/tui3/tabtitle_test.go b/internal/tui3/tabtitle_test.go index 8910ae903d..ecf76a2eca 100644 --- a/internal/tui3/tabtitle_test.go +++ b/internal/tui3/tabtitle_test.go @@ -15,10 +15,15 @@ func TestTabHoverRevealsFullTitleWithoutMovingTargets(t *testing.T) { frame := strings.Repeat(strings.Repeat(" ", a.width)+"\n", a.height-1) rows := strings.Split(frame, "\n") rows[placeTabRow] = before + seal := rule(a.width) + rows[placeTabRow+1] = seal frame = strings.Join(rows, "\n") - shown := a.tabTitlePreview(frame) - if !strings.Contains(plain(shown), a.title) { - t.Fatal("hover lost the full title") + shown := strings.Split(a.tabTitlePreview(frame), "\n") + if !strings.Contains(plain(shown[placeHeadRows-1]), a.title) { + t.Fatal("hover did not put the full title on the head's blank row") + } + if shown[placeTabRow+1] != seal { + t.Fatal("preview covered the rule under the strip") } if a.tabsRow(a.width) != before || tabSpanFor(t, a, a.title) != span { t.Fatal("preview moved the tab") From 610b0ed3874e7a8e318a6cce66bfdd5ad9bdaa7a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:09:06 -0400 Subject: [PATCH 2/3] tui3: end the hovered tab's full title where its tab ends The preview started at the left margin, so it read as part of the conversation below rather than the tab above. It is now right-aligned to the tab's right edge, close cells included, and a title wider than the tab sticks out to the left. Only a title too wide for the room left of that edge takes cells to the right, and only as many as it needs; one wider than the row still wraps from the margin over the conversation. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/screen.md | 5 ++- internal/tui3/tabtitle.go | 27 +++++++++++++- internal/tui3/tabtitle_test.go | 65 ++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 05ee19a1fa..99c63dd560 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -213,7 +213,10 @@ title does not wait for the answer to finish. their available width; there is no separately generated tab label. The title also reaches an idle chat or a chat you have switched away from. -Hover over a tab to reveal its full title beneath it. Long titles wrap; the tab and +Hover over a tab to reveal its full title beneath it, on the blank row under the line +that closes the top of the screen. The title ends where the tab ends, and a title wider +than the tab reaches out to the left; only one too wide for that room reaches right, and +one wider than the whole row wraps over the top of the conversation. The tab and conversation stay in place, and the preview disappears when the pointer leaves. **Temporary failures and unusable names retry automatically.** Naming asks again, with diff --git a/internal/tui3/tabtitle.go b/internal/tui3/tabtitle.go index 231c5348eb..5252238546 100644 --- a/internal/tui3/tabtitle.go +++ b/internal/tui3/tabtitle.go @@ -9,6 +9,13 @@ import ( // blank row under the rule (head.go) so the rule that seals the head stays drawn. // It overlays existing rows so neither the tab targets nor the transcript scroll // position move. Reading text stays still, including in the reduced-motion tier. +// +// THE NAME ENDS WHERE ITS TAB ENDS. It is right-aligned to the tab's right edge, +// close cells included, and a name wider than the tab sticks out to the left, so +// the eye reads it as belonging to the tab above rather than to the conversation +// below. Only a name that cannot fit between the left margin and that edge takes +// cells to the right of it, and only as many as it needs; one wider than the +// whole row wraps onto the rows below, over the top of the conversation. func (a *app) tabTitlePreview(frame string) string { hit, ok := a.hotTab() if !ok || (hit.kind != tabHere && hit.kind != tabOther) || hit.tab.start { @@ -29,12 +36,30 @@ func (a *app) tabTitlePreview(frame string) string { return frame } preview := strings.Split(ansi.Wrap(title, width-2*headLabelAt, ""), "\n") + block := 0 + for _, line := range preview { + block = max(block, ansi.StringWidth(line)) + } + end := min(width, max(a.tabBoxEnd(hit), headLabelAt+block)) + start := end - block for i, line := range preview { at := placeHeadRows - 1 + i if at >= len(rows) { break } - rows[at] = a.pal.ink(strings.Repeat(" ", headLabelAt) + line + strings.Repeat(" ", max(0, width-headLabelAt-ansi.StringWidth(line)))) + rows[at] = a.pal.ink(strings.Repeat(" ", start) + line + strings.Repeat(" ", max(0, width-start-ansi.StringWidth(line)))) } return strings.Join(rows, "\n") } + +// tabBoxEnd is the column just past a tab's filled box: its close cells when the +// strip drew them beside the label, which it does on every tab, and the label's +// own edge otherwise. +func (a *app) tabBoxEnd(hit tabHit) int { + for _, other := range a.chatTabHits { + if other.kind == tabClose && other.span.from == hit.span.to { + return other.span.to + } + } + return hit.span.to +} diff --git a/internal/tui3/tabtitle_test.go b/internal/tui3/tabtitle_test.go index ecf76a2eca..ab26c304c6 100644 --- a/internal/tui3/tabtitle_test.go +++ b/internal/tui3/tabtitle_test.go @@ -37,3 +37,68 @@ func TestTabHoverRevealsFullTitleWithoutMovingTargets(t *testing.T) { t.Fatal("preview remained after pointer left") } } + +// THE NAME ENDS WHERE ITS TAB ENDS: a name wider than the tab sticks out to the +// left, and only a name too wide for the room left of that edge takes cells to +// its right, and then only as many as it needs. +func TestTabTitlePreviewEndsUnderItsTab(t *testing.T) { + a, _, _ := tabApp(t) + // Every title below opens with the same words, so the tab's cut label, and + // with it the tab's place on the strip, is the same for all of them. + base := "Shipping the parser with complete unicode support" + a.title = base + a.tabsRow(a.width) + span := tabSpanFor(t, a, a.title) + a.hot, _ = a.tabHoverAt(span.from, placeTabRow) + hit, ok := a.hotTab() + if !ok { + t.Fatal("the pointer is not over the tab") + } + end := a.tabBoxEnd(hit) + if end != span.to+tabCloseCells { + t.Fatalf("the tab's box ends at %d, want its close cells' edge %d", end, span.to+tabCloseCells) + } + preview := func(title string) []string { + t.Helper() + a.title = title + row := a.tabsRow(a.width) + if got := tabSpanFor(t, a, title); got != span { + t.Fatalf("a longer title moved the tab from %+v to %+v", span, got) + } + rows := strings.Split(strings.Repeat(strings.Repeat(" ", a.width)+"\n", a.height-1), "\n") + rows[placeTabRow] = row + shown := strings.Split(a.tabTitlePreview(strings.Join(rows, "\n")), "\n") + for i := range shown { + shown[i] = plain(shown[i]) + } + return shown + } + grown := func(width int) string { + title := base + for len(title) < width { + title += " and more" + } + return strings.TrimSpace(title[:width]) + } + + if end-headLabelAt <= len(base) || end > a.width-headLabelAt { + t.Fatalf("the fixture's tab ends at %d, which cannot show both a short and a long name", end) + } + shown := preview(base) + if at := strings.Index(shown[placeHeadRows-1], base); at+len(base) != end { + t.Fatalf("a name that fits ends at %d, want the tab's edge %d:\n%q", at+len(base), end, shown[placeHeadRows-1]) + } + + long := grown(end - headLabelAt + 10) + shown = preview(long) + if at := strings.Index(shown[placeHeadRows-1], long); at != headLabelAt { + t.Fatalf("a name wider than the room left of its tab starts at %d, want the margin %d:\n%q", at, headLabelAt, shown[placeHeadRows-1]) + } + + wide := grown(a.width + 20) + shown = preview(wide) + first, second := shown[placeHeadRows-1], shown[placeHeadRows] + if !strings.HasPrefix(first, strings.Repeat(" ", headLabelAt)+"Shipping") || strings.TrimSpace(second) == "" || !strings.HasPrefix(second, strings.Repeat(" ", headLabelAt)) || second[headLabelAt] == ' ' { + t.Fatalf("a name wider than the row does not wrap from the margin:\n%q\n%q", first, second) + } +} From 1ea7cf34a5e28ff9a8808fdfdca97f996e9f1a26 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:16:03 -0400 Subject: [PATCH 3/3] docs: change entry for #1496 Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/1496-tab-title-under-its-tab.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/changes/unreleased/1496-tab-title-under-its-tab.md diff --git a/docs/changes/unreleased/1496-tab-title-under-its-tab.md b/docs/changes/unreleased/1496-tab-title-under-its-tab.md new file mode 100644 index 0000000000..969a6c59f3 --- /dev/null +++ b/docs/changes/unreleased/1496-tab-title-under-its-tab.md @@ -0,0 +1,8 @@ +--- +kind: changed +title: A hovered tab's full title sits on the blank row and ends under its tab +pr: 1496 +surface: [chat, docs] +invalidates: + - Hovering a tab drew its full title from the left margin on the row right under the tab strip, over the rule; it now sits on the head's blank row below the rule, right-aligned to the hovered tab's right edge (close mark included), sticking out to the left when wider than the tab and reaching right only when it cannot fit left of that edge. +---