diff --git a/README.md b/README.md index 560d9bd..fa18500 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/internal/tui/app.go b/internal/tui/app.go index 106e0c3..01da58e 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 @@ -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 @@ -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) @@ -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 } @@ -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 } diff --git a/internal/tui/daily_test.go b/internal/tui/daily_test.go index cabfe5c..e272f7b 100644 --- a/internal/tui/daily_test.go +++ b/internal/tui/daily_test.go @@ -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) } diff --git a/internal/tui/daypane.go b/internal/tui/daypane.go index 1a130c7..bf9351e 100644 --- a/internal/tui/daypane.go +++ b/internal/tui/daypane.go @@ -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" @@ -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 } @@ -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) @@ -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}) @@ -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 } @@ -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 @@ -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: @@ -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("") +} diff --git a/internal/tui/daypane_search_test.go b/internal/tui/daypane_search_test.go new file mode 100644 index 0000000..e85a7d1 --- /dev/null +++ b/internal/tui/daypane_search_test.go @@ -0,0 +1,241 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +func outRow(kind session.OutputKind, title, detail, project string) dayOutputRow { + return dayOutputRow{ + out: session.SessionOutput{Kind: kind, Title: title, Detail: detail}, + project: project, + shortID: "abc1234", + when: time.Now(), + } +} + +func rowTitles(rows []dayOutputRow) []string { + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.out.Title) + } + return out +} + +func TestDayOutputQueryFiltersRows(t *testing.T) { + rows := []dayOutputRow{ + outRow(session.OutputPR, "sendbird/ops-k8s#42", "argocd chart bump", "ops-k8s"), + outRow(session.OutputJira, "CPLAT-11790", "search resume", "ccx"), + outRow(session.OutputPR, "sendbird/ccx#162", "live state in search", "ccx"), + } + + cases := []struct { + query string + want []string + }{ + {"", []string{"sendbird/ops-k8s#42", "CPLAT-11790", "sendbird/ccx#162"}}, + {"cplat", []string{"CPLAT-11790"}}, + {"CPLAT", []string{"CPLAT-11790"}}, // case-insensitive + {"search", []string{"CPLAT-11790", "sendbird/ccx#162"}}, + {"ccx search", []string{"CPLAT-11790", "sendbird/ccx#162"}}, // AND across fields + {"argocd", []string{"sendbird/ops-k8s#42"}}, // matches Detail + {"ops-k8s", []string{"sendbird/ops-k8s#42"}}, // matches project + {"nothingmatches", nil}, + } + for _, c := range cases { + got := rowTitles(filterDayOutputRows(rows, dayOutputTabAll, c.query)) + if len(got) != len(c.want) { + t.Errorf("query %q: got %v, want %v", c.query, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("query %q: got %v, want %v", c.query, got, c.want) + break + } + } + } +} + +// The kind tab and the text query are independent narrowings and must compose. +func TestDayOutputQueryComposesWithKindTab(t *testing.T) { + rows := []dayOutputRow{ + outRow(session.OutputPR, "pr-search", "", "ccx"), + outRow(session.OutputJira, "jira-search", "", "ccx"), + outRow(session.OutputPR, "pr-other", "", "ccx"), + } + prTab := dayOutputTab{label: "PRs", kind: session.OutputPR} + + got := rowTitles(filterDayOutputRows(rows, prTab, "search")) + if len(got) != 1 || got[0] != "pr-search" { + t.Errorf("kind+query = %v, want [pr-search]", got) + } +} + +// daySearchApp reuses the daily-view harness and stocks the pane with rows to +// filter (dayPaneApp lives in daypane_test.go). +func daySearchApp(t *testing.T) *App { + t.Helper() + a := dayPaneApp(t, fakeSessions()) + a.dayOutputRows = []dayOutputRow{ + outRow(session.OutputPR, "pr-one", "", "p"), + outRow(session.OutputJira, "CPLAT-1", "", "p"), + } + return a +} + +// "/" in the day pane must search the day pane, not steal focus to the session +// list — the two panes answer different questions. +func TestSlashInDayPaneOpensItsOwnSearch(t *testing.T) { + a := daySearchApp(t) + sp := &a.sessSplit + + _, _, handled := a.handleDayPreviewKeys(sp, "/") + if !handled { + t.Fatal("day pane did not handle /") + } + if !a.dayOutputSearching { + t.Error("/ did not open the day pane's own search") + } + if !sp.Focus { + t.Error("/ moved focus away from the day pane") + } +} + +func TestDayOutputSearchAppliesAsYouType(t *testing.T) { + a := daySearchApp(t) + a.startDayOutputSearch() + + for _, r := range "cplat" { + m, _ := a.handleDayOutputSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + a = m.(*App) + } + if a.dayOutputQuery != "cplat" { + t.Errorf("query = %q, want %q", a.dayOutputQuery, "cplat") + } + + m, _ := a.handleDayOutputSearch(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(*App) + if a.dayOutputSearching { + t.Error("enter did not close the search input") + } + if a.dayOutputQuery != "cplat" { + t.Errorf("query after enter = %q, want %q", a.dayOutputQuery, "cplat") + } +} + +// Esc cancels the edit, not the filter — otherwise a stray keypress loses the +// narrowing the user built up. +func TestDayOutputSearchEscKeepsAppliedQuery(t *testing.T) { + a := daySearchApp(t) + a.applyDayOutputQuery("cplat") + a.startDayOutputSearch() + + m, _ := a.handleDayOutputSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + a = m.(*App) + m, _ = a.handleDayOutputSearch(tea.KeyMsg{Type: tea.KeyEsc}) + a = m.(*App) + + if a.dayOutputSearching { + t.Error("esc did not close the input") + } + if a.dayOutputQuery != "cplat" { + t.Errorf("esc dropped the applied query: %q, want %q", a.dayOutputQuery, "cplat") + } +} + +// Changing the filter must reset the cursor: every row action resolves through +// dayOutputsCursor into the FILTERED slice, so a kept index would act on a +// different output than the highlighted one. +func TestDayOutputQueryResetsCursor(t *testing.T) { + a := daySearchApp(t) + a.dayOutputsCursor = 1 + a.applyDayOutputQuery("cplat") + if a.dayOutputsCursor != 0 { + t.Errorf("cursor = %d after filter change, want 0", a.dayOutputsCursor) + } +} + +// The heading must say the count is filtered, or a narrowed list reads as +// "this day produced 3 things". +func TestDayPaneHeadingShowsQuery(t *testing.T) { + a := daySearchApp(t) + a.width, a.height = 200, 50 + all := []dayOutputRow{ + outRow(session.OutputPR, "pr-one", "", "p"), + outRow(session.OutputJira, "CPLAT-1", "", "p"), + } + a.dayOutputQuery = "cplat" + + view := stripANSI(a.renderOutputsPane("Today", "", "1 session", time.Now(), all, 120)) + if !strings.Contains(view, "of 2") { + t.Errorf("heading does not show the unfiltered total:\n%s", view) + } + if !strings.Contains(view, "cplat") { + t.Errorf("heading does not show the active query:\n%s", view) + } +} + +// An empty result under a query is a different answer than an empty day. +func TestDayPaneEmptyQueryResultIsDistinct(t *testing.T) { + a := daySearchApp(t) + a.width, a.height = 200, 50 + all := []dayOutputRow{outRow(session.OutputPR, "pr-one", "", "p")} + a.dayOutputQuery = "zzzznotfound" + + view := stripANSI(a.renderOutputsPane("Today", "", "1 session", time.Now(), all, 120)) + if !strings.Contains(view, "nothing matching") { + t.Errorf("empty query result is not distinguished from an empty day:\n%s", view) + } +} + +// The left pane keeps its own search: "/" with the list focused must still open +// the session filter, not the day pane's. +func TestSlashInSessionListStillFiltersSessions(t *testing.T) { + a := daySearchApp(t) + a.sessSplit.Focus = false // focus on the list, not the pane + + m, _ := a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + got := m.(*App) + + if got.dayOutputSearching { + t.Error("/ on the session list opened the day pane's search") + } + if !got.isFiltering() { + t.Error("/ on the session list did not open the session filter") + } +} + +// The two queries are independent: filtering one pane must not touch the other. +func TestPaneSearchesAreIndependent(t *testing.T) { + a := daySearchApp(t) + a.applyDayOutputQuery("cplat") + + if a.sessionList.FilterInput.Value() != "" { + t.Error("day pane query leaked into the session list filter") + } + + a.sessionList.SetFilterText("proj-a") + if a.dayOutputQuery != "cplat" { + t.Errorf("session filter clobbered the day pane query: %q", a.dayOutputQuery) + } +} + +// Moving to a different day must drop the query — carrying it would silently +// hide the new day's outputs behind a filter the user is no longer thinking of. +func TestDayOutputQueryResetsOnScopeChange(t *testing.T) { + a := daySearchApp(t) + a.applyDayOutputQuery("cplat") + a.dayOutputsCacheID = "some-other-day" + + di := dayItem{dayKey: "2026-08-31", sessions: fakeSessions()} + a.updateDayPreview(di) + + if a.dayOutputQuery != "" { + t.Errorf("query survived a day change: %q", a.dayOutputQuery) + } +} diff --git a/internal/tui/daypane_test.go b/internal/tui/daypane_test.go index 195158b..ae0e1b1 100644 --- a/internal/tui/daypane_test.go +++ b/internal/tui/daypane_test.go @@ -347,14 +347,14 @@ func TestDayOutputTabsKeepOneChronology(t *testing.T) { di := buildDailyItems(sessions, nil)[0].(dayItem) rows := buildDayOutputRows(di) - prs := filterDayOutputRows(rows, dayOutputTab{label: "PRs", kind: session.OutputPR}) + prs := filterDayOutputRows(rows, dayOutputTab{label: "PRs", kind: session.OutputPR}, "") if len(prs) != 2 { t.Fatalf("PR tab = %d rows, want 2", len(prs)) } if prs[0].out.Title != "early" || prs[1].out.Title != "late" { t.Errorf("PR tab = %q,%q — want the same chronology the All tab has", prs[0].out.Title, prs[1].out.Title) } - if all := filterDayOutputRows(rows, dayOutputTabAll); len(all) != 3 { + if all := filterDayOutputRows(rows, dayOutputTabAll, ""); len(all) != 3 { t.Errorf("All tab = %d rows, want every kind (3)", len(all)) } } diff --git a/internal/tui/help.go b/internal/tui/help.go index 0d7d6e9..9538f59 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -61,6 +61,9 @@ func (a *App) sessHelpLine() string { if a.sessConvSearching { return " " + a.sessConvSearchInput.View() + helpStyle.Render(" enter:apply esc:cancel") } + if a.dayOutputSearching { + return " " + a.dayOutputSearchTI.View() + helpStyle.Render(" enter:apply esc:cancel") + } // Pane proxy (live preview) if a.sessSplit.Focus && a.paneProxy != nil && a.sessPreviewMode == sessPreviewLive {