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
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ The list nests three tiers — **day → project → session** — each folding

The preview always shows **what that scope produced** — PRs, Jira issues, artifacts and plans, one row each. Selecting a date row shows the day's outputs; selecting a project row narrows to that project on that day. The sessions themselves are not listed in the pane: they are one row below in the list.

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), and `y` copies its URL or path. 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.
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.

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.

Expand All @@ -249,7 +249,25 @@ The per-session counterpart of the daily view: what this session produced, not w
| Files Changed | `Edit`/`Write`/`MultiEdit`/`NotebookEdit` targets, collapsed per path with a write count (`Read` does not count) |
| Scratchpad | Files in the session's scratchpad directory |

With the preview focused, `↑↓` moves the cursor, `y` copies the row's URL or path, and `Enter` opens it: external references go to the browser, and everything else jumps into the conversation at the entry that produced it.
With the preview focused, `↑↓` moves the cursor, `y` copies the row's URL or path, and `Enter` opens it: external references go to the browser, and everything else jumps into the conversation at the entry that produced it. `x` opens the row's full action menu — see below.

#### Output Row Actions (`x`)

Both output panes — the daily view's **Produced** list and the per-session **Outputs** digest — put every action for the row under the cursor behind `x`, the same modal-hint pattern the session browser uses. The menu lists **only what the row can actually do**, because a PR has no file to edit and a scratchpad file has no URL to open:

| Key | Action | Offered when |
|-----|--------|--------------|
| `o` | Open in browser | The row has a URL (PR, Jira issue, artifact) |
| `↵` | Jump to first mention | The row records the transcript entry it came from |
| `↵` | Open the conversation | No entry was recorded (e.g. a plan slug inherited from a parent session), but the producing session is known |
| `e` | Open in `$EDITOR` | The row is a local file (Files Changed, Scratchpad, plan files, memory notes) |
| `y` | Copy | Always — the URL when there is one, the path otherwise |

The hint box names the row it acts on (its section and title) so you can see what you are about to do it to. `Enter`, `o` and `y` keep working directly without the menu — `x` is the discoverable surface, not a replacement for the fast path. Letters follow your `actions` keymap (`edit`, `copy_path`), so rebinding those rebinds these.

#### Switching the Preview (`p`)

`p` opens the preview-mode menu — `v`:conv `s`:stats `m`:mem `x`:scratch `t`:tasks `a`:agents `w`:workflows `c`:contexts `r`:refs `o`:outputs `l`:live — from **either side**, whether the cursor is in the list or the preview has focus. It is the letter-based counterpart to the number keys (`0`-`9`), and follows the same rule about honesty: on a date row or a day-scoped project row the menu does not open, because those rows always render that scope's outputs and cannot honor any preview mode. The number keys already refuse there for the same reason.

#### Subagent and Workflow Support

Expand Down Expand Up @@ -353,7 +371,8 @@ Multi-select plugin components and press `t` to launch an isolated Claude sessio
| `[` / `]` | Adjust split ratio |
| `Space` | Multi-select toggle |
| `1-9` | Number key shortcuts (configurable) |
| `x` | Actions menu (delete, move, resume, fork, URLs, files, ...) |
| `p` | Preview-mode menu (works from the list *and* the focused preview) |
| `x` | Actions menu (delete, move, resume, fork, URLs, files, ...) — on a focused outputs pane, the row's own actions |
| `v` | Views menu (stats/config/plugins) |
| `:` | Command mode |
| `Ctrl+S` | Cross-session search |
Expand Down
74 changes: 62 additions & 12 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1767,7 +1767,11 @@ func (a *App) View() string {
if a.actionsMenu && a.state == viewSessions {
hintBox := a.renderActionsHintBox()
content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)})
help = formatHelp("x:actions — pick an action")
if a.outputsPreviewActionsActive() {
help = formatHelp("x:actions — pick an action for this output")
} else {
help = formatHelp("x:actions — pick an action")
}
}

if a.sessPageMenu && a.state == viewSessions {
Expand Down Expand Up @@ -1979,6 +1983,13 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return a.handleActionsMenu(key)
}

// Preview-page menu ("p" prefix). Intercepted here with the other overlays
// rather than inside the focused-preview block, because `p` now opens it
// from the list side too.
if a.sessPageMenu {
return a.handleSessPageMenu(key)
}

// State-filter toggle menu ("s" prefix)
if a.stateMenu {
return a.handleStateMenu(key)
Expand Down Expand Up @@ -2193,6 +2204,21 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
return a, nil
case km.Session.Actions:
// A focused outputs digest owns `x` for the row under its cursor. This
// comes first: without it, a day-scoped project row would fall into the
// project arm below and silently multi-select the whole project — an
// action about the LIST while the user is looking at the PANE.
if a.outputsPreviewActionsActive() {
if _, ok := a.outputActionTarget(); !ok {
// Focused on a digest with nothing in it. Say so rather than
// falling through to the list's session actions, which would act
// on a row the user is not looking at.
a.copiedMsg = "Nothing produced to act on"
return a, nil
}
a.actionsMenu = true
return a, nil
}
if a.hasMultiSelection() {
a.actionsMenu = true
return a, nil
Expand Down Expand Up @@ -2394,18 +2420,26 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {

// Focused preview: custom conversation nav or simple scroll
if sp.Focus && sp.Show {
if a.sessPageMenu {
return a.handleSessPageMenu(key)
}
if key == "p" {
a.sessPageMenu = true
return a, nil
}
if m, cmd, handled := a.handleFocusedPreviewKeys(sp, key); handled {
return m, cmd
}
}

// Preview-page menu. Reachable from BOTH sides: the common position is the
// cursor in the LIST wanting to change what the right pane shows, and
// gating this on preview focus made the combo look like it did not exist.
// Suppressed on rows that have no preview modes at all — see
// rowSupportsPreviewModes, which the number keys already honor. Two
// mechanisms for one thing must not disagree.
if key == "p" {
if !a.rowSupportsPreviewModes() {
a.copiedMsg = "This row always shows what it produced"
return a, nil
}
a.sessPageMenu = true
return a, nil
}

// List boundary (up/down always navigate list, scroll preview at edges)
if !sp.Focus && sp.HandleListBoundary(key) {
return a, a.schedulePreviewUpdate()
Expand Down Expand Up @@ -3744,6 +3778,16 @@ func (a *App) sessionPreviewActionsActive() bool {
func (a *App) handleActionsMenu(key string) (tea.Model, tea.Cmd) {
a.actionsMenu = false
a.copiedMsg = ""
// A focused outputs digest acts on the row under its cursor, not on a
// session in the list. Checked before every other arm for the same reason
// the `x` case is: the row may also be a project row, whose session actions
// would otherwise win.
if a.outputsPreviewActionsActive() {
if row, ok := a.outputActionTarget(); ok {
return a.runOutputAction(row, key)
}
return a, nil
}
if a.sessionPreviewActionsActive() {
switch key {
case "c":
Expand Down Expand Up @@ -5226,10 +5270,13 @@ func (a *App) renderSessionSplit() string {
a.refreshConvPreview()
} else if a.sessPreviewMode == sessPreviewOutputs && !isRemoteSetup {
// Re-render the digest at the new width from already-collected rows.
// A day row keeps its own summary pane (selectedSession would hand us
// an arbitrary child), and this path must never dispatch — View
// cannot deliver a cmd, so arming a latch here would strand the pane.
if _, isDay := a.selectedDay(); !isDay {
// A row that owns the day pane keeps its own summary (selectedSession
// would hand us an arbitrary child), and this path must never
// dispatch — View cannot deliver a cmd, so arming a latch here would
// strand the pane. selectedOwnsDayPane, not selectedDay: a day-scoped
// PROJECT row owns that pane too, and checking only date rows let a
// resize overwrite its "Produced" list with a child session's digest.
if !a.selectedOwnsDayPane() {
a.sessOutputsCacheKey = ""
if sess, ok := a.selectedSession(); ok {
a.refreshOutputsPreviewLayout(sess)
Expand Down Expand Up @@ -7654,6 +7701,9 @@ func (a *App) renderActionsHintBox() string {
akm := a.keymap.Actions

var lines []string
if a.outputsPreviewActionsActive() {
return a.renderOutputActionsHintBox()
}
if a.hasMultiSelection() && !a.sessionPreviewActionsActive() {
header := fmt.Sprintf("%d selected", len(a.selectedSet))
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(header))
Expand Down
4 changes: 2 additions & 2 deletions internal/tui/daypane.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, rows []dayOutpu

heading := fmt.Sprintf("Produced (%d)", len(rows))
if len(rows) > 0 && a.sessSplit.Focus {
heading += " ↵:jump to first mention o:open y:copy"
heading += " ↵:jump to first mention o:open y:copy x:actions"
}
sb.WriteString(section.Render(heading) + "\n")

Expand Down Expand Up @@ -215,7 +215,7 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, rows []dayOutpu
// 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 • ↑↓ 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 • ↑↓ moves between outputs"))
} else {
sb.WriteString(dimStyle.Render("↵/o folds this row • tab focuses this pane"))
}
Expand Down
159 changes: 159 additions & 0 deletions internal/tui/daypane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,162 @@ func selectedConvEntryUUID(a *App) string {
}
return a.conv.messages[ci.merged.startIdx].UUID
}

// TestDayPaneActionsMenuActsOnTheRow guards the day pane's half of the x-actions
// work: with the pane focused, `x` must act on the OUTPUT under the cursor, not
// on the day's sessions. A day-scoped project row is the trap — the plain `x`
// path multi-selects the whole project, which is an action about the list while
// the user is looking at the pane.
func TestDayPaneActionsMenuActsOnTheRow(t *testing.T) {
sessions := []session.Session{{
ID: "maker", ShortID: "maker", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a",
ModTime: dayOf(0),
Refs: []session.SessionRef{{
Kind: session.RefPR, Label: "sendbird/ccx#5",
URL: "https://github.com/sendbird/ccx/pull/5", Resolved: true,
}},
}}
app := dayPaneApp(t, sessions)

if !app.outputsPreviewActionsActive() {
t.Fatal("a focused day pane must own the x actions menu")
}

m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
got := m.(*App)
if !got.actionsMenu {
t.Fatal("x did not open the actions menu on the focused day pane")
}
if len(got.selectedSet) > 0 {
t.Errorf("x multi-selected %d sessions instead of acting on the row", len(got.selectedSet))
}
box := got.renderActionsHintBox()
if !strings.Contains(box, "sendbird/ccx#5") {
t.Errorf("the menu does not name the row it acts on:\n%s", box)
}
if strings.Contains(box, ":delete") {
t.Errorf("the session actions menu rendered over the day pane:\n%s", box)
}

var opened string
got.openURL = func(u string) error { opened = u; return nil }
m, _ = got.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'o'}})
if opened != "https://github.com/sendbird/ccx/pull/5" {
t.Errorf("x→o opened %q, want the row's PR", opened)
}
if m.(*App).actionsMenu {
t.Error("the menu should close after a key is picked")
}
}

// TestDayPaneActionsAnchorToTheRowsSession pins the day pane's extra field: the
// row's anchor session, which the per-session digest does not need. Without it,
// the uuid-less fallback ("open the conversation") could not be offered.
func TestDayPaneActionsAnchorToTheRowsSession(t *testing.T) {
app := newTestApp(nil)
app.sessGroupMode = groupDaily
app.dayOutputRows = []dayOutputRow{{
out: session.SessionOutput{Kind: session.OutputPlan, Title: "some-plan"},
sessID: "planner",
}}
app.dayOutputsCursor = 0

acts := app.outputActionsFor(app.dayOutputRows[0].out, app.dayOutputRows[0].sessID != "")
var kinds []outputActionKind
for _, a := range acts {
kinds = append(kinds, a.kind)
}
if len(kinds) != 1 || kinds[0] != outputActionSession {
t.Fatalf("a uuid-less plan slug with an anchor should offer exactly the conversation, got %+v", acts)
}
}

// TestDayPaneActionsOnProjectRow pins the day-scoped project row, which owns the
// same pane (selectedOwnsDayPane) but is a projectItem — the arm the plain `x`
// path would otherwise route into.
func TestDayPaneActionsOnProjectRow(t *testing.T) {
sessions := []session.Session{{
ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a",
ModTime: dayOf(0),
Refs: []session.SessionRef{{
Kind: session.RefPR, Label: "sendbird/ccx#1",
URL: "https://github.com/sendbird/ccx/pull/1", Resolved: true,
}},
}}
app := newTestApp(sessions)
app.sessGroupMode = groupDaily
app.rebuildSessionList()

idx := -1
for i, item := range app.sessionList.VisibleItems() {
if pi, ok := item.(projectItem); ok && pi.dayKey != "" {
idx = i
break
}
}
if idx < 0 {
t.Fatal("expected a day-scoped project row in the daily tree")
}
app.sessionList.Select(idx)
app.sessSplit.Show = true
app.sessSplit.Focus = true
_ = app.updateSessionPreview()

m, _ := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
got := m.(*App)
if len(got.selectedSet) > 0 {
t.Errorf("x on a focused day-project pane multi-selected %d sessions instead of acting on the row", len(got.selectedSet))
}
if box := got.renderActionsHintBox(); !strings.Contains(box, "sendbird/ccx#1") {
t.Errorf("the menu does not name the row it acts on:\n%s", box)
}
}

// TestDayProjectPaneSurvivesResize guards a bug found while wiring the actions
// menu: the resize branch in renderSessionSplit guarded only selectedDay(), so
// on a day-scoped PROJECT row — which owns the same pane — a window resize fell
// through to the per-session digest path and overwrote the "Produced" list with
// an arbitrary child session's outputs.
func TestDayProjectPaneSurvivesResize(t *testing.T) {
sessions := []session.Session{{
ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a",
ModTime: dayOf(0),
Refs: []session.SessionRef{{
Kind: session.RefPR, Label: "sendbird/ccx#1",
URL: "https://github.com/sendbird/ccx/pull/1", Resolved: true,
}},
}}
app := newTestApp(sessions)
app.sessGroupMode = groupDaily
// The digest mode is what makes the resize branch reachable; a day-scoped
// row renders the day pane regardless, which is exactly the mismatch.
app.sessPreviewMode = sessPreviewOutputs
app.rebuildSessionList()

idx := -1
for i, item := range app.sessionList.VisibleItems() {
if pi, ok := item.(projectItem); ok && pi.dayKey != "" {
idx = i
break
}
}
if idx < 0 {
t.Fatal("expected a day-scoped project row in the daily tree")
}
app.sessionList.Select(idx)
app.sessSplit.Show = true
app.sessSplit.Focus = true
_ = app.updateSessionPreview()

if before := app.sessSplit.Preview.View(); !strings.Contains(before, "Produced") {
t.Fatalf("fixture should start on the day-project pane, got:\n%s", before)
}

m, _ := app.Update(tea.WindowSizeMsg{Width: 140, Height: 44})
got := m.(*App)
_ = got.View() // the resize branch lives in the render path

if after := got.sessSplit.Preview.View(); !strings.Contains(after, "Produced") {
t.Errorf("resize replaced the day-project pane with a session digest:\n%s", after)
}
}
19 changes: 11 additions & 8 deletions internal/tui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,24 +74,27 @@ func (a *App) sessHelpLine() string {
sk := a.keymap.Session
var h string
if !a.sessSplit.Show {
h = fmtKey(sk.Open, "open") + " " + fmtKey(sk.Actions, "actions") + " →:preview " + fmtKey(sk.Search, "search")
h = fmtKey(sk.Open, "open") + " " + fmtKey(sk.Actions, "actions") + " →:preview p:page " + fmtKey(sk.Search, "search")
} else if a.sessSplit.Focus {
switch a.sessPreviewMode {
case sessPreviewConversation:
switch {
case a.outputsPreviewActionsActive():
// Both digests: the row under the cursor is what the keys act on.
h = "↑↓:nav ↵:jump o:open " + fmtKey(sk.Actions, "actions") + " ←:unfocus p:page"
case a.sessPreviewMode == sessPreviewConversation:
h = "↑↓:nav c:full " + fmtKey(sk.Open, "jump") + " ←:unfocus tab:mode"
case sessPreviewAgents:
case a.sessPreviewMode == sessPreviewAgents:
h = "↑↓:nav " + fmtKey(sk.Open, "jump") + " ←:unfocus tab:mode"
case sessPreviewWorkflows:
case a.sessPreviewMode == sessPreviewWorkflows:
h = "↑↓:agent ↵:transcript ←:unfocus tab:mode"
case sessPreviewRefs:
case a.sessPreviewMode == sessPreviewRefs:
h = "↑↓:nav ↵:open sp:select ←:unfocus tab:mode"
case sessPreviewContexts:
case a.sessPreviewMode == sessPreviewContexts:
h = "↑↓:node ↵:open ←:unfocus tab:mode"
default:
h = "↑↓:scroll ←:unfocus tab:mode"
}
} else {
h = "↑↓:nav →:focus tab:mode ←:close"
h = "↑↓:nav →:focus tab:mode p:page ←:close"
}
h += " " + a.helpSuffix()
return formatHelp(h)
Expand Down
Loading
Loading