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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ The preview always shows **what that scope produced** — PRs, Jira issues, arti

Rows read as a **timeline**: every output in the order it first appeared, stamped with that time, kinds interleaved — a day is lived in time, and "what happened after the PR went up" is the question the pane is usually asked. An output whose first mention falls on another date (a long-lived session carrying a ref in) shows its full date rather than a bare time that would belong to the wrong day, and a `~` marks a time taken from the producing session because the output records no entry of its own (plan slugs, and refs extracted by an older build).

To read one kind at a time, the pane is **tabbed**: `All` plus a tab for each kind the scope produced (`PRs`, `Jira`, `Artifacts`, `Plans`), each carrying its own count so the bar doubles as the day's rollup. `tab`/`shift+tab` switches — the same keys that rotate preview modes on a session row, since a day row has no preview modes to rotate. Kinds are tabs rather than sections in one list because a busy day produces 500+ outputs, and stacked sections put the later kinds hundreds of lines below the fold. Every tab keeps the one chronology, and the selected tab is sticky as you walk between dates, so "what PRs did each day produce" stays a single keypress per day; a day with none of that kind says so rather than silently falling back to `All`.
To read one kind at a time, the pane is **tabbed**: `All` plus a tab for each kind the scope produced (`PRs`, `Jira`, `Artifacts`, `Plans`), each carrying its own count so the bar doubles as the day's rollup. `tab`/`shift+tab` walks the bar, and the number keys jump straight to a tab — `1` is `All` and the rest follow the bar **as rendered**, so `4` is whatever sits fourth on screen (the bar drops kinds the day produced none of, so a fixed digit-to-kind table would point at labels that are not there). The digits are the same keys that select preview modes on a session row; a day row has no preview modes, so they address the only axis it has. Kinds are tabs rather than sections in one list because a busy day produces 500+ outputs, and stacked sections put the later kinds hundreds of lines below the fold. Every tab keeps the one chronology, and the selected tab is sticky as you walk between dates, so "what PRs did each day produce" stays a single keypress per day; a day with none of that kind says so rather than silently falling back to `All`.

Every output row carries the session that produced it as an anchor (`a1b2c3 · ~/src/repo`). Focus the preview and press `Enter` on a row to land in that conversation **at the message where the output first appeared** — the digest tells you *what* came out, and the anchor is how you get to *how*. `o` opens the output itself (a PR, Jira issue or artifact in the browser), `y` copies its URL or path, and `x` lists every action that applies to the row (see [Output Row Actions](#output-row-actions-x)). Outputs referenced from several sessions collapse to one row with a `+N` spread marker, anchored to the earliest session (where the work happened, not where it was later quoted) — and the jump lands in *that* session, at *its* first mention.

Expand Down
48 changes: 45 additions & 3 deletions internal/tui/daypane.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,9 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, day time.Time,
// belongs to the list (it folds the row); focused, the keys are this pane's,
// and saying otherwise sent people to the wrong action.
if a.sessSplit.Focus {
sb.WriteString(dimStyle.Render("↵ jumps to where it first appeared • o opens it • y copies • x lists every action for the row • tab switches kind • ↑↓ moves between outputs"))
sb.WriteString(dimStyle.Render("↵ jumps to where it first appeared • o opens it • y copies • x lists every action for the row • 1-9/tab switch kind • ↑↓ moves between outputs"))
} else {
sb.WriteString(dimStyle.Render("↵/o folds this row • tab switches kind • → focuses this pane"))
sb.WriteString(dimStyle.Render("↵/o folds this row • 1-9/tab switch kind • → focuses this pane"))
}
return sb.String()
}
Expand Down Expand Up @@ -501,13 +501,55 @@ func (a *App) cycleDayOutputTab(delta int) {
return
}
idx := dayOutputTabIndex(tabs, a.dayOutputTabKind)
a.dayOutputTabKind = tabs[(idx+delta+len(tabs))%len(tabs)].kind
a.setDayOutputTabKind(tabs[(idx+delta+len(tabs))%len(tabs)].kind)
}

// selectDayOutputTab jumps straight to the n-th tab in the bar, 1-based and
// POSITIONAL: "1" is whatever sits leftmost (always All), "2" the next one, and
// so on, exactly as the bar reads on screen. The tabs are built per scope
// (dayOutputTabsFor drops kinds the day produced none of), so a fixed
// digit→kind table would point the digits at labels that are not there.
//
// Reports whether n addressed a tab; out of range is the caller's to swallow.
func (a *App) selectDayOutputTab(n int) bool {
tabs := dayOutputTabsFor(a.currentDayOutputRows(), a.dayOutputTabKind)
if n < 1 || n > len(tabs) {
return false
}
a.setDayOutputTabKind(tabs[n-1].kind)
return true
}

// setDayOutputTabKind applies a tab switch: repaint the pane under the new
// filter and put the cursor back on its first row. Re-selecting the active tab
// keeps the cursor where it is — nothing about the list changed, so moving it
// would be a switch the user did not ask for.
func (a *App) setDayOutputTabKind(kind session.OutputKind) {
if kind == a.dayOutputTabKind {
return
}
a.dayOutputTabKind = kind
a.dayOutputsCursor = 0
a.sessSplit.CacheKey = ""
a.renderOwningDayScope()
a.sessSplit.Preview.GotoTop()
}

// dayOutputTabHint lists the digit → tab bindings for the help overlay, in the
// bar's own order, so the hint matches what the pane is showing rather than the
// preview modes the digits carry on a session row.
func (a *App) dayOutputTabHint() string {
tabs := dayOutputTabsFor(a.currentDayOutputRows(), a.dayOutputTabKind)
parts := make([]string, 0, len(tabs))
for i, t := range tabs {
if i >= 9 {
break
}
parts = append(parts, fmt.Sprintf("%d:%s", i+1, strings.ToLower(t.label)))
}
return strings.Join(parts, " ")
}

// currentDayOutputRows rebuilds the UNFILTERED rows for whichever scope owns the
// pane. The tab bar needs every kind the scope produced, which the filtered
// a.dayOutputRows no longer knows.
Expand Down
134 changes: 134 additions & 0 deletions internal/tui/daypane_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -710,3 +711,136 @@ func TestDayProjectPaneSurvivesResize(t *testing.T) {
t.Errorf("resize replaced the day-project pane with a session digest:\n%s", after)
}
}

// timedRefOf is timedRef for a kind other than PR — the digit tests need a bar
// with several kinds on it, which one ref kind cannot produce.
func timedRefOf(kind session.RefKind, label string, ts time.Time) session.SessionRef {
return session.SessionRef{
Kind: kind, Label: label,
URL: "https://example.test/" + label,
Resolved: true,
FirstSeen: ts, FirstSeenUUID: "u-" + label,
}
}

// dayPaneTabDigitsApp builds the shape the digits were reported broken on: a day
// whose bar reads All / PRs / Jira / Plans — no Artifacts, because the day
// produced none. That gap is the whole point: the digits are POSITIONAL over the
// bar as rendered, so "4" must reach Plans even though Plans is 5th in
// dayOutputTabOrder.
func dayPaneTabDigitsApp(t *testing.T) *App {
t.Helper()
day := dayOf(0)
return dayPaneApp(t, []session.Session{{
ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: day,
PlanSlugs: []string{"a-plan"},
Refs: []session.SessionRef{
timedRef("pr-1", day.Add(time.Hour)),
timedRefOf(session.RefJira, "CPLAT-1", day.Add(2*time.Hour)),
},
}})
}

// TestDayPreviewDigitsSelectTabsPositionally is the reported bug: on a day row
// the number keys are bound to preview modes the pane cannot show, so they were
// swallowed and 1/2/3/4 did nothing at all. They now address the tab bar.
func TestDayPreviewDigitsSelectTabsPositionally(t *testing.T) {
app := dayPaneTabDigitsApp(t)

tabs := dayOutputTabsFor(app.currentDayOutputRows(), app.dayOutputTabKind)
var labels []string
for _, tb := range tabs {
labels = append(labels, tb.label)
}
if want := []string{"All", "PRs", "Jira", "Plans"}; !reflect.DeepEqual(labels, want) {
t.Fatalf("tab bar = %v, want %v — the digit mapping is defined against this bar", labels, want)
}

cases := []struct {
key rune
want session.OutputKind
rows int
}{
{'2', session.OutputPR, 1},
{'3', session.OutputJira, 1},
{'4', session.OutputPlan, 1},
{'1', "", 3},
}
for _, tc := range cases {
m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{tc.key}})
app = m.(*App)
if app.dayOutputTabKind != tc.want {
t.Errorf("key %q selected kind %q, want %q", tc.key, app.dayOutputTabKind, tc.want)
}
if len(app.dayOutputRows) != tc.rows {
t.Errorf("key %q left %d actionable rows, want %d", tc.key, len(app.dayOutputRows), tc.rows)
}
if app.dayOutputsCursor != 0 {
t.Errorf("key %q left the cursor at %d — a stale index acts on a different output", tc.key, app.dayOutputsCursor)
}
}
}

// TestDayPreviewDigitsFollowTheStickyBar pins the one trap in a positional
// mapping: the active tab stays in the bar even on a day that produced none of
// that kind (dayOutputTabsFor), which shifts every later tab's position. The
// digits must follow what is on screen, not a fixed kind table.
func TestDayPreviewDigitsFollowTheStickyBar(t *testing.T) {
day := dayOf(0)
app := dayPaneApp(t, []session.Session{{
ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: day,
PlanSlugs: []string{"a-plan"},
}})
// Carried over from another date: Jira has no rows here but keeps its tab,
// so the bar reads All / Jira / Plans and Plans sits at position 3.
app.dayOutputTabKind = session.OutputJira
app.sessSplit.CacheKey = ""
_ = app.updateSessionPreview()

m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}})
app = m.(*App)
if app.dayOutputTabKind != session.OutputPlan {
t.Errorf("key 3 selected %q, want the Plans tab that the sticky bar puts in that slot", app.dayOutputTabKind)
}
}

// TestDayPreviewOutOfRangeDigitIsSwallowed keeps the original guarantee: a digit
// with no tab behind it must not fall through to the list, where it would scroll
// the cursor instead.
func TestDayPreviewOutOfRangeDigitIsSwallowed(t *testing.T) {
app := dayPaneTabDigitsApp(t)
before := app.sessionList.Index()

for _, r := range []rune{'0', '9'} {
m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
app = m.(*App)
if app.dayOutputTabKind != "" {
t.Errorf("key %q changed the tab to %q — it addresses no tab", r, app.dayOutputTabKind)
}
if app.sessionList.Index() != before {
t.Errorf("key %q moved the list cursor to %d, want it left at %d", r, app.sessionList.Index(), before)
}
}
}

// TestSessionRowDigitsStillSwitchPreviewMode guards the other side of the
// re-point: only a day-pane row loses the preview-mode digits. On a session row
// they must still do what they always did.
func TestSessionRowDigitsStillSwitchPreviewMode(t *testing.T) {
app := dayPaneTabDigitsApp(t)
for i := 0; i < len(app.sessionList.VisibleItems()); i++ {
app.sessionList.Select(i)
if !app.selectedOwnsDayPane() {
break
}
}
if app.selectedOwnsDayPane() {
t.Fatal("could not land on a session row")
}
app.sessPreviewMode = sessPreviewConversation

m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'5'}})
if got := m.(*App).sessPreviewMode; got != sessPreviewRefs {
t.Errorf("sessPreviewMode = %v, want the refs preview 5 is bound to", got)
}
}
2 changes: 1 addition & 1 deletion internal/tui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func (a *App) sessHelpLine() string {
if a.selectedOwnsDayPane() {
// Only the day pane is tabbed by kind — the per-session digest
// has one fixed list, so advertising it there would mislead.
h += " tab:kind"
h += " 1-9/tab:kind"
}
h += " ←:unfocus p:page"
case a.sessPreviewMode == sessPreviewConversation:
Expand Down
31 changes: 25 additions & 6 deletions internal/tui/shortcuts.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"strconv"
"strings"

tea "github.com/charmbracelet/bubbletea"
Expand Down Expand Up @@ -166,9 +167,10 @@ func migrateShortcuts(sc Shortcuts) {
// A date row, and a project row nested inside one, always render that scope's
// outputs pane: updateSessionPreview() routes them to updateDayPreview /
// updateDayProjectPreview without ever consulting sessPreviewMode. Firing a
// preview-mode shortcut there changed hidden state and repainted nothing, so
// the digits looked broken — and shortcutHint() advertised all ten of them
// anyway. Only sessions have preview modes, so only sessions get the digits.
// preview-mode shortcut there changes hidden state and repaints nothing, so the
// digits would look broken. Only sessions have preview modes, so only sessions
// get the preview-mode digits — on a day row handleShortcutKey re-points them
// at that pane's own axis, its kind tabs.
//
// Plain project rows in the non-daily browser are deliberately excluded from
// this check: selectedSession() falls back to the project's most-recent session
Expand Down Expand Up @@ -219,10 +221,20 @@ func (a *App) handleShortcutKey(key string) (tea.Model, tea.Cmd, bool) {
return nil, nil, false
}

// Swallow rather than fall through: the digit is bound to a preview mode
// the current row cannot show, and letting it reach the list would scroll
// the cursor instead — a second surprise on top of the first.
// The digit is bound to a preview mode the current row cannot show. On a
// row that owns the day pane the digits get re-pointed at that pane's own
// tab bar — the axis it actually has — so 1 lands on All, 2 on the next tab,
// positionally as the bar reads.
//
// Anything that does not address a tab is swallowed rather than falling
// through: letting the digit reach the list would scroll the cursor instead,
// a second surprise on top of the first.
if isPreviewModeCmd(cmdName) && !a.rowSupportsPreviewModes() {
if a.sessSplit.Show {
if n, err := strconv.Atoi(key); err == nil && a.selectDayOutputTab(n) {
return a, nil, true
}
}
return a, nil, true
}

Expand Down Expand Up @@ -316,6 +328,13 @@ func (a *App) shortcutHint() string {

// Build hint in key order (0-9); 0 is rendered first as the quick "live" key.
previewOK := a.rowSupportsPreviewModes()
// On a day-pane row the preview-mode digits are re-pointed at that pane's
// kind tabs, so the hint has to name the tabs — listing the preview modes
// would promise modes the row cannot render, and skipping them all would
// leave the digits looking unbound when they do work.
if !previewOK && a.sessSplit.Show {
return a.dayOutputTabHint()
}
var parts []string
for _, i := range "0123456789" {
key := string(i)
Expand Down
Loading