Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1509-nav-air-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: changed
title: tui3 head grows an air row under the top navigation line
pr: 1509
surface: [chat]
invalidates:
- "The tui3 page head was four rows under the top navigation line. It is five: a blank air row sits directly under the nav line, then the tab strip, divider, and margin row."
---

Every page's head pays one extra row for the air row; the welcome screen's
budget and the tui3 tests were adjusted with it. Visual proof lives at
`docs/design/spark-header-spacing.png` (live Spark tmux capture).
Binary file added docs/design/header-spacing-ref-flux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/header-spacing-ref-imagen.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/header-spacing-ref-imagen2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/header-spacing-reference.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/spark-header-spacing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/design/spark-tmux-live-header.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions internal/tui3/chattabs.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,10 +487,10 @@ func tabsCapped(tabs []chatTab, prev []string) []chatTab {
// those numbers.

// tabsHeight is what the head costs the body region DOWN TO AND INCLUDING THE
// STRIP when a conversation is in front: the pulse, and the strip under it
// ([tabStripRow]). On a place the strip is not drawn, so this is the nav's
// one row and the page owns the row under it (head.go's [app.stripInHead]).
//
// STRIP when a conversation is in front: the pulse, the air row under it, and
// the strip ([tabStripRow]). On a place the strip is not drawn, so this is the
// nav and its air row, and the page owns the row under them (head.go's
// [app.stripInHead]).//
// IT STANDS DOWN ON THE TWO FLOORS THE CONVERSATION'S BAR STOOD DOWN ON. A frame
// too narrow for a name and a way out is too narrow for this, and a terminal too
// short for a blank above the draft has no row to spare for a fact that is true
Expand All @@ -509,7 +509,7 @@ func (a *app) tabsHeight(width int) int {
return 0
}
if !a.stripInHead() {
return navRow + 1
return navAirRow + 1
}
return tabStripRow + 1
}
Expand Down
29 changes: 16 additions & 13 deletions internal/tui3/head.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@ package tui3
// THE HEAD: ONE SET OF ROWS OVER EVERY FRAME.
//
// >● codeaf home teams chats sessions spend settings 2 want you · $1.20 thu 10:31pm
//
// ● harbor ▾ ◆ Manager × Refactor the rail… × + ▦ All
// ───────────────────────────────────────────────────────────────────────────────────────────
//
// The wordmark with the places after it and the machine's pulse on the far
// end (topnav.go) is row zero on every page, and it never moves. The strip of
// chats (chattabs.go) is the next row ONLY while a conversation is in front:
// a chat, a room inside one, the grid of open tabs, the run's work tab. On a
// place the strip is not drawn, so the head is the nav, the rule and a blank
// ([placeHeadRows]) and the body starts one row higher. In a chat the strip
// sits between the nav and the rule ([chatHeadRows]).
// end (topnav.go) is row zero on every page, and it never moves. Under it
// sits an air row ([navAirRow]): the breathing room that keeps the places'
// words from pressing on the strip and the rule. The strip of chats
// (chattabs.go) is the next row ONLY while a conversation is in front: a
// chat, a room inside one, the grid of open tabs, the run's work tab. On a
// place the strip is not drawn, so the head is the nav, its air row, the rule
// and a blank ([placeHeadRows]) and the body starts one row higher. In a chat
// the strip sits between the air row and the rule ([chatHeadRows]).
//
// THE STRIP USED TO BE DRAWN ON EVERY PAGE, including places, where it repeated
// the teams rail and home's sessions and offered a jump from a place to one
// chat that is not a journey anyone takes. `chats` on row zero, `alt+k` and
// home's sessions list are the ways between them (owner, 2026-09-25).
//
// home's sessions list are the ways between them (owner, 2026-09-25).//
// THE PLACES AND THE STRIP USED TO SHARE ROW ONE AND TAKE TURNS ON IT. A place
// drew its bar there and a conversation drew its strip there, so the words a
// hand was reaching for changed under it on every walk (owner, 2026-09-24).
Expand All @@ -35,9 +37,10 @@ package tui3
// under it, where a place's heading is (PLACES-AUDIT.md, lane K).

// headRows is the head, drawn at `width` in `pal`. While a conversation is in
// front, `strip` (the frame's [app.tabsRow]) is the second row and the head is
// [chatHeadRows] rows. On a place `strip` is ignored and the head is
// [placeHeadRows]: the nav, the rule, a blank. A frame that draws fewer, a
// front, `strip` (the frame's [app.tabsRow]) is the row under the air row and
// the head is [chatHeadRows] rows. On a place `strip` is ignored and the head
// is [placeHeadRows]: the nav, its air row, the rule, a blank. A frame that
// draws fewer, a
// terminal under the strip's floors, takes a prefix of it, so the rows a frame
// draws and the rows it charges are one count.
//
Expand Down Expand Up @@ -69,9 +72,9 @@ func (a *app) headRows(width int, strip string, pal palette) []string {
if !a.stripInHead() {
a.chatTabHits = nil
a.wall.chip, a.wall.door = hudSpan{}, hudSpan{}
return []string{nav, line, ""}
return []string{nav, "", line, ""}
}
return []string{nav, strip, line, ""}
return []string{nav, "", strip, line, ""}
}

// stripInHead reports whether this frame draws the chat strip. A conversation
Expand Down
38 changes: 19 additions & 19 deletions internal/tui3/head_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import (
// ── THE ONE HEAD ────────────────────────────────────────────────────────────
//
// Row zero is the same on a conversation and on every place. A conversation
// then draws the strip, the rule and a blank ([chatHeadRows]). A place draws
// the rule and a blank and no strip ([placeHeadRows]), so its body starts one
// row higher.

// then draws the air row, the strip, the rule and a blank ([chatHeadRows]). A
// place draws the air row, the rule and a blank and no strip
// ([placeHeadRows]), so its body starts one row higher.
// headSizes are the three frames the one head is pinned at: the classic
// terminal, a tall one, and a wide one.
var headSizes = []struct{ w, h int }{{80, 24}, {80, 40}, {120, 45}}
Expand Down Expand Up @@ -65,11 +64,11 @@ func headPulseWhole() string {
return "2 want you · 1 moving · $0.14 / " + railFigure(20) + " · thu 9:49am"
}

// THE CONVERSATION'S HEAD IS THE NAV, THE STRIP, THE RULE AND A BLANK.
// The body starts under exactly those four rows. At eighty columns the pulse
// has given up the clock, the moving count and the words of `2 want you`, whose
// count stays as `2 ?`, to keep every place on the row (topnav.go's ladder);
// wider, it says everything.
// THE CONVERSATION'S HEAD IS THE NAV, THE AIR ROW, THE STRIP, THE RULE AND A
// BLANK. The body starts under exactly those five rows. At eighty columns the
// pulse has given up the clock, the moving count and the words of `2 want you`,
// whose count stays as `2 ?`, to keep every place on the row (topnav.go's
// ladder); wider, it says everything.
func TestTheConversationWearsThePlacesHead(t *testing.T) {
a := headLab(t)
for _, size := range headSizes {
Expand All @@ -89,8 +88,8 @@ func TestTheConversationWearsThePlacesHead(t *testing.T) {
if strings.Contains(head[tabStripRow], " home ") || !strings.Contains(head[tabStripRow], a.chatDisplayName()) {
t.Fatalf("at %dx%d the strip is not under the nav, on its own:\n%q", size.w, size.h, head[tabStripRow])
}
if head[2] != strings.Repeat("─", size.w) || head[3] != "" {
t.Fatalf("at %dx%d the head does not close with a rule and a blank:\n%q\n%q", size.w, size.h, head[2], head[3])
if head[tabStripRow+1] != strings.Repeat("─", size.w) || head[tabStripRow+2] != "" {
t.Fatalf("at %dx%d the head does not close with a rule and a blank:\n%q\n%q", size.w, size.h, head[tabStripRow+1], head[tabStripRow+2])
}
if a.headHeight() != chatHeadRows || a.bodyTop() != chatHeadRows+a.stripHeight() {
t.Fatalf("at %dx%d the head draws %d rows and is charged %d, body at %d",
Expand All @@ -108,10 +107,11 @@ func TestTheConversationWearsThePlacesHead(t *testing.T) {
// (roompanel.go's [app.roomOrganized]).
var headFrameSizes = []struct{ w, h int }{{80, 24}, {120, 45}, {180, 45}}

// A TASK ROOM SPENDS THE CONVERSATION'S HEAD ABOVE ITS BODY: the nav, the
// strip, the rule and a blank, and the room's own trail on the first row under
// them. A place spends three rows and no strip, so its body starts where the
// chat's strip is. The room used to lay the trail where the rule stands
// A TASK ROOM SPENDS THE CONVERSATION'S HEAD ABOVE ITS BODY: the nav, the air
// row, the strip, the rule and a blank, and the room's own trail on the first
// row under them. A place spends four rows and no strip, so its body starts
// one row above a conversation's. The room used to lay the trail where the
// rule stands
// (PLACES-AUDIT.md, lane K).
func TestATaskRoomSpendsThePlacesHeadAboveItsBody(t *testing.T) {
room, chat := crumbApp(t), headLab(t)
Expand All @@ -127,13 +127,13 @@ func TestATaskRoomSpendsThePlacesHeadAboveItsBody(t *testing.T) {
}
f.a.touch()
head := headOf(t, f.a)
ruleAt := 2
ruleAt := tabStripRow + 1
if f.to != pageNone {
ruleAt = 1
ruleAt = placeHeadRows - 2
}
if !strings.HasPrefix(head[0], " "+plain(f.a.pal.wordmark(f.a.width))) || head[ruleAt] != strings.Repeat("─", size.w) || strings.TrimSpace(head[ruleAt+1]) != "" {
t.Fatalf("at %dx%d %s's head is not the nav, then %s:\n%s",
size.w, size.h, f.where, map[bool]string{true: "the rule and a blank", false: "the strip, the rule and a blank"}[f.to != pageNone], strings.Join(head, "\n"))
size.w, size.h, f.where, map[bool]string{true: "the air row, the rule and a blank", false: "the air row, the strip, the rule and a blank"}[f.to != pageNone], strings.Join(head, "\n"))
}
if f.to != pageNone {
f.a.showPage(pageNone)
Expand Down Expand Up @@ -242,7 +242,7 @@ func TestThePulseOverAChatIsThePulseOverAPlace(t *testing.T) {
if !strings.Contains(place[navRow], "home") || !strings.Contains(place[navRow], "sessions") {
t.Fatalf("the nav is not on the first row: %q", place[navRow])
}
if strings.Contains(place[tabStripRow], a.chatDisplayName()) || !strings.HasPrefix(place[1], "─") {
if strings.Contains(place[tabStripRow], a.chatDisplayName()) || !strings.HasPrefix(place[tabStripRow], "─") {
t.Fatalf("the tasks place drew a strip under the nav: %q", place[tabStripRow])
}
a.showPage(pageNone)
Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/header_home_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func TestTheHeadAroundTheStripIsInertAndHomeHasPlainHover(t *testing.T) {

// THE STRIP KEEPS ITS ACTIVE TAB AND THE HEAD KEEPS ITS SHAPE AT EVERY SIZE. The
// head used to grow a row of air over the strip at thirty-two rows and another
// under it at thirty-six; it is the places' four rows now wherever the strip is
// under it at thirty-six; it is the places' five rows now wherever the strip is
// drawn at all, and nothing below the strip's own floors.
func TestTheStripKeepsItsActiveTabAndTheHeadItsShapeAtEverySize(t *testing.T) {
lab := newStartLab(t)
Expand Down
4 changes: 2 additions & 2 deletions internal/tui3/margin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (
func marginApp(t *testing.T, stand ...standing.Item) (*app, *standingPlaceFake) {
t.Helper()
a, agent := standingPlaceApp(t, stand, nil)
a.width, a.height = 140, 24
a.width, a.height = 140, 25 // the head's air row costs the frame one row
return a, agent
}

Expand Down Expand Up @@ -307,7 +307,7 @@ func TestBareStandingStillOpensThePage(t *testing.T) {
// chord answers with (standmark.go).
func TestStandingWithWordsSaysSoWhereNothingCanHoldOne(t *testing.T) {
a := newTestApp(&fakeAgent{model: "m"})
a.width, a.height = 140, 24
a.width, a.height = 140, 25 // the head's air row costs the frame one row
typeLine(t, a, "/standing always run the tests")
if !strings.Contains(plain(frame(a)), standMarkNowhere) {
t.Fatalf("the refusal is not on the frame:\n%s", plain(frame(a)))
Expand Down
4 changes: 2 additions & 2 deletions internal/tui3/onetopbar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ func TestOneTopNavOnAChatAndOnAPlace(t *testing.T) {
t.Fatalf("at %d the place drew the strip on row %d", width, py2)
}
placeRows := strings.Split(plain(frame), "\n")
if len(placeRows) <= placeHeadRows || !strings.HasPrefix(placeRows[1], "─") || strings.TrimSpace(placeRows[2]) != "" {
t.Fatalf("at %d the place's head is not the nav, the rule and a blank", width)
if len(placeRows) <= placeHeadRows || !strings.HasPrefix(placeRows[placeHeadRows-2], "─") || strings.TrimSpace(placeRows[placeHeadRows-1]) != "" {
t.Fatalf("at %d the place's head is not the nav, the air row, the rule and a blank", width)
}
lit := a.pal.onPlaces()
if !strings.Contains(frame, lit.bold(lit.accent(tabPad+"spend"+tabPad))) {
Expand Down
14 changes: 7 additions & 7 deletions internal/tui3/placebodies.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,21 @@ import (
// around them and the keyboard, which is the whole of what that wave claimed.

// placeHeadRows is how many rows every place spends before its body: the nav,
// the rule, and the blank under it (pages.go's [placeFrame]).
// the air row under it, the rule, and the blank under that (pages.go's
// [placeFrame]).
//
// THE STRIP IS NOT ONE OF THEM. It is a chat's own row, drawn only while a
// conversation is in front (head.go), so a place's body starts one row higher
// than a chat's and a click on that row is the page's.
//
// than a chat's and a click on that row is the page's.//
// IT IS A CONSTANT AND THE POINTER DEPENDS ON IT. A press arrives as a row of
// the terminal and has to become a row of the body, and the only honest way to
// subtract the head is to have exactly one number for how tall the head is.
// A head row added or removed is a change to this constant and to nothing else.
const placeHeadRows = 3
const placeHeadRows = 4

// chatHeadRows is the head while a conversation is in front: the places' three
// rows with the strip between the nav and the rule. A room inside a chat wears
// it too. A place does not.
// chatHeadRows is the head while a conversation is in front: the places' four
// rows with the strip between the air row and the rule. A room inside a chat
// wears it too. A place does not.
const chatHeadRows = placeHeadRows + 1

// placeNote is the one line a place says about what it is holding, drawn under
Expand Down
7 changes: 3 additions & 4 deletions internal/tui3/placelaws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,8 +322,7 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) {
// doors rather than counted out again here.
wantFor := func(id page, size [2]int) edges {
height := size[1]
got := edges{bar: navRow, headRule: 1, blank: placeHeadRows - 1,
footRule: height - placeFootRowsFor(id, height) + 1, box: -1, hint: height - 1}
got := edges{bar: navRow, headRule: placeHeadRows - 2, blank: placeHeadRows - 1, footRule: height - placeFootRowsFor(id, height) + 1, box: -1, hint: height - 1}
if id == pageHome {
got.box = height - 1 - boxFloor(height)
}
Expand All @@ -339,8 +338,8 @@ func TestEveryPlaceSpendsTheSameHeadAndFoot(t *testing.T) {
rows[i] = ansi.Strip(line)
}
got := edges{bar: a.tabRow, headRule: -1, blank: -1, footRule: -1, box: -1, hint: len(rows) - 1}
if len(rows) > 1 && strings.HasPrefix(rows[1], "──") {
got.headRule = 1
if len(rows) > 2 && strings.HasPrefix(rows[2], "──") {
got.headRule = 2
}
if strings.TrimSpace(rows[placeHeadRows-1]) == "" {
got.blank = placeHeadRows - 1
Expand Down
10 changes: 7 additions & 3 deletions internal/tui3/topnav.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,14 @@ import (
// frame with no head at all.
const navRow = 0

// tabStripRow is the row the chat strip is drawn on: under the nav and over
// the rule, and only while a conversation is in front (head.go).
const tabStripRow = 1
// navAirRow is the row of air under the nav, on every frame that draws a
// head: the spacing between the places' row and the strip or rule under it
// (head.go).
const navAirRow = 1

// tabStripRow is the row the chat strip is drawn on: under the nav's air row
// and over the rule, and only while a conversation is in front (head.go).
const tabStripRow = navAirRow + 1
const (
// navInset is the blank cell at each end of the row, the same inset every
// row of the head keeps.
Expand Down
30 changes: 16 additions & 14 deletions internal/tui3/topnav_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ func headRowsOf(a *app) []string {
return rows
}

// ROW ZERO IS THE SAME ON EVERY PAGE. A chat then draws the strip, the rule
// and a blank, and the body under those four rows. A place draws the rule and
// a blank and no strip, and the body starts on the next row.
func TestTheHeadIsTheSameFourRowsOnEveryPage(t *testing.T) {
// ROW ZERO IS THE SAME ON EVERY PAGE. A chat then draws the air row, the
// strip, the rule and a blank, and the body under those five rows. A place
// draws the air row, the rule and a blank and no strip, and the body starts on
// the next row.
func TestRowZeroIsTheSameOnEveryPage(t *testing.T) {
for _, width := range navWidths {
a := navChat(t)
a.width = width
Expand All @@ -70,7 +71,7 @@ func TestTheHeadIsTheSameFourRowsOnEveryPage(t *testing.T) {
if a.tabRow != navRow || a.headHeight() != chatHeadRows {
t.Fatalf("at %d the chat's nav is on row %d and its head is %d rows", width, a.tabRow, a.headHeight())
}
if !strings.Contains(chat[tabStripRow], "harbor") || !strings.HasPrefix(chat[2], "─") || strings.TrimSpace(chat[3]) != "" {
if !strings.Contains(chat[tabStripRow], "harbor") || !strings.HasPrefix(chat[tabStripRow+1], "─") || strings.TrimSpace(chat[tabStripRow+2]) != "" {
t.Fatalf("at %d the chat's head is not the nav, the strip, the rule and a blank:\n%s", width, strings.Join(chat, "\n"))
}
for _, to := range []page{pageHome, pageTeams, pageSpend} {
Expand All @@ -79,10 +80,10 @@ func TestTheHeadIsTheSameFourRowsOnEveryPage(t *testing.T) {
if a.tabRow != navRow || a.headHeight() != placeHeadRows {
t.Fatalf("at %d %s drew its nav on row %d and a %d-row head", width, to.word(), a.tabRow, a.headHeight())
}
if place[navRow] != chat[navRow] {
t.Fatalf("at %d %s moved row zero:\nchat %q\nplace %q", width, to.word(), chat[navRow], place[navRow])
if place[navRow] != chat[navRow] || place[1] != chat[1] {
t.Fatalf("at %d %s moved the nav or the air row under it:\nchat %q\nplace %q", width, to.word(), chat[navRow], place[navRow])
}
if !strings.HasPrefix(place[1], "─") || strings.TrimSpace(place[2]) != "" || strings.Contains(place[1], "harbor") {
if !strings.HasPrefix(place[placeHeadRows-2], "─") || strings.TrimSpace(place[placeHeadRows-1]) != "" || strings.Contains(place[placeHeadRows-2], "harbor") {
t.Fatalf("at %d %s drew a strip where the rule belongs:\n%s", width, to.word(), strings.Join(place, "\n"))
}
if len(a.chatTabHits) != 0 {
Expand Down Expand Up @@ -221,9 +222,10 @@ func TestAPressOnEachNavWordOpensThatPlace(t *testing.T) {
}
}

// A PRESS ON ROW 1 OF A PLACE IS THE PAGE'S. The strip is not drawn there, so
// the row under the nav is the rule, and a click on it does not open a chat.
// The page's own first row is the next one after the blank.
// A PRESS ON THE STRIP'S ROW OF A PLACE IS THE PAGE'S. The strip is not drawn
// on a place, so the row where a conversation would draw it holds the rule,
// and a click on it does not open a chat. The page's own first row is the one
// after the head's blank.
func TestAPressOnRowOneOfAPlaceIsThePages(t *testing.T) {
a := navChat(t)
a.width = 160
Expand All @@ -238,11 +240,11 @@ func TestAPressOnRowOneOfAPlaceIsThePages(t *testing.T) {
t.Fatalf("%s kept strip targets", id.word())
}
if _, ok := a.tabAt(4, tabStripRow); ok {
t.Fatalf("%s still has a tab on row 1", id.word())
t.Fatalf("%s still has a tab on the strip's row", id.word())
}
rows := strings.Split(plain(frame(a)), "\n")
if len(rows) <= placeHeadRows || !strings.HasPrefix(rows[1], "─") {
t.Fatalf("%s row 1 is not the rule:\n%s", id.word(), strings.Join(rows[:placeHeadRows+1], "\n"))
if len(rows) <= placeHeadRows || !strings.HasPrefix(rows[placeHeadRows-2], "─") {
t.Fatalf("%s the strip's row is not the rule:\n%s", id.word(), strings.Join(rows[:placeHeadRows+1], "\n"))
}
drive(t, a, tea.MouseClickMsg{X: 4, Y: tabStripRow, Button: tea.MouseLeft})
if !a.at(id) || a.frontTabKey() != front {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/welcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ const welcomeUnitWidth = 76
// to type — so a small window simply opens on the prompt, which is what it
// would have done anyway.
const (
welcomeMinRows = 12
welcomeMinRows = 11 // the head's air row is paid out of the greeting's reserve
welcomeMinCols = 40
)

Expand Down
Loading
Loading