From af6016a70b04c397ac6327d1d2f3e365fc29a1f0 Mon Sep 17 00:00:00 2001 From: tdwd <111124579+tdwd@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:55:17 +0200 Subject: [PATCH 1/2] Mark a list column that continues off screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both dashboard columns scroll: window keeps the cursor visible and slides only when it has to. Nothing said so. A list that fits and one that is cut looked identical, and arrowing past the edge was the only way to find out which you were looking at. A clipped edge is now the ellipsis truncate already appends, which means "cut off, there is more" everywhere else in the frame. One glyph rather than a second symbol for the same fact. No count. The project list's lines are projects, the sidebar's are thirds of a session card, and one number cannot be right in both columns. Which edge it sits on is the direction. Three things the markers must not do, each pinned: - Cover the cursor. They cost the first and last row, and a cursor hidden behind one would make the arrows look like they had stopped working at the edge of a long list. window centres focus, so the row it is on is never an edge when a marker goes there — checked across every focus at heights 3 to 9. - Edit the list. The slice window returns aliases its input, so marking in place would replace real rows in the caller's own list. - Appear on a list that fits, or on a column too short to spare the rows. Below three lines a clipped column would be all marker. Rendered at both call sites to check it rather than assuming: the marker lands in the cursor's own column in the project list, and replaces a card line cleanly in the sidebar. --- internal/ui/projectlist.go | 2 +- internal/ui/sidebar.go | 2 +- internal/ui/util.go | 43 ++++++++++++++++- internal/ui/util_test.go | 97 +++++++++++++++++++++++++++++++++++++- 4 files changed, 139 insertions(+), 5 deletions(-) diff --git a/internal/ui/projectlist.go b/internal/ui/projectlist.go index 19804ba..fdebe1f 100644 --- a/internal/ui/projectlist.go +++ b/internal/ui/projectlist.go @@ -58,7 +58,7 @@ func (m Model) renderProjectList(width, height int) string { } bodyH := height - 1 // the top border takes a row - lines = window(lines, m.projectIx+2, bodyH) + lines = window(lines, m.projectIx+2, bodyH, " "+s.Faint.Render(moreGlyph)) return m.columnStyle(m.focus == colProjects). Width(width).Height(bodyH). Render(strings.Join(lines, "\n")) diff --git a/internal/ui/sidebar.go b/internal/ui/sidebar.go index 8b8076f..0ebadd0 100644 --- a/internal/ui/sidebar.go +++ b/internal/ui/sidebar.go @@ -58,7 +58,7 @@ func (m Model) renderSidebar(width, height int) string { } } - lines = window(lines, selected, height) + lines = window(lines, selected, height, s.Faint.Render(moreGlyph)) for i, l := range lines { lines[i] = " " + l } diff --git a/internal/ui/util.go b/internal/ui/util.go index 9fc68a7..ce92608 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -57,9 +57,32 @@ func clip(lines []string, height int) []string { return lines } +// moreGlyph marks a column edge with content past it. +// +// The same glyph truncate appends, and deliberately so: it already means "cut +// off, there is more" everywhere else in the frame, and a second symbol for the +// same fact is a second thing to learn. +// +// One character, so it fits the narrowest column Deck will draw, and it carries +// no count: the project list's lines are projects, while the sidebar's are +// thirds of a session card, so any number would be right in one column and +// wrong in the other. Which edge it sits on is the direction. +const moreGlyph = "…" + // window returns the height-line slice of lines that keeps focus visible, // scrolling only when it has to. -func window(lines []string, focus, height int) []string { +// +// A clipped edge becomes more, so a column that continues off screen says so. +// Without it a list that fits and one that is cut look identical, and arrowing +// past the edge is the only way to find out which you are looking at. Pass "" +// to mark nothing. +// +// The marker arrives already styled. This file draws and does not know the +// palette. +// +// Nothing is marked below three lines. The markers cost the first and last row, +// and a two-line column would be all marker and no content. +func window(lines []string, focus, height int, more string) []string { if height <= 0 { return nil } @@ -73,7 +96,23 @@ func window(lines []string, focus, height int) []string { if start > len(lines)-height { start = len(lines) - height } - return lines[start : start+height] + out := lines[start : start+height] + if more == "" || height < 3 { + return out + } + // Copied before overwriting: the slice above aliases the caller's lines, + // and marking in place would edit the list itself rather than this view of + // it. Centring keeps focus off both edges whenever a marker goes there, so + // no marker can land on the row the cursor is meant to be showing. + marked := make([]string, height) + copy(marked, out) + if start > 0 { + marked[0] = more + } + if start+height < len(lines) { + marked[height-1] = more + } + return marked } // firstLine returns the first line of primary, or fallback when primary is diff --git a/internal/ui/util_test.go b/internal/ui/util_test.go index 620d9a8..ed71b84 100644 --- a/internal/ui/util_test.go +++ b/internal/ui/util_test.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" "testing" "time" @@ -62,7 +63,7 @@ func TestWindowKeepsFocusVisible(t *testing.T) { lines[i] = strings.Repeat("x", i+1) } for _, focus := range []int{0, 10, 19} { - got := window(lines, focus, 5) + got := window(lines, focus, 5, "") if len(got) != 5 { t.Fatalf("window height = %d, want 5", len(got)) } @@ -111,3 +112,97 @@ func TestPlural(t *testing.T) { } } } + +// TestWindowMarksAClippedEdge is the gap this closes: a column that fits and +// one that is cut looked identical, so the only way to learn there was more was +// to arrow past the edge. +func TestWindowMarksAClippedEdge(t *testing.T) { + lines := make([]string, 20) + for i := range lines { + lines[i] = fmt.Sprintf("line%d", i) + } + + top := window(lines, 0, 5, moreGlyph) + if top[0] == moreGlyph { + t.Error("marked above the first line, where there is nothing above") + } + if top[4] != moreGlyph { + t.Errorf("bottom row = %q, want the marker", top[4]) + } + + middle := window(lines, 10, 5, moreGlyph) + if middle[0] != moreGlyph || middle[4] != moreGlyph { + t.Errorf("mid-list window = %q, want both edges marked", middle) + } + + end := window(lines, 19, 5, moreGlyph) + if end[0] != moreGlyph { + t.Errorf("top row = %q, want the marker", end[0]) + } + if end[4] == moreGlyph { + t.Error("marked below the last line, where there is nothing below") + } +} + +// TestWindowNeverMarksOverTheCursor is the property the markers rest on. They +// cost the first and last row, and a cursor hidden behind one would make the +// arrow keys appear to stop working at the edge of a long list. +func TestWindowNeverMarksOverTheCursor(t *testing.T) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%d", i) + } + for height := 3; height <= 9; height++ { + for focus := range lines { + got := window(lines, focus, height, moreGlyph) + found := false + for _, l := range got { + if l == lines[focus] { + found = true + } + } + if !found { + t.Fatalf("height %d focus %d: the cursor row is not in %q", height, focus, got) + } + } + } +} + +// TestWindowLeavesAFittingListAlone keeps the marker off a list that is whole. +// A column saying there is more when there is not is worse than one saying +// nothing, because it sends the reader looking. +func TestWindowLeavesAFittingListAlone(t *testing.T) { + lines := []string{"a", "b", "c"} + got := window(lines, 0, 5, moreGlyph) + for _, l := range got { + if l == moreGlyph { + t.Errorf("marked a list that fits: %q", got) + } + } +} + +// TestWindowDoesNotMarkATinyColumn covers the degenerate height. The markers +// take the first and last row, so at two lines a clipped column would be all +// marker and no content. +func TestWindowDoesNotMarkATinyColumn(t *testing.T) { + lines := []string{"a", "b", "c", "d", "e"} + got := window(lines, 2, 2, moreGlyph) + for _, l := range got { + if l == moreGlyph { + t.Errorf("marked a two-line column: %q", got) + } + } +} + +// TestWindowDoesNotEditTheList pins the copy. The slice window takes aliases +// its input, so marking in place would replace real rows in the caller's own +// list rather than in this view of it. +func TestWindowDoesNotEditTheList(t *testing.T) { + lines := []string{"a", "b", "c", "d", "e", "f", "g"} + window(lines, 3, 3, moreGlyph) + for i, l := range lines { + if l == moreGlyph { + t.Errorf("line %d of the caller's list was overwritten: %q", i, lines) + } + } +} From 3d090aa4ca3e0b1dd7f5704ca1ce32480236287e Mon Sep 17 00:00:00 2001 From: tdwd <111124579+tdwd@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:55:32 +0200 Subject: [PATCH 2/2] Describe both outcomes of x in the README PR #12 gave x a modal with Close and Delete, and updated the key table, but left the prose under "Sessions and worktrees" describing only closing. It was not wrong, it was half the feature: a reader of that section would not learn Delete exists, or that nothing is forced. Docs only. --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 334d963..ceb2984 100644 --- a/README.md +++ b/README.md @@ -171,8 +171,19 @@ the agent — see **Choosing the agent** below. Session names are `scheming-hawk-jhgk`: two words you can say out loud plus a suffix that makes the branch unique. -Closing a session stops the agent and forgets the session. It leaves the -worktree on disk, because it may hold uncommitted work. The footer says where. +`x` asks what to do with the worktree. **Close** stops the agent, forgets the +session, and leaves the worktree on disk because it may hold uncommitted work — +the footer says where. **Delete** removes the worktree and the branch, and the +row carries what that session changed so you are answering a fact rather than a +warning. Close is where the cursor starts. + +Nothing is forced. A worktree holding modified or untracked files is refused and +the session stays, so you can open it again, commit, and delete it after. If the +tree is clean but its branch holds unmerged commits, the worktree goes and the +branch is kept — the notice names it. + +A session that ran in the project directory has no worktree of its own, so it +skips the question and closes. ## Agents coordinating