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
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion internal/ui/projectlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
43 changes: 41 additions & 2 deletions internal/ui/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down
97 changes: 96 additions & 1 deletion internal/ui/util_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package ui

import (
"fmt"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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)
}
}
}
Loading