diff --git a/README.md b/README.md index 334d963..d7720da 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 | @@ -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/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")), 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]) + } +}