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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ To read one kind at a time, the pane is **tabbed**: `All` plus a tab for each ki

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.

The pane also has **its own search**: with the preview focused, `/` filters the day's outputs by text (title, detail, path, URL, kind, project), AND-ing terms so `cplat argocd` narrows without you having to know which field holds which part. It composes with the kind tab, and the heading says the count is filtered (`Produced (3) of 682 /cplat`) so a narrowed list is never mistaken for a quiet day. This is deliberately **separate from the session list's `/`** — the two panes answer different questions ("which sessions" vs "which outputs"), and a day with hundreds of outputs needs narrowing even when the session list does not. `Esc` clears it; unlike the kind tab, the query does not travel across dates.

Sessions are bucketed by the calendar day of their **last** activity. A session that spans midnight appears once, under the day it was last active — it is never duplicated across dates.

**Known limitation — produced vs. referenced.** A reference counts as an output if the session's transcript contains its URL, which includes links that were merely read or quoted (a `kubernetes/kubernetes` PR consulted during debugging shows up next to the PR the session actually opened). Artifacts already avoid this — they are only counted from the `Published … at <url>` tool result — but PRs and Jira issues have no equivalent creation marker yet. Treat the Produced list as "references this day touched", weighted toward what it created.
Expand Down
35 changes: 28 additions & 7 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,17 @@ type App struct {
dayOutputsCursor int // cursor within the day pane's output list
dayOutputsCacheID string // day key the cursor currently tracks
dayOutputTabKind session.OutputKind // day pane's active kind tab ("" = the All timeline)
preDailyGroupMode int // grouping to restore when the daily view is toggled back off
dailyPreviewMode sessPreview // preview mode remembered for the daily view
browserPreviewMode sessPreview // preview mode remembered for every other grouping
openURL func(string) error // opens a URL in the browser; overridable in tests (defaults to `open`)
// The day pane searches independently of the session list: the two answer
// different questions ("which sessions" vs "which outputs"), and a day with
// hundreds of outputs needs narrowing even when the session list does not.
dayOutputSearching bool // typing in the day pane's search input
dayOutputSearchTI textinput.Model // day pane search input
dayOutputQuery string // applied day pane query ("" = no filter)
dayOutputQueryBefore string // query as of the input opening, restored on Esc
preDailyGroupMode int // grouping to restore when the daily view is toggled back off
dailyPreviewMode sessPreview // preview mode remembered for the daily view
browserPreviewMode sessPreview // preview mode remembered for every other grouping
openURL func(string) error // opens a URL in the browser; overridable in tests (defaults to `open`)

// Conversation preview state
sessConvEntries []mergedMsg // merged conversation messages
Expand Down Expand Up @@ -1558,6 +1565,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil
}

// The day pane's own query unwinds the same way: while that pane is
// focused, Esc drops its filter before it means anything else, so the
// narrowing you can see is the thing Esc undoes.
if msg.String() == "esc" && a.state == viewSessions && a.sessSplit.Focus &&
a.selectedOwnsDayPane() && a.dayOutputQuery != "" {
a.clearDayOutputSearch()
return a, nil
}

// Esc clears an applied search filter before doing normal navigation.
// In the session list we only clear via esc while the "/" search input
// is active (isFiltering, handled above) — an applied filter (e.g. the
Expand Down Expand Up @@ -2039,6 +2055,11 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return a.handleConvSearch(msg)
}

// Same for the day pane's own output search.
if a.dayOutputSearching {
return a.handleDayOutputSearch(msg)
}

// Move mode: text input for new project path
if a.moveMode {
return a.handleMoveInput(msg)
Expand Down Expand Up @@ -5470,7 +5491,7 @@ func (a *App) updateSessionPreview() tea.Cmd {
// The pane is the day's outputs regardless of preview mode, so the mode
// is not part of the key; the cursor, focus and KIND TAB are, so moving
// the highlight or switching tabs re-renders.
cacheKey := fmt.Sprintf("day:%s:%d:%d:%t:%s", di.dayKey, len(di.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind)
cacheKey := fmt.Sprintf("day:%s:%d:%d:%t:%s:%s", di.dayKey, len(di.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind, a.dayOutputQuery)
if cacheKey == a.sessSplit.CacheKey {
return nil
}
Expand All @@ -5484,8 +5505,8 @@ func (a *App) updateSessionPreview() tea.Cmd {
// day's work in one project, so its pane is that slice's outputs — not a
// representative session's, and not the generic project summary.
if pi.dayKey != "" {
cacheKey := fmt.Sprintf("dayproj:%s:%s:%d:%d:%t:%s", pi.dayKey, pi.basePath,
len(pi.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind)
cacheKey := fmt.Sprintf("dayproj:%s:%s:%d:%d:%t:%s:%s", pi.dayKey, pi.basePath,
len(pi.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind, a.dayOutputQuery)
if cacheKey == a.sessSplit.CacheKey {
return nil
}
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/daily_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ func TestDayPreviewTabsCoverEveryKindProduced(t *testing.T) {
if tb.kind == "" {
continue
}
for _, r := range filterDayOutputRows(rows, tb) {
for _, r := range filterDayOutputRows(rows, tb, "") {
if r.out.Kind != tb.kind {
t.Errorf("tab %q leaked a %s row", tb.label, r.out.Kind)
}
Expand Down
125 changes: 115 additions & 10 deletions internal/tui/daypane.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"time"

"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
Expand Down Expand Up @@ -89,19 +90,49 @@ func dayOutputTabsFor(rows []dayOutputRow, active session.OutputKind) []dayOutpu
return tabs
}

// filterDayOutputRows narrows rows to one tab's kind. All returns rows
// unchanged. The result is what the pane both renders AND indexes with
// dayOutputRowMatches reports whether a row matches every term in the query.
// Terms are AND-ed and matched case-insensitively against everything visible on
// the row plus its target, so "cplat argocd" narrows the way a reader expects
// without needing to know which field holds which part.
func dayOutputRowMatches(r dayOutputRow, terms []string) bool {
if len(terms) == 0 {
return true
}
hay := strings.ToLower(strings.Join([]string{
r.out.Title, r.out.Detail, r.out.Path, r.out.URL,
string(r.out.Kind), r.project, r.shortID,
}, "\x00"))
for _, t := range terms {
if !strings.Contains(hay, t) {
return false
}
}
return true
}

// filterDayOutputRows narrows rows to one tab's kind and the pane's own search
// query. The result is what the pane both renders AND indexes with
// dayOutputsCursor — filtering at render time only would leave Enter/o/y/x
// acting on a different output than the highlighted one.
func filterDayOutputRows(rows []dayOutputRow, tab dayOutputTab) []dayOutputRow {
if tab.kind == "" {
//
// The query is the day pane's own, independent of the session list's filter:
// the two panes answer different questions ("which sessions" vs "which
// outputs"), and a day with 682 outputs needs narrowing even when the session
// list does not.
func filterDayOutputRows(rows []dayOutputRow, tab dayOutputTab, query string) []dayOutputRow {
terms := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
if tab.kind == "" && len(terms) == 0 {
return rows
}
out := make([]dayOutputRow, 0, len(rows))
for _, r := range rows {
if r.out.Kind == tab.kind {
out = append(out, r)
if tab.kind != "" && r.out.Kind != tab.kind {
continue
}
if !dayOutputRowMatches(r, terms) {
continue
}
out = append(out, r)
}
return out
}
Expand Down Expand Up @@ -238,6 +269,11 @@ func (a *App) updateDayPreview(di dayItem) {
if a.dayOutputsCacheID != di.dayKey {
a.dayOutputsCursor = 0
a.dayOutputsCacheID = di.dayKey
// The query is dropped on a scope change, unlike the TAB. A kind filter
// is a lens you carry across dates; a text query is about one day's
// specific rows, and carrying it would silently hide the new day's
// outputs behind a filter the user is no longer thinking about.
a.dayOutputQuery = ""
}
all := buildDayOutputRows(di)

Expand Down Expand Up @@ -266,6 +302,7 @@ func (a *App) updateDayProjectPreview(pi projectItem) {
if a.dayOutputsCacheID != cacheID {
a.dayOutputsCursor = 0
a.dayOutputsCacheID = cacheID
a.dayOutputQuery = "" // see updateDayPreview: queries do not travel
}
all := buildDayOutputRows(dayItem{sessions: pi.sessions})

Expand Down Expand Up @@ -308,7 +345,7 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, day time.Time,

tabs := dayOutputTabsFor(all, a.dayOutputTabKind)
active := tabs[dayOutputTabIndex(tabs, a.dayOutputTabKind)]
rows := filterDayOutputRows(all, active)
rows := filterDayOutputRows(all, active, a.dayOutputQuery)
if a.dayOutputsCursor >= len(rows) {
a.dayOutputsCursor = 0
}
Expand All @@ -325,12 +362,19 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, day time.Time,
sb.WriteString(a.renderDayOutputTabs(tabs, active, all) + "\n")

heading := fmt.Sprintf("Produced (%d)", len(rows))
if a.dayOutputQuery != "" {
// Say the count is filtered and by what. Without this a narrowed list
// reads as "this day produced 3 things", which is a different claim.
heading += fmt.Sprintf(" of %d /%s", len(all), a.dayOutputQuery)
}
if len(rows) > 0 && a.sessSplit.Focus {
heading += " ↵:jump to first mention o:open y:copy x:actions"
heading += " ↵:jump to first mention o:open y:copy x:actions /:search"
}
sb.WriteString(section.Render(heading) + "\n")

switch {
case len(rows) == 0 && a.dayOutputQuery != "":
sb.WriteString(dimStyle.Render(fmt.Sprintf(" nothing matching %q", a.dayOutputQuery)) + "\n\n")
case len(rows) == 0 && active.kind != "":
// The tab is sticky across dates on purpose, so an empty day under a
// kind filter is a real answer ("this day produced no PRs"), not a
Expand Down Expand Up @@ -463,8 +507,12 @@ func (a *App) handleDayPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cm
case a.keymap.Actions.CopyPath, "y":
return a.copySelectedDayOutput()
case "/":
sp.Focus = false
return a, startListSearch(&a.sessionList), true
// Search the pane you are in. Focus stays here: the day pane has its own
// query because "which outputs" and "which sessions" are different
// questions, and a day with hundreds of rows needs narrowing on its own
// terms.
a.startDayOutputSearch()
return a, nil, true
}
switch HandleFlatCursorNav(&a.dayOutputsCursor, len(a.dayOutputRows), key) {
case NavCursorMoved:
Expand Down Expand Up @@ -657,3 +705,60 @@ func chronological(sessions []session.Session) []session.Session {
sort.SliceStable(out, func(i, j int) bool { return out[i].ModTime.Before(out[j].ModTime) })
return out
}

// startDayOutputSearch opens the day pane's own search input, pre-filled with
// the applied query so refining is editing rather than retyping.
func (a *App) startDayOutputSearch() {
a.dayOutputSearching = true
// Remember what was applied when the input opened. Typing applies live, so
// by the time Esc arrives dayOutputQuery already holds the edited value and
// is no longer what "cancel" should restore.
a.dayOutputQueryBefore = a.dayOutputQuery
ti := textinput.New()
ti.Prompt = "Search outputs: "
ti.SetValue(a.dayOutputQuery)
ti.CursorEnd()
ti.Focus()
a.dayOutputSearchTI = ti
}

// handleDayOutputSearch processes keys while the day pane's search is active.
// The query applies as you type so the row count reacts immediately; Esc
// restores whatever was applied when the input opened.
func (a *App) handleDayOutputSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "enter":
a.dayOutputSearching = false
a.applyDayOutputQuery(a.dayOutputSearchTI.Value())
return a, nil
case "esc":
a.dayOutputSearching = false
// Esc cancels the edit, not the filter: it restores what was applied when
// the input opened, so an abandoned edit does not silently become the
// filter and an accidental keypress does not lose the narrowing.
a.applyDayOutputQuery(a.dayOutputQueryBefore)
return a, nil
}
var cmd tea.Cmd
a.dayOutputSearchTI, cmd = a.dayOutputSearchTI.Update(msg)
a.applyDayOutputQuery(a.dayOutputSearchTI.Value())
return a, cmd
}

// applyDayOutputQuery sets the pane's query and re-renders. The cursor goes back
// to the top for the same reason a tab switch resets it: every row action
// resolves through dayOutputsCursor into the FILTERED slice, so an index kept
// across a filter change would point at a different output than the highlighted
// one.
func (a *App) applyDayOutputQuery(q string) {
a.dayOutputQuery = q
a.dayOutputsCursor = 0
a.sessSplit.CacheKey = ""
a.renderOwningDayScope()
}

// clearDayOutputSearch drops the pane's query entirely.
func (a *App) clearDayOutputSearch() {
a.dayOutputSearching = false
a.applyDayOutputQuery("")
}
Loading
Loading