Skip to content
Merged
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
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
12 changes: 10 additions & 2 deletions internal/ui/projectdetail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 41 additions & 21 deletions internal/ui/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
//
Expand All @@ -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
Expand All @@ -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))
}
54 changes: 45 additions & 9 deletions internal/ui/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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}
}
Loading
Loading