From ee3302099e7d06cd6a81c500d1d651aeaff86b71 Mon Sep 17 00:00:00 2001 From: tdwd <111124579+tdwd@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:22:18 +0200 Subject: [PATCH 1/2] Say what ^g j and ^g k do, not what they mean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They were labelled "next / previous session". Sessions sort newest-first (store/query.go:79), so j moves down the list to an older session — which reads as going backwards, and "next" is what made it read that way. The binding is not the problem and is untouched: j is down, which is what the arrow keys and every other TUI agree on. Only the claim changes. --- README.md | 2 +- internal/ui/keys.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 334d963..ddaed77 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ border. `↵` opens from either column. |---|---| | `^g d` | dashboard | | `^g s` | sessions | -| `^g j` / `^g k` | next / previous session (stays attached if it is live) | +| `^g j` / `^g k` | down / up the session list (stays attached if it is live) | | `^g 1`…`^g 9` | jump straight to that session | | `^g n` | new session | | `^g x` | stop the agent | diff --git a/internal/ui/keys.go b/internal/ui/keys.go index ba2c0d9..a78d553 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -75,7 +75,7 @@ func defaultKeys() keyMap { ThemeCmd: key.NewBinding(key.WithKeys("t"), key.WithHelp("^g t", "theme")), HelpCmd: key.NewBinding(key.WithKeys("?"), key.WithHelp("^g ?", "this help")), QuitCmd: key.NewBinding(key.WithKeys("q"), key.WithHelp("^g q", "quit")), - NextSess: key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("^g j/k", "next / previous session")), + NextSess: key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("^g j/k", "down / up the session list")), JumpSess: key.NewBinding( key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8", "9"), key.WithHelp("^g 1…9", "jump to that session")), From 48fc7b2ab53da56b4a6016404b01bca391464ebb Mon Sep 17 00:00:00 2001 From: tdwd <111124579+tdwd@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:22:27 +0200 Subject: [PATCH 2/2] Put a session's status on its title line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar card spent a third of its height on a line that said "Idle" beside a dot already saying it. The glyph moves up to the title line and that row goes, so a quiet session is two lines instead of three — and most sessions in a sidebar are quiet ones. The glyph set had to change for this to lose nothing. Idle and working were both ◉, told apart by accent against muted. That was a hint while the word sat next to it and would have been the whole answer without it, and a colour is not an answer: a low-contrast theme flattens it and a reader who cannot separate two hues never had it. Filled is busy, hollow is quiet, closed is a dot so it does not compete with idle, and a dead process is a cross because it is not a degree of running. ● working ○ idle ◆ needs you ✕ exited · closed TestStatusGlyphsAreDistinct is the regression: pairwise distinct, and one cell each, because the title line budgets a fixed width for the glyph. The card still grows a third line for what a glyph cannot say — an exit error, and the mail, claim and review badges. Those are facts about a session rather than a restatement of its dot. statusOf returns a struct rather than a fourth value. form.update grew to four and the architecture doc records what that cost; detail is the field that carries an exit error to whichever caller has room for it. The dashboard row joins it to the label as before, so nothing is lost there — that regression was in this change until it was caught. Both card tests were checked by making the third line unconditional. Both fail. --- README.md | 14 ++-- internal/ui/projectdetail.go | 12 +++- internal/ui/sidebar.go | 62 ++++++++++------ internal/ui/status.go | 54 +++++++++++--- internal/ui/status_test.go | 134 ++++++++++++++++++++++++++++++----- 5 files changed, 222 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index ddaed77..d7720da 100644 --- a/README.md +++ b/README.md @@ -284,11 +284,17 @@ that arrives without an event rather than one particular case. | Dot | Meaning | |---|---| -| `◉ Working` | a turn is running | +| `● Working` | a turn is running | | `◆ Needs you` | a permission prompt, a question, or a turn that hit an API error | -| `◉ Idle` | the turn finished | -| `◍ Exited` | the process is gone | -| `○ Closed` | never opened, or stopped | +| `○ Idle` | the turn finished | +| `✕ Exited` | the process is gone | +| `· Closed` | never opened, or stopped | + +Filled is busy, hollow is quiet. The shape carries the state on its own — the +sidebar card shows the dot without the word beside it, and a colour is no +distinction at all in a low-contrast theme or to a reader who cannot separate +two hues. The dashboard still prints the word, which is where the column is +wide enough for it. `Needs you` is the one the old heuristic could never show: an agent blocked on a prompt prints nothing, so it looked idle. diff --git a/internal/ui/projectdetail.go b/internal/ui/projectdetail.go index d13e2c0..e52ddf0 100644 --- a/internal/ui/projectdetail.go +++ b/internal/ui/projectdetail.go @@ -124,8 +124,16 @@ func (m Model) sessionLine(sess *store.Session, selected, focused bool, width in marker, titleStyle := m.cursorMarker(selected, focused) - glyph, label, style := m.statusOf(sess) - right := style.Render(glyph+" "+label) + s.Faint.Render(" "+ago(sess.CreatedAt)) + // The detail joins the label here rather than taking a line of its own as + // it does on a sidebar card. This row has width to spend and no second + // line to spend instead, and an exited session that does not say why is a + // dead end. + st := m.statusOf(sess) + text := st.label + if st.detail != "" { + text += ": " + st.detail + } + right := st.style.Render(st.glyph+" "+text) + s.Faint.Render(" "+ago(sess.CreatedAt)) title := firstLine(sess.Title, sess.Name) left := marker + s.Faint.Render(">_ ") + titleStyle.Render(title) diff --git a/internal/ui/sidebar.go b/internal/ui/sidebar.go index 8b8076f..4fb3d78 100644 --- a/internal/ui/sidebar.go +++ b/internal/ui/sidebar.go @@ -65,7 +65,15 @@ func (m Model) renderSidebar(width, height int) string { return lipgloss.NewStyle().Width(width).Height(height).Render(strings.Join(lines, "\n")) } -// sessionCard renders one session as three lines: title, branch, status. +// sessionCard renders one session as two lines, title and branch, plus a third +// only when there is something the first two cannot hold. +// +// The status glyph sits on the title line. It used to have a line of its own +// with the state spelled out beside it, which spent a third of every card on a +// word that the shape already says — see the glyph constants for why the shape +// now carries it alone. What still earns a line is an exit error, and the mail, +// claim and review badges: those are facts about a session, not a restatement +// of its dot, and most cards have none of them. // // A nth of 1..9 prefixes the title with that jump number; anything else, zero // included, leaves the card unnumbered and the title two columns longer. @@ -79,9 +87,9 @@ func (m Model) sessionCard(sess *store.Session, active bool, width, nth int) []s titleStyle = s.Value.Bold(true) } - // The number sits on the title line only. Indenting all three lines under - // it would redraw the whole card on every ^g, and the branch and status - // lines are already keyed to the bar. + // The number sits on the title line only. Indenting the rest of the card + // under it would redraw the whole thing on every ^g, and the other lines + // are already keyed to the bar. num, numW := "", 0 if nth >= 1 && nth <= 9 { num, numW = s.Accent.Render(strconv.Itoa(nth))+" ", 2 @@ -94,13 +102,21 @@ func (m Model) sessionCard(sess *store.Session, active bool, width, nth int) []s ref = "· project directory" } - glyph, label, style := m.statusOf(sess) + st := m.statusOf(sess) + + // What goes on the third line, if anything does. Built as parts and joined + // rather than concatenated with leading spaces, so an absent first entry + // does not indent the rest. + var extra []string + if st.detail != "" { + extra = append(extra, st.style.Render(st.detail)) + } // Claims and waiting mail are the two things about a sibling worth seeing // at a glance: one shows two agents in the same files, the other shows a // message nobody has collected. Mail is pull-only, so without this an // unread message is invisible until the agent happens to ask. - // Mail first, because the status line truncates from the right and the two + // Mail first, because the line truncates from the right and the two // badges are not equally urgent. Unread mail means a sibling asked for // something and nobody has read it; a claim is a fact about files. // @@ -110,10 +126,10 @@ func (m Model) sessionCard(sess *store.Session, active bool, width, nth int) []s // way, so the order here decides only what a narrow column drops. if m.coord != nil { if n := m.coord.Unread(sess.ID); n > 0 { - label += s.Accent.Render(fmt.Sprintf(" ✉ %d", n)) + extra = append(extra, s.Accent.Render(fmt.Sprintf("✉ %d", n))) } if n := m.coord.ClaimCount(sess.ID); n > 0 { - label += s.Faint.Render(fmt.Sprintf(" ⊙ %d", n)) + extra = append(extra, s.Faint.Render(fmt.Sprintf("⊙ %d", n))) } // A spawned review is the one thing here that costs money while // nobody is watching it: it has no pane, and the session that asked @@ -135,24 +151,28 @@ func (m Model) sessionCard(sess *store.Session, active bool, width, nth int) []s // a sixth should not read as though it had spent nothing. switch b := m.coord.Analyses(sess.ID); { case b.Running > 0 && b.Spent > 0: - label += s.Accent.Render(fmt.Sprintf(" ⚗ %d · %s · $%.2f", - b.Running, compactCount(b.Output), b.Spent)) + extra = append(extra, s.Accent.Render(fmt.Sprintf("⚗ %d · %s · $%.2f", + b.Running, compactCount(b.Output), b.Spent))) case b.Running > 0: - label += s.Accent.Render(fmt.Sprintf(" ⚗ %d · %s", b.Running, compactCount(b.Output))) + extra = append(extra, s.Accent.Render(fmt.Sprintf("⚗ %d · %s", b.Running, compactCount(b.Output)))) case b.Spent > 0: - label += s.Faint.Render(fmt.Sprintf(" ⚗ $%.2f", b.Spent)) + extra = append(extra, s.Faint.Render(fmt.Sprintf("⚗ $%.2f", b.Spent))) } } - // The status line is truncated like the other two. It is the one that grows - // without a bound the caller controls — "Needs you" plus two badges with - // two-digit counts passes 24 columns, and an over-long line does not widen - // the sidebar, it wraps: the card becomes four rows, the remainder starts - // at column 0 with no bar, and the column is a row taller than the pane - // beside it. - return []string{ - bar + num + titleStyle.Render(truncate(title, width-3-numW)), + // The glyph and its trailing space cost the title two columns, which is + // what the removed line gives back many times over. + lines := []string{ + bar + num + st.style.Render(st.glyph) + " " + titleStyle.Render(truncate(title, width-5-numW)), bar + s.Faint.Render(truncate("⑂ "+ref, width-3)), - bar + truncate(style.Render(glyph+" "+label), width-3), } + if len(extra) == 0 { + return lines + } + // Truncated like the other two. This is the line whose length the caller + // does not control — an exit error plus two badges with two-digit counts + // passes 24 columns — and an over-long line does not widen the sidebar, it + // wraps: the card gains a row, the remainder starts at column 0 with no + // bar, and the column ends up taller than the pane beside it. + return append(lines, bar+truncate(strings.Join(extra, " "), width-3)) } diff --git a/internal/ui/status.go b/internal/ui/status.go index 84d4bcb..613a237 100644 --- a/internal/ui/status.go +++ b/internal/ui/status.go @@ -33,6 +33,41 @@ import ( // test that sleeps for ten seconds. var staleWorkingReport = 10 * time.Second +// The status glyphs. +// +// Shape carries the state and colour only reinforces it. Idle and working were +// both ◉ and told apart by accent against muted, which is no distinction at all +// in a low-contrast theme or to a reader who cannot separate the two hues — and +// the sidebar card now shows the glyph without the word beside it, so the shape +// is all there is. +// +// Filled is busy and hollow is quiet, which is the reading people arrive at +// unprompted. Closed is a dot rather than a hollow circle so it does not +// compete with idle, and a dead process is a cross rather than a circle at all: +// it is not a degree of running. +const ( + glyphClosed = "·" + glyphIdle = "○" + glyphWorking = "●" + glyphWaiting = "◆" + glyphExited = "✕" +) + +// status is what a session's dot, and the words beside it, say. +// +// A struct rather than a fourth return value. form.update grew to four and the +// architecture doc records what that cost — every caller and every test had to +// be updated, and the next field would cost the same again. +type status struct { + glyph string + // label is the state in words, for the places with room to print it. + label string + // detail is what neither the glyph nor the label can carry: the error a + // dead process left behind. Empty for every other state. + detail string + style lipgloss.Style +} + // statusOf maps a session to its sidebar dot. // // The order is deliberate. No runner at all means the session was never @@ -44,17 +79,18 @@ var staleWorkingReport = 10 * time.Second // nothing — cathode, or claude before its first turn. // // The one exception is a stale "working": see staleWorkingReport. -func (m Model) statusOf(sess *store.Session) (glyph, label string, style lipgloss.Style) { +func (m Model) statusOf(sess *store.Session) status { s := m.styles r, ok := m.runners[sess.ID] if !ok { - return "○", "Closed", s.Faint + return status{glyph: glyphClosed, label: "Closed", style: s.Faint} } if r.Status() == agent.Exited { + st := status{glyph: glyphExited, label: "Exited", style: s.Error} if err := r.Err(); err != nil { - return "◍", "Exited: " + truncate(err.Error(), 20), s.Error + st.detail = truncate(err.Error(), 20) } - return "◍", "Exited", s.Error + return st } if m.coord != nil { if state, reported := m.coord.StateOf(sess.ID); reported { @@ -63,16 +99,16 @@ func (m Model) statusOf(sess *store.Session) (glyph, label string, style lipglos if r.Quiet() > staleWorkingReport { break // the report outlived the turn; the PTY knows better } - return "◉", "Working", s.Accent + return status{glyph: glyphWorking, label: "Working", style: s.Accent} case coord.StateWaiting: - return "◆", "Needs you", s.Accent + return status{glyph: glyphWaiting, label: "Needs you", style: s.Accent} case coord.StateIdle: - return "◉", "Idle", s.Muted + return status{glyph: glyphIdle, label: "Idle", style: s.Muted} } } } if r.Status() == agent.Working { - return "◉", "Working", s.Accent + return status{glyph: glyphWorking, label: "Working", style: s.Accent} } - return "◉", "Idle", s.Muted + return status{glyph: glyphIdle, label: "Idle", style: s.Muted} } diff --git a/internal/ui/status_test.go b/internal/ui/status_test.go index 02dbaee..589aca1 100644 --- a/internal/ui/status_test.go +++ b/internal/ui/status_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "context" "github.com/tripledownab/deck/internal/agent" @@ -37,7 +38,7 @@ func TestStatusOfPrefersTruthOverGuess(t *testing.T) { wantLabel string }{{ name: "a session that was never opened is closed, not guessed at", - wantGlyph: "○", + wantGlyph: glyphClosed, wantLabel: "Closed", }, { name: "a dead process outranks whatever it last reported", @@ -45,26 +46,26 @@ func TestStatusOfPrefersTruthOverGuess(t *testing.T) { exited: true, report: coord.StateWorking, reported: true, - wantGlyph: "◍", + wantGlyph: glyphExited, wantLabel: "Exited", }, { name: "a reported wait is the dot the heuristic could never show", script: "sleep 30", report: coord.StateWaiting, reported: true, - wantGlyph: "◆", + wantGlyph: glyphWaiting, wantLabel: "Needs you", }, { name: "a reported state outranks a quiet PTY", script: "sleep 30", report: coord.StateWorking, reported: true, - wantGlyph: "◉", + wantGlyph: glyphWorking, wantLabel: "Working", }, { name: "with nothing reported the heuristic still answers", script: "sleep 30", - wantGlyph: "◉", + wantGlyph: glyphIdle, wantLabel: "Idle", }, { // An interrupted turn ends without an event to observe, so the @@ -74,7 +75,7 @@ func TestStatusOfPrefersTruthOverGuess(t *testing.T) { report: coord.StateWorking, reported: true, staleAfter: time.Nanosecond, - wantGlyph: "◉", + wantGlyph: glyphIdle, wantLabel: "Idle", }} { t.Run(tc.name, func(t *testing.T) { @@ -114,25 +115,25 @@ func TestStatusOfPrefersTruthOverGuess(t *testing.T) { c.Report(sess.ID, tc.report) } - glyph, label, _ := m.statusOf(sess) - if glyph != tc.wantGlyph || label != tc.wantLabel { - t.Errorf("statusOf = %q %q, want %q %q", glyph, label, tc.wantGlyph, tc.wantLabel) + got := m.statusOf(sess) + if got.glyph != tc.wantGlyph || got.label != tc.wantLabel { + t.Errorf("statusOf = %q %q, want %q %q", got.glyph, got.label, tc.wantGlyph, tc.wantLabel) } }) } } -// TestSidebarSurvivesALoadedStatusLine keeps the status line inside the +// TestSidebarSurvivesALoadedBadgeLine keeps the card's third line inside the // column. // -// It is the one card line whose length the caller does not control: the label -// grows with the claim and mail counts, and "Needs you" is two cells longer -// than "Working". An over-long line does not widen the sidebar, it wraps — the -// card becomes four rows, the remainder starts at column 0 with no bar, and -// the column ends up a row taller than the pane it is joined to. So the -// assertion is on the row count as much as the width; checking width alone -// misses it entirely, because every wrapped row is narrow. -func TestSidebarSurvivesALoadedStatusLine(t *testing.T) { +// It is the one card line whose length the caller does not control: it grows +// with the claim and mail counts and with an exit error, where the title and +// branch are truncated against a width the caller passes in. An over-long line +// does not widen the sidebar, it wraps — the card gains a row, the remainder +// starts at column 0 with no bar, and the column ends up taller than the pane +// it is joined to. So the assertion is on the row count as much as the width; +// checking width alone misses it entirely, because every wrapped row is narrow. +func TestSidebarSurvivesALoadedBadgeLine(t *testing.T) { c, err := coord.Start(t.TempDir()) if err != nil { t.Fatalf("coordinator: %v", err) @@ -250,3 +251,100 @@ func waitForBadge(t *testing.T, m Model, want string) { } t.Fatalf("sidebar never showed %q:\n%s", want, m.renderSidebar(34, 14)) } + +// TestStatusGlyphsAreDistinct is the regression for the state the card cannot +// spell out any more. +// +// Idle and working were both ◉, separated by accent against muted. The sidebar +// carried the word beside the dot, so the colour was a hint rather than the +// whole answer. It is the whole answer now, and a colour is not one: a +// low-contrast theme flattens it, and a reader who cannot separate two hues +// never had it. +func TestStatusGlyphsAreDistinct(t *testing.T) { + seen := map[string]string{} + for _, g := range []struct{ name, glyph string }{ + {"closed", glyphClosed}, + {"idle", glyphIdle}, + {"working", glyphWorking}, + {"waiting", glyphWaiting}, + {"exited", glyphExited}, + } { + if first, dup := seen[g.glyph]; dup { + t.Errorf("%s and %s are both %q, so the card cannot tell them apart", first, g.name, g.glyph) + } + seen[g.glyph] = g.name + // One cell each. The sidebar budgets the title line by subtracting a + // fixed width for the glyph, so a two-cell one silently eats a column + // of every session name. + if w := ansi.StringWidth(g.glyph); w != 1 { + t.Errorf("%s glyph %q is %d cells wide, want 1", g.name, g.glyph, w) + } + } +} + +// TestSessionCardSpendsTwoLinesOnAQuietSession is the saving this shape exists +// for. A third of every card went on a line that said "Idle" beside a dot +// already saying it, and most sessions in a sidebar are quiet ones. +func TestSessionCardSpendsTwoLinesOnAQuietSession(t *testing.T) { + st := &store.State{} + p := st.AddProject(store.Project{Name: "demo", Path: "/d"}) + sess := st.AddSession(store.Session{ + ProjectID: p.ID, Name: "swift-otter-aaaa", Title: "a session", Branch: "session/x", + }) + m := New(st, "bash", nil) + + card := m.sessionCard(sess, false, 30, 0) + + if len(card) != 2 { + t.Fatalf("card is %d lines, want 2:\n%s", len(card), strings.Join(card, "\n")) + } + // The glyph has to be on the title line, or the state is not visible at all + // now that the word is gone. + if !strings.Contains(card[0], glyphClosed) { + t.Errorf("the status glyph is not on the title line: %q", card[0]) + } + if !strings.Contains(card[0], "a session") { + t.Errorf("the title is not on the title line: %q", card[0]) + } +} + +// TestSessionCardGrowsALineForWhatAGlyphCannotSay is the other half. Badges and +// an exit error are facts about a session rather than a restatement of its dot, +// so they still earn the row the state no longer does. +func TestSessionCardGrowsALineForWhatAGlyphCannotSay(t *testing.T) { + c, err := coord.Start(t.TempDir()) + if err != nil { + t.Fatalf("coordinator: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + + st := &store.State{} + p := st.AddProject(store.Project{Name: "demo", Path: t.TempDir()}) + sess := st.AddSession(store.Session{ + ProjectID: p.ID, Name: "swift-otter-aaaa", Title: "a session", Dir: t.TempDir(), + }) + m := New(st, "bash", nil).WithCoordinator(c) + + if n := len(m.sessionCard(sess, false, 30, 0)); n != 2 { + t.Fatalf("card is %d lines before any badge, so this proves nothing", n) + } + + c.Register(coord.Session{ID: sess.ID, ProjectID: p.ID, Name: sess.Name, Dir: sess.Dir}) + c.Register(coord.Session{ID: "sib", ProjectID: p.ID, Name: "wily-crane-bbbb", Dir: sess.Dir}) + if _, err := c.Send("sib", sess.Name, "hi"); err != nil { + t.Fatalf("send: %v", err) + } + + card := m.sessionCard(sess, false, 30, 0) + if len(card) != 3 { + t.Fatalf("card is %d lines with unread mail, want 3:\n%s", len(card), strings.Join(card, "\n")) + } + if !strings.Contains(card[2], "✉") { + t.Errorf("the third line does not carry the badge that earned it: %q", card[2]) + } + // No leading gap: the badges are joined, not concatenated with the two + // spaces that used to separate them from a label in front. + if strings.HasPrefix(strings.TrimPrefix(card[2], " "), " ") { + t.Errorf("the badge line is indented past the bar: %q", card[2]) + } +}