From cde882560a168dead2418aad981c4506b8946151 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Fri, 17 Apr 2026 19:09:03 +0900 Subject: [PATCH 1/4] fix: skip decorative separators from block selection in preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separator lines (───) between messages in subagent/task previews were navigable and selectable via block cursor, which was meaningless. Now separators render without cursor prefix and are skipped by keyboard navigation, mouse clicks, and initial cursor placement. --- internal/tui/messages.go | 32 ++++++++++++++---- internal/tui/splitpane.go | 68 ++++++++++++++++++++++++++++++++------- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/internal/tui/messages.go b/internal/tui/messages.go index eba7580..0ce30d0 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -555,13 +555,18 @@ func renderFullMessageImpl(e session.Entry, width int, folds foldSet, formats fo break } if text != "" && !isSystemText(text) { - buf.WriteString(cursorPrefix) - if formatted { - text = tryFormatJSON(text) + if isDecorativeSeparator(text) { + // Render separator without cursor prefix (not navigable) + buf.WriteString(dimStyle.Render(strings.Repeat("─", max(w-3, 10))) + "\n\n") + } else { + buf.WriteString(cursorPrefix) + if formatted { + text = tryFormatJSON(text) + } + text = formatMarkdownTables(text) + wrapped := wrapText(text, max(w-2, 10)) + buf.WriteString(wrapped + "\n\n") } - text = formatMarkdownTables(text) - wrapped := wrapText(text, max(w-2, 10)) - buf.WriteString(wrapped + "\n\n") } case "tool_use": buf.WriteString(cursorPrefix) @@ -730,6 +735,21 @@ func applyBgToLine(line string, width int) string { return bgCode + inner + resetCode } +// isDecorativeSeparator returns true if the text block is a purely decorative +// separator line (e.g. ─── between messages in agent/task previews). +func isDecorativeSeparator(text string) bool { + t := strings.TrimSpace(text) + if t == "" { + return false + } + for _, r := range t { + if r != '─' { + return false + } + } + return true +} + // formatMarkdownTables detects markdown tables in text and re-renders them // with aligned columns. Non-table lines pass through unchanged. func formatMarkdownTables(text string) string { diff --git a/internal/tui/splitpane.go b/internal/tui/splitpane.go index 11ba6f8..52f196a 100644 --- a/internal/tui/splitpane.go +++ b/internal/tui/splitpane.go @@ -795,40 +795,55 @@ func (fs *FoldState) isBlockVisible(i int) bool { return fs.BlockVisible[i] } -// nextVisibleBlock returns the next visible block index after current, or -1. +// isBlockNavigable returns whether block i can be selected by the block cursor. +// Decorative separators (e.g. ─── lines between messages) are visible but not navigable. +func (fs *FoldState) isBlockNavigable(i int) bool { + if !fs.isBlockVisible(i) { + return false + } + if i >= 0 && i < len(fs.Entry.Content) { + b := fs.Entry.Content[i] + if b.Type == "text" && isDecorativeSeparator(b.Text) { + return false + } + } + return true +} + +// nextVisibleBlock returns the next navigable block index after current, or -1. func (fs *FoldState) nextVisibleBlock(current int) int { for i := current + 1; i < len(fs.Entry.Content); i++ { - if fs.isBlockVisible(i) { + if fs.isBlockNavigable(i) { return i } } return -1 } -// prevVisibleBlock returns the previous visible block index before current, or -1. +// prevVisibleBlock returns the previous navigable block index before current, or -1. func (fs *FoldState) prevVisibleBlock(current int) int { for i := current - 1; i >= 0; i-- { - if fs.isBlockVisible(i) { + if fs.isBlockNavigable(i) { return i } } return -1 } -// firstVisibleBlock returns the first visible block index, or -1. +// firstVisibleBlock returns the first navigable block index, or -1. func (fs *FoldState) firstVisibleBlock() int { for i := 0; i < len(fs.Entry.Content); i++ { - if fs.isBlockVisible(i) { + if fs.isBlockNavigable(i) { return i } } return -1 } -// lastVisibleBlock returns the last visible block index, or -1. +// lastVisibleBlock returns the last navigable block index, or -1. func (fs *FoldState) lastVisibleBlock() int { for i := len(fs.Entry.Content) - 1; i >= 0; i-- { - if fs.isBlockVisible(i) { + if fs.isBlockNavigable(i) { return i } } @@ -841,7 +856,7 @@ func (fs *FoldState) SelectBlockAtLine(line int) { return } // Find the last block whose start line is <= the clicked line - best := 0 + best := -1 for i, start := range fs.BlockStarts { if start <= line { best = i @@ -849,7 +864,19 @@ func (fs *FoldState) SelectBlockAtLine(line int) { break } } - fs.BlockCursor = best + // If the clicked block is a non-navigable separator, snap to nearest navigable block + if best >= 0 && !fs.isBlockNavigable(best) { + if next := fs.nextVisibleBlock(best); next >= 0 { + best = next + } else if prev := fs.prevVisibleBlock(best); prev >= 0 { + best = prev + } else { + return + } + } + if best >= 0 { + fs.BlockCursor = best + } } // Reset initializes fold state for a new entry (cold start, no preferences). @@ -857,8 +884,12 @@ func (fs *FoldState) Reset(entry session.Entry) { fs.Entry = entry fs.Collapsed = defaultFolds(entry) fs.Formatted = nil - fs.BlockCursor = 0 fs.Selected = nil + // Start cursor on the first navigable block (skip decorative separators) + fs.BlockCursor = 0 + if first := fs.firstVisibleBlock(); first >= 0 { + fs.BlockCursor = first + } } // ResetWithPrefs initializes fold state for a new entry using persistent @@ -866,7 +897,6 @@ func (fs *FoldState) Reset(entry session.Entry) { func (fs *FoldState) ResetWithPrefs(entry session.Entry, foldPrefs map[string]bool, fmtPrefs map[string]bool) { fs.Entry = entry fs.Collapsed = make(foldSet) - fs.BlockCursor = 0 // Log block types for debugging types := make([]string, len(entry.Content)) @@ -904,6 +934,12 @@ func (fs *FoldState) ResetWithPrefs(entry session.Entry, foldPrefs map[string]bo } else { fs.Formatted = nil } + + // Start cursor on the first navigable block (skip decorative separators) + fs.BlockCursor = 0 + if first := fs.firstVisibleBlock(); first >= 0 { + fs.BlockCursor = first + } } // GrowBlocks extends fold defaults for newly-appended blocks (live tail). @@ -936,4 +972,12 @@ func (fs *FoldState) GrowBlocks(entry session.Entry, oldBlockCount int, foldPref if fs.BlockCursor >= len(entry.Content) { fs.BlockCursor = max(len(entry.Content)-1, 0) } + // Ensure cursor is on a navigable block + if !fs.isBlockNavigable(fs.BlockCursor) { + if next := fs.nextVisibleBlock(fs.BlockCursor); next >= 0 { + fs.BlockCursor = next + } else if prev := fs.prevVisibleBlock(fs.BlockCursor); prev >= 0 { + fs.BlockCursor = prev + } + } } From 98bb310e2c0a949f83130ae56cf33e7a94a38e6e Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Fri, 17 Apr 2026 19:13:12 +0900 Subject: [PATCH 2/4] fix: hide empty entries and improve separator skip in agent/task preview - Skip conversation entries with no text content (tool-only turns like auto-generated user messages) from agent/task preview - Separators only added between entries that are actually emitted --- internal/tui/conversation.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index a97d6fb..236ea1a 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -1415,13 +1415,20 @@ func buildConversationPreviewEntry(header string, fallbackTS time.Time, entries blocks = append(blocks, session.ContentBlock{Type: "text", Text: header}) } - for idx, e := range entries { - if idx > 0 { - blocks = append(blocks, session.ContentBlock{Type: "text", Text: strings.Repeat("─", 24)}) - } + emitted := 0 + for _, e := range entries { if ts.IsZero() && !e.Timestamp.IsZero() { ts = e.Timestamp } + // Skip entries that have no meaningful text (only role+timestamp header) + // These are typically auto-generated user turns containing only tool results. + hasText := entryFullText(e) != "" + if !hasText { + continue + } + if emitted > 0 { + blocks = append(blocks, session.ContentBlock{Type: "text", Text: strings.Repeat("─", 24)}) + } if msg := previewMessageText(e); msg != "" { blocks = append(blocks, session.ContentBlock{Type: "text", Text: msg}) } @@ -1436,6 +1443,7 @@ func buildConversationPreviewEntry(header string, fallbackTS time.Time, entries } blocks = append(blocks, b) } + emitted++ } if len(blocks) == 0 { From 0e489fa3012921a9742dd2ae348ba5b32aadab23 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Sat, 18 Apr 2026 19:40:35 +0900 Subject: [PATCH 3/4] fix: preserve tool_use entries in conversation preview Only skip entries with no text AND no tool_use blocks (pure tool_result entries). Entries with tool_use blocks are kept since they represent meaningful operations (e.g. background job commands). --- internal/tui/conversation.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index 236ea1a..89e5854 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -1420,11 +1420,20 @@ func buildConversationPreviewEntry(header string, fallbackTS time.Time, entries if ts.IsZero() && !e.Timestamp.IsZero() { ts = e.Timestamp } - // Skip entries that have no meaningful text (only role+timestamp header) + // Skip entries that have no text and no tool_use blocks (only tool_results). // These are typically auto-generated user turns containing only tool results. hasText := entryFullText(e) != "" + hasToolUse := false if !hasText { - continue + for _, b := range e.Content { + if b.Type == "tool_use" { + hasToolUse = true + break + } + } + if !hasToolUse { + continue + } } if emitted > 0 { blocks = append(blocks, session.ContentBlock{Type: "text", Text: strings.Repeat("─", 24)}) From 79bcc43959525b9980543831ff8caa9a71464414 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Sun, 19 Apr 2026 08:52:48 +0900 Subject: [PATCH 4/4] fix: split session agents preview and align navigation semantics Separate session agents from tasks/plan preview, fix worktree plan-memory capture, and align split-pane hints and boundary behavior with actual navigation. --- internal/session/cache.go | 1 + internal/session/memory_import_test.go | 16 ++ internal/session/scanner.go | 1 + internal/session/scanner_path.go | 45 ++++- internal/session/scanner_stream.go | 4 +- internal/tui/app.go | 219 +++++++++++++------------ internal/tui/cmdmode.go | 15 +- internal/tui/conversation.go | 24 ++- internal/tui/conversation_models.go | 18 +- internal/tui/conversation_render.go | 75 ++++++++- internal/tui/conversation_ux_test.go | 64 +++++++- internal/tui/help.go | 12 +- internal/tui/live_preview_test.go | 1 + internal/tui/sessions.go | 2 +- internal/tui/shortcuts.go | 30 +++- internal/tui/shortcuts_test.go | 14 +- internal/tui/splitpane.go | 85 +++++++--- internal/tui/state.go | 3 + 18 files changed, 463 insertions(+), 166 deletions(-) diff --git a/internal/session/cache.go b/internal/session/cache.go index 33e2fd5..ff88f69 100644 --- a/internal/session/cache.go +++ b/internal/session/cache.go @@ -96,6 +96,7 @@ func LoadCachedSessions(claudeDir string) []Session { sessions := make([]Session, 0, len(sc.entries)) for _, cached := range sc.entries { if cached.Sess.MsgCount > 0 { + refreshSessionDerivedState(&cached.Sess, filepath.Dir(claudeDir)) sessions = append(sessions, cached.Sess) } } diff --git a/internal/session/memory_import_test.go b/internal/session/memory_import_test.go index 80509f6..a19fa5b 100644 --- a/internal/session/memory_import_test.go +++ b/internal/session/memory_import_test.go @@ -24,6 +24,22 @@ func TestResolveMainProjectPath(t *testing.T) { } } +func TestHasProjectMemoryFallsBackToMainRepoForWorktree(t *testing.T) { + home := t.TempDir() + mainProject := "/Users/gavin.jeong/src/keyolk/ccproxy" + worktreeProject := "/Users/gavin.jeong/src/keyolk/ccproxy/.worktree/rebuild" + memDir := filepath.Join(home, ".claude", "projects", EncodeProjectPath(mainProject), "memory") + if err := os.MkdirAll(memDir, 0o755); err != nil { + t.Fatalf("mkdir memory dir: %v", err) + } + if err := os.WriteFile(filepath.Join(memDir, "state.md"), []byte("memory"), 0o644); err != nil { + t.Fatalf("write memory file: %v", err) + } + if !hasProjectMemory(worktreeProject, home) { + t.Fatalf("expected worktree project to inherit memory from main repo") + } +} + func TestListMemoryDir(t *testing.T) { dir := t.TempDir() diff --git a/internal/session/scanner.go b/internal/session/scanner.go index 900dd5c..a3fea24 100644 --- a/internal/session/scanner.go +++ b/internal/session/scanner.go @@ -72,6 +72,7 @@ func ScanSessions(claudeDir string) ([]Session, error) { for _, f := range files { validPaths[f.path] = true if cached, ok := cache.lookup(f.path, f.modTime); ok { + refreshSessionDerivedState(&cached, home) if cached.MsgCount > 0 { // Load custom badges for cached sessions cached.CustomBadges = badgeStore.Get(cached.ID) diff --git a/internal/session/scanner_path.go b/internal/session/scanner_path.go index 6233f54..9c2c52f 100644 --- a/internal/session/scanner_path.go +++ b/internal/session/scanner_path.go @@ -234,10 +234,47 @@ func ResolveBaseRepo(projectPath string, worktreeDirs ...string) string { } func hasProjectMemory(projectPath, home string) bool { - encoded := EncodeProjectPath(projectPath) - memDir := filepath.Join(home, ".claude", "projects", encoded, "memory") - entries, err := os.ReadDir(memDir) - return err == nil && len(entries) > 0 + paths := []string{projectPath} + if base := ResolveBaseRepo(projectPath); base != "" && base != projectPath { + paths = append(paths, base) + } + if main := ResolveMainProjectPath(projectPath); main != "" && main != projectPath { + paths = append(paths, main) + } + seen := make(map[string]bool, len(paths)) + for _, p := range paths { + if p == "" || seen[p] { + continue + } + seen[p] = true + encoded := EncodeProjectPath(p) + memDir := filepath.Join(home, ".claude", "projects", encoded, "memory") + entries, err := os.ReadDir(memDir) + if err == nil && len(entries) > 0 { + return true + } + } + return false +} + +func refreshSessionDerivedState(sess *Session, home string) { + if sess == nil { + return + } + if len(sess.PlanSlugs) == 0 && sess.PlanSlug != "" { + sess.PlanSlugs = []string{sess.PlanSlug} + } + if sess.ProjectPath != "" { + sess.IsWorktree = isGitWorktree(sess.ProjectPath) + sess.HasMemory = hasProjectMemory(sess.ProjectPath, home) + } + sess.HasPlan = false + for _, slug := range sess.PlanSlugs { + if planFileExists(slug, home) { + sess.HasPlan = true + break + } + } } func hasSubagents(sessionFilePath string) bool { diff --git a/internal/session/scanner_stream.go b/internal/session/scanner_stream.go index 8d2f20d..e58f1cf 100644 --- a/internal/session/scanner_stream.go +++ b/internal/session/scanner_stream.go @@ -66,7 +66,6 @@ func scanSessionStream(path string, modTime time.Time, home string, badgeStore * if branch != "" { sess.GitBranch = branch } - continue } if bytes.Contains(line, bRoleUser) || bytes.Contains(line, bRoleUserS) { @@ -170,8 +169,7 @@ func scanSessionStream(path string, modTime time.Time, home string, badgeStore * } } if sess.ProjectPath != "" { - sess.IsWorktree = isGitWorktree(sess.ProjectPath) - sess.HasMemory = hasProjectMemory(sess.ProjectPath, home) + refreshSessionDerivedState(&sess, home) } // Load richer todos from ~/.claude/todos/ files if JSONL had none diff --git a/internal/tui/app.go b/internal/tui/app.go index f678e40..662bd43 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -532,9 +532,10 @@ const ( sessPreviewStats sessPreviewMemory sessPreviewTasksPlan + sessPreviewAgents sessPreviewLive // tmux pane capture sessPreviewRemote // remote session status/stream - numSessPreviewModes = 6 + numSessPreviewModes = 7 ) // Config holds application configuration from CLI flags. @@ -611,7 +612,7 @@ func NewApp(sessions []session.Session, cfg Config) *App { } } if a.config.PreviewMode != "" { - modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "tasks": sessPreviewTasksPlan, "live": sessPreviewLive} + modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "tasks": sessPreviewTasksPlan, "agents": sessPreviewAgents, "live": sessPreviewLive} if m, ok := modeMap[a.config.PreviewMode]; ok { a.sessPreviewMode = m a.sessSplit.Show = true @@ -1443,8 +1444,8 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if sp.Focus && sp.Show && a.sessPreviewMode == sessPreviewConversation && len(a.sessConvEntries) > 0 { return a.jumpToConvMessage() } - // If Tasks/Plan preview is focused with agents, jump to selected agent - if sp.Focus && sp.Show && a.sessPreviewMode == sessPreviewTasksPlan && len(a.sessPreviewAgents) > 0 { + // If Agents preview is focused, jump to selected agent + if sp.Focus && sp.Show && a.sessPreviewMode == sessPreviewAgents && len(a.sessPreviewAgents) > 0 { m, cmd, _ := a.jumpToAgentConversation() return m, cmd } @@ -1536,48 +1537,6 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.rebuildSessionList() } return a, nil - case km.Session.Left: - if sp.Focus && sp.Show && a.sessPreviewMode == sessPreviewConversation { - // Delegate to conversation handler (collapse), fall through below - } else if sp.Focus && sp.Show { - sp.Focus = false - return a, nil - } else if !sp.Focus && sp.Show { - idx := a.sessionList.Index() - sp.Show = false - contentH := max(a.height-3, 1) - a.sessionList.SetSize(sp.ListWidth(a.width, a.splitRatio), contentH) - a.sessionList.Select(idx) - return a, nil - } - case km.Session.Right: - if sp.Focus && sp.Show && a.sessPreviewMode == sessPreviewConversation { - // Delegate to conversation handler (expand), fall through below - } else { - if !sp.Show { - idx := a.sessionList.Index() - sp.Show = true - sp.CacheKey = "" - contentH := max(a.height-3, 1) - a.sessionList.SetSize(sp.ListWidth(a.width, a.splitRatio), contentH) - a.sessionList.Select(idx) - } - sp.Focus = true - if a.paneProxy != nil { - return a, capturePaneCmd(a.paneProxy.pane) // immediate capture on focus - } - return a, nil - } - case km.Session.ResizeShrink: - if sp.Show { - a.adjustSplitRatio(-5) // preview larger - } - return a, nil - case km.Session.ResizeGrow: - if sp.Show { - a.adjustSplitRatio(5) // preview smaller - } - return a, nil } // Translate navigation aliases (e.g. vim j→down, emacs ctrl+n→down) @@ -1586,6 +1545,22 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { msg = navMsg } + // Shared split-pane semantics for left/right/tab/esc/resize. + result := sp.HandleSplitKey(key, a.width, a.height, a.splitRatio, a.adjustSplitRatio) + switch result { + case splitKeyClosed: + return a, nil + case splitKeyFocused, splitKeyOpened: + if a.paneProxy != nil { + return a, capturePaneCmd(a.paneProxy.pane) + } + return a, a.updateSessionPreview() + case splitKeyUnfocused: + return a, nil + case splitKeyHandled: + return a, nil + } + // Focused preview: custom conversation nav or simple scroll if sp.Focus && sp.Show { if m, cmd, handled := a.handleFocusedPreviewKeys(sp, key); handled { @@ -1641,7 +1616,7 @@ func (a *App) handleFocusedPreviewKeys(sp *SplitPane, key string) (tea.Model, te if a.sessPreviewMode == sessPreviewConversation && len(a.sessConvEntries) > 0 { return a.handleConvPreviewKeys(sp, key) } - if a.sessPreviewMode == sessPreviewTasksPlan && len(a.sessPreviewAgents) > 0 { + if a.sessPreviewMode == sessPreviewAgents && len(a.sessPreviewAgents) > 0 { return a.handleTasksPreviewKeys(sp, key) } switch key { @@ -1668,7 +1643,11 @@ func (a *App) handleTasksPreviewKeys(sp *SplitPane, key string) (tea.Model, tea. // Flat cursor navigation over agents switch HandleFlatCursorNav(&a.sessAgentCursor, len(a.sessPreviewAgents), key) { case NavCursorMoved: - a.rebuildTasksPreviewContent() + if key == "up" || key == "k" { + sp.Preview.LineUp(1) + } else if key == "down" || key == "j" { + sp.Preview.LineDown(1) + } return a, nil, true case NavBoundaryDown: a.sessPreviewBoundaryCross("down") @@ -1776,13 +1755,16 @@ func (a *App) handleConvPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.C } else { a.sessConvCursor = convFirstVisible(visible, a.sessConvExpanded, previewW, vpTop, vpBottom) } + if a.sessConvCursor > 0 { + a.sessConvCursor-- + } } else { a.sessConvCursor-- } a.sessPreviewPinned = true a.refreshConvPreview() } else { - // At top boundary: move to previous session + // At top boundary, stay within the current session preview. a.sessPreviewBoundaryCross("up") } return a, nil, true @@ -1798,12 +1780,15 @@ func (a *App) handleConvPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.C } else { a.sessConvCursor = convLastVisible(visible, a.sessConvExpanded, previewW, vpTop, vpBottom) } + if a.sessConvCursor < len(visible)-1 { + a.sessConvCursor++ + } } else { a.sessConvCursor++ } a.refreshConvPreview() } else { - // At bottom boundary: move to next session + // At bottom boundary, stay within the current session preview. a.sessPreviewBoundaryCross("down") } a.sessPreviewPinned = a.sessConvCursor < len(visible)-1 @@ -3797,6 +3782,8 @@ func (a *App) updateSessionPreview() tea.Cmd { a.updateSessionMemoryPreview(sess) case sessPreviewTasksPlan: a.updateSessionTasksPlanPreview(sess) + case sessPreviewAgents: + a.updateSessionAgentsPreview(sess) case sessPreviewLive: if sess.IsLive { a.sessSplit.Preview.SetContent(dimStyle.Render("(connecting…)")) @@ -4295,6 +4282,13 @@ func (a *App) updateSessionTasksPlanPreview(sess session.Session) { a.sessSplit.Preview.SetContent(a.sessTasksCache) } +func (a *App) updateSessionAgentsPreview(sess session.Session) { + previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) + contentH := max(a.height-3, 1) + a.sessSplit.Preview = viewport.New(previewW, contentH) + a.sessSplit.Preview.SetContent(a.buildAgentsPreviewContent(sess)) +} + func (a *App) buildTasksPlanContent(sess session.Session) string { home, _ := os.UserHomeDir() var sb strings.Builder @@ -4404,65 +4398,7 @@ func (a *App) buildTasksPlanContent(sess session.Session) string { } } - // Agents/teammates - if sess.HasAgents { - agents, err := session.FindSubagents(sess.FilePath) - a.sessPreviewAgents = agents - if a.sessAgentCursor >= len(agents) { - a.sessAgentCursor = 0 - } - if err == nil && len(agents) > 0 { - running := 0 - for _, ag := range agents { - if ag.MsgCount > 0 && ag.MsgCount%2 == 1 { - running++ - } - } - label := fmt.Sprintf("── Agents [%d] ↵:jump ──", len(agents)) - if running > 0 { - label = fmt.Sprintf("── Agents [%d, %d active] ↵:jump ──", len(agents), running) - } - sb.WriteString(dimStyle.Render(label) + "\n\n") - sel := lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true) - for i, ag := range agents { - icon := "⊕" - style := dimStyle - if ag.MsgCount > 0 && ag.MsgCount%2 == 1 { - icon = "◉" - style = lipgloss.NewStyle().Foreground(colorAssistant) - } else if ag.MsgCount > 0 { - icon = "✓" - style = lipgloss.NewStyle().Foreground(colorAccent) - } - typeBadge := ag.AgentType - if typeBadge == "" { - typeBadge = "agent" - } - headline := fmt.Sprintf("%s %s", icon, typeBadge) - if ag.ShortID != "" { - headline += " " + ag.ShortID - } - cursor := " " - if i == a.sessAgentCursor && a.sessSplit.Focus { - cursor = sel.Render("> ") - sb.WriteString(cursor + sel.Render(headline) + "\n") - } else { - sb.WriteString(cursor + style.Render(headline) + "\n") - } - if ag.FirstPrompt != "" { - prompt := ag.FirstPrompt - if len(prompt) > 100 { - prompt = prompt[:97] + "..." - } - sb.WriteString(dimStyle.Render(" "+prompt) + "\n") - } - if !ag.Timestamp.IsZero() { - sb.WriteString(dimStyle.Render(" "+timeAgo(ag.Timestamp)) + "\n") - } - } - sb.WriteString("\n") - } - } + // Agents are shown in the dedicated agents preview mode. // Plans (show all distinct plans in order) for i, slug := range sess.PlanSlugs { @@ -4485,6 +4421,71 @@ func (a *App) buildTasksPlanContent(sess session.Session) string { return sb.String() } +func (a *App) buildAgentsPreviewContent(sess session.Session) string { + a.sessPreviewAgents = nil + if !sess.HasAgents { + return dimStyle.Render("No agents found for this session.") + } + agents, err := session.FindSubagents(sess.FilePath) + if err != nil || len(agents) == 0 { + return dimStyle.Render("No agents found for this session.") + } + a.sessPreviewAgents = agents + if a.sessAgentCursor >= len(agents) { + a.sessAgentCursor = 0 + } + running := 0 + for _, ag := range agents { + if ag.MsgCount > 0 && ag.MsgCount%2 == 1 { + running++ + } + } + var sb strings.Builder + label := fmt.Sprintf("── Agents [%d] ↵:jump ──", len(agents)) + if running > 0 { + label = fmt.Sprintf("── Agents [%d, %d active] ↵:jump ──", len(agents), running) + } + sb.WriteString(dimStyle.Render(label) + "\n\n") + sel := lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true) + for i, ag := range agents { + icon := "⊕" + style := dimStyle + if ag.MsgCount > 0 && ag.MsgCount%2 == 1 { + icon = "◉" + style = lipgloss.NewStyle().Foreground(colorAssistant) + } else if ag.MsgCount > 0 { + icon = "✓" + style = lipgloss.NewStyle().Foreground(colorAccent) + } + typeBadge := ag.AgentType + if typeBadge == "" { + typeBadge = "agent" + } + headline := fmt.Sprintf("%s %s", icon, typeBadge) + if ag.ShortID != "" { + headline += " " + ag.ShortID + } + cursor := " " + if i == a.sessAgentCursor && a.sessSplit.Focus { + cursor = sel.Render("> ") + sb.WriteString(cursor + sel.Render(headline) + "\n") + } else { + sb.WriteString(cursor + style.Render(headline) + "\n") + } + if ag.FirstPrompt != "" { + prompt := ag.FirstPrompt + if len(prompt) > 100 { + prompt = prompt[:97] + "..." + } + sb.WriteString(dimStyle.Render(" "+prompt) + "\n") + } + if !ag.Timestamp.IsZero() { + sb.WriteString(dimStyle.Render(" "+timeAgo(ag.Timestamp)) + "\n") + } + } + return sb.String() +} + // buildMemoryContent produces the styled memory text for a session. func (a *App) buildMemoryContent(sess session.Session) string { if sess.ProjectPath == "" { diff --git a/internal/tui/cmdmode.go b/internal/tui/cmdmode.go index da2d4c9..0b6fe69 100644 --- a/internal/tui/cmdmode.go +++ b/internal/tui/cmdmode.go @@ -83,10 +83,21 @@ func buildCmdRegistry() []cmdEntry { action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewStats); return a, nil }}, {name: "preview:mem", aliases: []string{"p:mem"}, desc: "memory preview", views: cmdSessions, action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewMemory); return a, nil }}, - {name: "preview:tasks", aliases: []string{"p:tasks"}, desc: "tasks preview", views: cmdSessions, + {name: "preview:tasks", aliases: []string{"p:tasks"}, desc: "tasks/plan preview", views: cmdSessions, action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewTasksPlan); return a, nil }}, + {name: "preview:agents", aliases: []string{"p:agents"}, desc: "agents preview", views: cmdSessions, + action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewAgents); return a, nil }}, {name: "preview:live", aliases: []string{"p:live"}, desc: "live preview", views: cmdSessions, - action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewLive); return a, nil }}, + action: func(a *App) (tea.Model, tea.Cmd) { + sess, ok := a.selectedSession() + if !ok { + return a, nil + } + if sess.IsRemote { + return a.openRemoteLivePreview(sess) + } + return a.openLivePreview(sess) + }}, // Views { diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index 89e5854..8890878 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -68,7 +68,7 @@ func (a *App) openConversation(sess session.Session) tea.Cmd { } a.conv.sess = sess a.currentSess = sess - a.conv.items = buildConvItems(a.conv.merged, agents, tasks, crons) + a.conv.items = buildConvItems(sess, a.conv.merged, agents, tasks, crons) // Reset artifact page browser state on fresh conversation open. a.convPageActive = false @@ -758,6 +758,14 @@ func (a *App) updateConvPreview() { entry = item.merged.entry case convAgent: entry = buildAgentPreviewEntry(item.agent) + case convSessionMeta: + switch item.sessionMeta { + case "memory": + a.setConvPreviewText(a.buildMemoryContent(a.conv.sess)) + default: + a.setConvPreviewText(a.buildTasksPlanContent(a.conv.sess)) + } + return case convTask: pw := sp.PreviewWidth(a.width, a.splitRatio) if item.groupTag == "agents" && item.count > 0 { @@ -1359,7 +1367,7 @@ func (a *App) openConvChangesPage() (tea.Model, tea.Cmd) { func (a *App) setConvPreviewText(content string) { sp := &a.conv.split sp.CacheKey = "text" - sp.Preview.SetContent(content) + sp.SetPreviewContent(content, a.width, a.height, a.splitRatio) sp.Preview.YOffset = 0 // Clear stale fold state so fold keys don't re-render a previous message if sp.Folds != nil { @@ -1395,6 +1403,8 @@ func convPreviewBaseKey(item convItem) string { return fmt.Sprintf("msg:%d", item.merged.startIdx) case item.kind == convAgent: return "agent:" + item.agent.ShortID + case item.kind == convSessionMeta: + return "sessionmeta:" + item.sessionMeta case item.bgTaskID != "": return "bg:" + item.bgTaskID case item.cron.ID != "": @@ -1996,7 +2006,7 @@ func (a *App) rebuildConversationList(selectIdx int) { contentH := ContentHeight(a.height) items := a.conv.items if a.conv.leftPaneMode == convPaneTree { - a.conv.treeItems = buildEntityTree(a.conv.merged, a.conv.agents, a.conv.sess.Tasks, a.conv.sess.Crons, inferAgentStatuses(a.conv.merged)) + a.conv.treeItems = buildEntityTree(a.conv.sess, a.conv.merged, a.conv.agents, a.conv.sess.Tasks, a.conv.sess.Crons, inferAgentStatuses(a.conv.merged)) items = a.conv.treeItems } a.convList = newConvList(items, a.conv.split.ListWidth(a.width, a.splitRatio), contentH) @@ -2035,7 +2045,7 @@ func (a *App) refreshConversation() tea.Cmd { crons = session.LoadCronsFromEntries(entries) a.conv.sess.Crons = crons } - a.conv.items = buildConvItems(a.conv.merged, agents, tasks, crons) + a.conv.items = buildConvItems(a.conv.sess, a.conv.merged, agents, tasks, crons) a.conv.sess.Tasks = tasks // Preserve cursor position @@ -2601,7 +2611,7 @@ func (a *App) openCronConversation(cron session.CronItem) (tea.Model, tea.Cmd) { merged := filterConversation(mergeConversationTurns(cronEntries)) agents, _ := session.FindSubagents(a.conv.sess.FilePath) - items := buildConvItems(merged, agents, nil, nil) + items := buildConvItems(a.currentSess, merged, agents, nil, nil) a.conv.sess = a.currentSess a.conv.messages = cronEntries @@ -2631,7 +2641,7 @@ func (a *App) openTaskConversation(task session.TaskItem) (tea.Model, tea.Cmd) { merged := filterConversation(mergeConversationTurns(taskEntries)) agents, _ := session.FindSubagents(a.conv.sess.FilePath) - items := buildConvItems(merged, agents, nil, nil) + items := buildConvItems(a.currentSess, merged, agents, nil, nil) a.conv.sess = a.currentSess a.conv.messages = taskEntries @@ -2670,7 +2680,7 @@ func (a *App) openAgentConversation(agent session.Subagent) (tea.Model, tea.Cmd) merged := filterConversation(mergeConversationTurns(entries)) agents, _ := session.FindSubagents(agent.FilePath) - items := buildConvItems(merged, agents, nil, nil) + items := buildConvItems(a.currentSess, merged, agents, nil, nil) a.conv.sess = a.currentSess a.conv.messages = entries diff --git a/internal/tui/conversation_models.go b/internal/tui/conversation_models.go index cb9f6f9..2a638e7 100644 --- a/internal/tui/conversation_models.go +++ b/internal/tui/conversation_models.go @@ -14,9 +14,10 @@ import ( type convItemKind int const ( - convMsg convItemKind = iota // user/assistant message turn - convTask // task item (under assistant message) - convAgent // agent reference (under assistant message) + convMsg convItemKind = iota // user/assistant message turn + convTask // task item (under assistant message) + convAgent // agent reference (under assistant message) + convSessionMeta // session-level memory/tasks-plan shortcuts ) // convItem represents a single row in the conversation list. @@ -27,6 +28,7 @@ type convItem struct { cron session.CronItem // for cron-related convTask rows agent session.Subagent // for convAgent agentStatus string // "running", "completed", "stopped" for convAgent + sessionMeta string // "memory" or "tasksplan" for convSessionMeta bgTaskID string // background task ID for individual task op items indent int // 0=message, 1=sub-item folded bool // for expandable group headers (tasks/agents) @@ -57,6 +59,14 @@ func (c convItem) FilterValue() string { case convAgent: parts = append(parts, c.agent.FirstPrompt, c.agent.ShortID, c.agent.AgentType) parts = append(parts, "is:agent") + case convSessionMeta: + parts = append(parts, c.label) + switch c.sessionMeta { + case "memory": + parts = append(parts, "memory", "todos", "is:memory") + case "tasksplan": + parts = append(parts, "tasks", "plan", "agents", "crons", "is:tasksplan", "is:plan") + } } return strings.Join(parts, " ") } @@ -85,5 +95,7 @@ func (d convDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite renderConvTaskOrAgent(w, ci, selected, width, clamp, filterTerm) case convAgent: renderConvTaskOrAgent(w, ci, selected, width, clamp, filterTerm) + case convSessionMeta: + renderConvSessionMeta(w, ci, selected, width, clamp, filterTerm) } } diff --git a/internal/tui/conversation_render.go b/internal/tui/conversation_render.go index 58fabd6..90739b3 100644 --- a/internal/tui/conversation_render.go +++ b/internal/tui/conversation_render.go @@ -260,6 +260,50 @@ func renderConvTaskOrAgent(w io.Writer, ci convItem, selected bool, width int, c fmt.Fprint(w, clamp.Render(line)) } +func renderConvSessionMeta(w io.Writer, ci convItem, selected bool, width int, clamp lipgloss.Style, filterTerm string) { + cursor := " " + if selected { + cursor = convCursorStyle.Render("> ") + } + + style := dimStyle + if selected { + style = selectedStyle + } + + badge := "[?]" + title := ci.label + subtitle := "" + switch ci.sessionMeta { + case "memory": + badge = memoryBadge.Render("[M]") + if title == "" { + title = "Session Memory" + } + subtitle = "memory files and todos" + case "tasksplan": + badge = planBadge.Render("[P]") + if title == "" { + title = "Session Tasks/Plan" + } + subtitle = "tasks, cron jobs, agents, and plans" + } + + text := title + if subtitle != "" { + text += " " + subtitle + } + availW := max(width-10, 10) + if filterTerm != "" && availW > 0 { + text = highlightSnippet(text, filterTerm, availW, style) + } else { + text = style.Render(truncate(text, availW)) + } + + line := cursor + badge + " " + text + fmt.Fprint(w, clamp.Render(line)) +} + // convMsgPreview returns a short text preview for a conversation message. func convMsgPreview(e session.Entry, maxW int) string { if maxW <= 0 { @@ -432,7 +476,7 @@ func inferAgentStatuses(merged []mergedMsg) map[string]string { // with inline task and agent sub-items under assistant messages. // A collapsible task group header appears at every task-touching message. // Individual task rows (expandable) are attached only under the LAST one. -func buildConvItems(merged []mergedMsg, agents []session.Subagent, tasks []session.TaskItem, crons []session.CronItem) []convItem { +func buildConvItems(sess session.Session, merged []mergedMsg, agents []session.Subagent, tasks []session.TaskItem, crons []session.CronItem) []convItem { // First pass: find all task-touching message indices and the last one. // Always scan for task operations (TaskCreate, TaskOutput, etc.) regardless // of whether a resolved task list exists — operations should be visible as @@ -535,6 +579,20 @@ func buildConvItems(merged []mergedMsg, agents []session.Subagent, tasks []sessi agentStatuses := inferAgentStatuses(merged) var items []convItem + if sess.HasMemory || len(sess.Todos) > 0 { + items = append(items, convItem{ + kind: convSessionMeta, + sessionMeta: "memory", + label: "Session Memory", + }) + } + if sess.HasPlan || sess.HasTasks || sess.HasCrons || sess.HasAgents { + items = append(items, convItem{ + kind: convSessionMeta, + sessionMeta: "tasksplan", + label: "Session Tasks/Plan", + }) + } for mi, m := range merged { parentIdx := len(items) @@ -726,6 +784,7 @@ func buildConvItems(merged []mergedMsg, agents []session.Subagent, tasks []sessi // buildEntityTree builds an entity-centric tree view: agents, background jobs, // and task board items grouped under collapsible section headers. func buildEntityTree( + sess session.Session, merged []mergedMsg, agents []session.Subagent, tasks []session.TaskItem, @@ -733,6 +792,20 @@ func buildEntityTree( agentStatuses map[string]string, ) []convItem { var items []convItem + if sess.HasMemory || len(sess.Todos) > 0 { + items = append(items, convItem{ + kind: convSessionMeta, + sessionMeta: "memory", + label: "Session Memory", + }) + } + if sess.HasPlan || sess.HasTasks || sess.HasCrons || sess.HasAgents { + items = append(items, convItem{ + kind: convSessionMeta, + sessionMeta: "tasksplan", + label: "Session Tasks/Plan", + }) + } // --- Agents section --- if len(agents) > 0 { diff --git a/internal/tui/conversation_ux_test.go b/internal/tui/conversation_ux_test.go index 411fafe..64ff211 100644 --- a/internal/tui/conversation_ux_test.go +++ b/internal/tui/conversation_ux_test.go @@ -122,7 +122,7 @@ func setupConvApp(t *testing.T, entries []session.Entry, width, height int) *App app.conv.sess = sess app.conv.messages = entries app.conv.merged = filterConversation(mergeConversationTurns(entries)) - app.conv.items = buildConvItems(app.conv.merged, nil, nil, nil) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) contentH := ContentHeight(height) app.conv.split.Focus = false @@ -143,7 +143,7 @@ func setupTreeConvApp(t *testing.T, entries []session.Entry, tasks []session.Tas app.currentSess.Tasks = tasks app.conv.sess.Tasks = tasks app.conv.agents = agents - app.conv.items = buildConvItems(app.conv.merged, agents, tasks, nil) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, agents, tasks, nil) app.conv.leftPaneMode = convPaneTree app.rebuildConversationList(0) app.updateConvPreview() @@ -216,7 +216,53 @@ func testEntries() []session.Entry { } } -// --- Group 1: Preview Update on Navigation --- +func TestBuildConvItemsAddsSessionMetaRows(t *testing.T) { + entries := testEntries() + sess := session.Session{ + ID: "test-sess", + ShortID: "test", + ProjectPath: "/tmp/test", + HasMemory: true, + HasPlan: true, + Todos: []session.TodoItem{{Content: "remember this", Status: "pending"}}, + } + merged := filterConversation(mergeConversationTurns(entries)) + items := buildConvItems(sess, merged, nil, nil, nil) + if len(items) < 2 { + t.Fatalf("expected session meta rows, got %d items", len(items)) + } + if items[0].kind != convSessionMeta || items[0].sessionMeta != "memory" { + t.Fatalf("first item = %#v, want memory session meta", items[0]) + } + if items[1].kind != convSessionMeta || items[1].sessionMeta != "tasksplan" { + t.Fatalf("second item = %#v, want tasksplan session meta", items[1]) + } + if fv := items[0].FilterValue(); !strings.Contains(fv, "is:memory") { + t.Fatalf("memory filter tokens missing: %q", fv) + } + if fv := items[1].FilterValue(); !strings.Contains(fv, "is:tasksplan") || !strings.Contains(fv, "is:plan") { + t.Fatalf("tasksplan filter tokens missing: %q", fv) + } +} + +func TestConvPreviewSessionMetaUsesSessionRenderers(t *testing.T) { + app := setupConvApp(t, testEntries(), 160, 50) + app.currentSess.HasMemory = true + app.currentSess.HasPlan = true + app.currentSess.Todos = []session.TodoItem{{Content: "saved todo", Status: "pending"}} + app.conv.sess = app.currentSess + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) + contentH := ContentHeight(app.height) + app.convList = newConvList(app.conv.items, app.conv.split.ListWidth(app.width, app.splitRatio), contentH) + app.conv.split.List = &app.convList + + selectConvItemBy(t, app, func(ci convItem) bool { return ci.kind == convSessionMeta && ci.sessionMeta == "memory" }) + app.updateConvPreview() + if got := app.conv.split.Preview.View(); !strings.Contains(got, "saved todo") { + t.Fatalf("memory preview did not use session memory renderer: %q", got) + } +} + func TestConvPreviewUpdatesOnCursorMove(t *testing.T) { app := setupConvApp(t, testEntries(), 160, 50) @@ -292,7 +338,7 @@ func TestConvPreviewGrowBlocksOnSameEntry(t *testing.T) { // Simulate growing: add more blocks to the same entry grown := makeGrowingEntry(base.Add(time.Second), 6) app.conv.merged[1] = mergedMsg{entry: grown, startIdx: 1, endIdx: 1} - app.conv.items = buildConvItems(app.conv.merged, nil, nil, nil) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) // Update preview — should use GrowBlocks, preserving existing folds app.conv.split.CacheKey = fmt.Sprintf("%d:%d", 1, 3) // old block count @@ -354,7 +400,7 @@ func TestLiveTailTracksNewMessages(t *testing.T) { entries = append(entries, newEntry) app.conv.messages = entries app.conv.merged = filterConversation(mergeConversationTurns(entries)) - app.conv.items = buildConvItems(app.conv.merged, nil, nil, nil) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) contentH := ContentHeight(app.height) app.convList = newConvList(app.conv.items, app.conv.split.ListWidth(app.width, app.splitRatio), contentH) @@ -392,7 +438,7 @@ func TestLiveTailGrowingContent(t *testing.T) { // Grow the entry grown := makeGrowingEntry(base.Add(time.Second), 8) app.conv.merged[len(app.conv.merged)-1] = mergedMsg{entry: grown, startIdx: 1, endIdx: 1} - app.conv.items = buildConvItems(app.conv.merged, nil, nil, nil) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) app.conv.split.CacheKey = fmt.Sprintf("%d:%d", 1, 2) // old count app.updateConvPreview() @@ -499,7 +545,7 @@ func TestLiveTailAlwaysSelectsLastItem(t *testing.T) { // Simulate handleLiveTail inline (refreshConversation needs file I/O, // so rebuild manually) - app.conv.items = buildConvItems(app.conv.merged, nil, nil, nil) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) contentH := ContentHeight(app.height) app.convList = newConvList(app.conv.items, app.conv.split.ListWidth(app.width, app.splitRatio), contentH) app.conv.split.List = &app.convList @@ -564,7 +610,7 @@ func TestLiveTailRefreshNoCachePoisoning(t *testing.T) { grown := makeGrowingEntry(base.Add(time.Second), 8) app.conv.messages = []session.Entry{entries[0], grown} app.conv.merged = filterConversation(mergeConversationTurns(app.conv.messages)) - app.conv.items = buildConvItems(app.conv.merged, nil, nil, nil) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) // Simulate what refreshConversation does (minus LoadMessages I/O) oldIdx := app.convList.Index() @@ -799,7 +845,7 @@ func TestBuildEntityTreeUsesCompactLabels(t *testing.T) { Status: "in_progress", }} - items := buildEntityTree(merged, agents, tasks, nil, map[string]string{"agent-1": "running"}) + items := buildEntityTree(session.Session{}, merged, agents, tasks, nil, map[string]string{"agent-1": "running"}) var agentLabel, bgLabel, taskLabel string for _, item := range items { diff --git a/internal/tui/help.go b/internal/tui/help.go index cbb6577..a902520 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -68,10 +68,16 @@ func (a *App) sessHelpLine() string { h := fmtKey(sk.Open, "open") + " " + fmtKey(sk.Edit, "edit") + " " + fmtKey(sk.Actions, "actions") + " " + fmtKey(sk.Views, "views") + " " + fmtKey(sk.Refresh, "refresh") if !a.sessSplit.Show { h += " →:preview tab:group" - } else if a.sessSplit.Focus && a.sessPreviewMode == sessPreviewConversation { - h += " ↑↓:nav c:full " + fmtKey(sk.Open, "jump") + " →←:fold f/F:all " + fmtKey(sk.Search, "search") + " tab:mode" } else if a.sessSplit.Focus { - h += " tab:mode ←:unfocus " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" + switch a.sessPreviewMode { + case sessPreviewConversation: + h += " ↑↓:nav c:full " + fmtKey(sk.Open, "jump") + " ←:unfocus /:search tab:mode" + case sessPreviewAgents: + h += " ↑↓:nav " + fmtKey(sk.Open, "jump") + " ←:unfocus tab:mode" + default: + h += " ↑↓:scroll ←:unfocus tab:mode" + } + h += " " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" } else { h += " tab:group →:focus ←:close " + displayKey(sk.ResizeShrink) + displayKey(sk.ResizeGrow) + ":resize" } diff --git a/internal/tui/live_preview_test.go b/internal/tui/live_preview_test.go index 0cb3527..1035ea8 100644 --- a/internal/tui/live_preview_test.go +++ b/internal/tui/live_preview_test.go @@ -354,6 +354,7 @@ func TestPreviewModeConstants(t *testing.T) { sessPreviewStats, sessPreviewMemory, sessPreviewTasksPlan, + sessPreviewAgents, sessPreviewLive, sessPreviewRemote, } diff --git a/internal/tui/sessions.go b/internal/tui/sessions.go index 0cc7108..77c3871 100644 --- a/internal/tui/sessions.go +++ b/internal/tui/sessions.go @@ -1011,7 +1011,7 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st {displayKey(sk.Group), "Group (flat→proj→tree→chain)"}, {displayKey(km.Views.Stats), "Global stats"}, {displayKey(sk.Refresh), "Refresh list"}, - {displayKey(sk.Preview), "Cycle preview mode"}, + {displayKey(sk.Preview), "Cycle preview mode (conv→stats→mem→tasks/plan)"}, {displayKey(sk.Live), "Live preview (^Q:unfocus)"}, {displayKey(sk.Select), "Toggle multi-select"}, {displayKey(sk.Help), "This help"}, diff --git a/internal/tui/shortcuts.go b/internal/tui/shortcuts.go index 550305d..3aae108 100644 --- a/internal/tui/shortcuts.go +++ b/internal/tui/shortcuts.go @@ -23,7 +23,8 @@ func DefaultShortcuts() Shortcuts { "2": "preview:stats", "3": "preview:mem", "4": "preview:tasks", - "5": "preview:live", + "5": "preview:agents", + "6": "preview:live", }, }, "conversation": { @@ -84,6 +85,21 @@ func mergeShortcuts(dst Shortcuts, src Shortcuts) { } dst[viewName] = dstVS } + migrateShortcuts(dst) +} + +func migrateShortcuts(sc Shortcuts) { + sess, ok := sc["sessions"] + if !ok || sess.Left == nil { + return + } + if sess.Left["5"] == "preview:live" { + sess.Left["5"] = "preview:agents" + if _, exists := sess.Left["6"]; !exists { + sess.Left["6"] = "preview:live" + } + sc["sessions"] = sess + } } // handleShortcutKey checks if a key press matches a shortcut for the current @@ -100,8 +116,14 @@ func (a *App) handleShortcutKey(key string) (tea.Model, tea.Cmd, bool) { var sm ShortcutMap if side == "right" { sm = vs.Right + if len(sm) == 0 { + sm = vs.Left + } } else { sm = vs.Left + if len(sm) == 0 { + sm = vs.Right + } } if sm == nil { return nil, nil, false @@ -187,8 +209,14 @@ func (a *App) shortcutHint() string { var sm ShortcutMap if side == "right" { sm = vs.Right + if len(sm) == 0 { + sm = vs.Left + } } else { sm = vs.Left + if len(sm) == 0 { + sm = vs.Right + } } if len(sm) == 0 { return "" diff --git a/internal/tui/shortcuts_test.go b/internal/tui/shortcuts_test.go index 5a33939..7aaa1ce 100644 --- a/internal/tui/shortcuts_test.go +++ b/internal/tui/shortcuts_test.go @@ -15,8 +15,11 @@ func TestDefaultShortcuts(t *testing.T) { if sess.Left["1"] != "preview:conv" { t.Errorf("sessions left 1 = %q, want preview:conv", sess.Left["1"]) } - if sess.Left["5"] != "preview:live" { - t.Errorf("sessions left 5 = %q, want preview:live", sess.Left["5"]) + if sess.Left["5"] != "preview:agents" { + t.Errorf("sessions left 5 = %q, want preview:agents", sess.Left["5"]) + } + if sess.Left["6"] != "preview:live" { + t.Errorf("sessions left 6 = %q, want preview:live", sess.Left["6"]) } // Conversation view @@ -111,8 +114,11 @@ func TestShortcutHint(t *testing.T) { if !containsSubstring(hint, "1:conv") { t.Errorf("hint %q should contain 1:conv", hint) } - if !containsSubstring(hint, "5:live") { - t.Errorf("hint %q should contain 5:live", hint) + if !containsSubstring(hint, "5:agents") { + t.Errorf("hint %q should contain 5:agents", hint) + } + if !containsSubstring(hint, "6:live") { + t.Errorf("hint %q should contain 6:live", hint) } } diff --git a/internal/tui/splitpane.go b/internal/tui/splitpane.go index 52f196a..6fd09ce 100644 --- a/internal/tui/splitpane.go +++ b/internal/tui/splitpane.go @@ -543,35 +543,82 @@ func (sp *SplitPane) RefreshFoldCursor(totalW, splitRatio int) { return } - // Fold state unchanged — re-render with new cursor position only - previewW := sp.PreviewWidth(totalW, splitRatio) + oldOffset := sp.Preview.YOffset cursor := sp.Folds.BlockCursor - ro := renderOpts{visible: sp.Folds.BlockVisible, hideHooks: sp.Folds.HideHooks, selected: sp.Folds.Selected} - rp := renderFullMessageWithCursor(sp.Folds.Entry, previewW, sp.Folds.Collapsed, sp.Folds.Formatted, cursor, ro) - sp.Folds.BlockStarts = rp.blockStarts - sp.cachedRP = &rp + starts := sp.cachedRP.blockStarts + if len(starts) == 0 || cursor < 0 || cursor >= len(starts) { + sp.RefreshFoldPreview(totalW, splitRatio) + return + } - oldOffset := sp.Preview.YOffset + oldCursor := -1 + for i, line := range starts { + if line >= oldOffset && line < oldOffset+sp.Preview.Height { + oldCursor = i + break + } + } - content := rp.content - padLines := 0 - if sp.BottomAlign && rp.lineCount < sp.Preview.Height { - padLines = sp.Preview.Height - rp.lineCount - content = strings.Repeat("\n", padLines) + content - for i := range sp.Folds.BlockStarts { - sp.Folds.BlockStarts[i] += padLines + lines := strings.Split(sp.cachedRP.content, "\n") + updateIndicator := func(idx int) { + if idx < 0 || idx >= len(starts) { + return + } + lineIdx := starts[idx] + if lineIdx < 0 || lineIdx >= len(lines) { + return + } + line := lines[lineIdx] + if len(line) < 2 { + return + } + indicator := " " + if idx == cursor { + block := sp.Folds.Entry.Content[idx] + formatted := sp.Folds.Formatted != nil && sp.Folds.Formatted[idx] + folded := sp.Folds.Collapsed != nil && sp.Folds.Collapsed[idx] + isFoldable := block.Type == "tool_use" || block.Type == "tool_result" || block.Type == "thinking" || block.Type == "system_tag" + switch { + case formatted: + indicator = blockCursorStyle.Render("✦") + " " + case isFoldable && folded: + indicator = blockCursorStyle.Render("▸") + " " + case isFoldable: + indicator = blockCursorStyle.Render("▾") + " " + default: + indicator = blockCursorStyle.Render("›") + " " + } + } else { + block := sp.Folds.Entry.Content[idx] + formatted := sp.Folds.Formatted != nil && sp.Folds.Formatted[idx] + folded := sp.Folds.Collapsed != nil && sp.Folds.Collapsed[idx] + isFoldable := block.Type == "tool_use" || block.Type == "tool_result" || block.Type == "thinking" || block.Type == "system_tag" + switch { + case formatted: + indicator = dimStyle.Render("✦") + " " + case isFoldable && folded: + indicator = dimStyle.Render("▸") + " " + case isFoldable: + indicator = dimStyle.Render("▾") + " " + default: + indicator = " " + } + } + if len(line) >= 2 { + lines[lineIdx] = indicator + line[2:] } } - sp.Preview.SetContent(content) - totalLines := sp.Preview.TotalLineCount() - maxOffset := max(totalLines-sp.Preview.Height, 0) + updateIndicator(oldCursor) + updateIndicator(cursor) + + sp.cachedRP.content = strings.Join(lines, "\n") + sp.Preview.SetContent(sp.cachedRP.content) + maxOffset := max(sp.Preview.TotalLineCount()-sp.Preview.Height, 0) if oldOffset > maxOffset { oldOffset = maxOffset } sp.Preview.YOffset = oldOffset - - // Ensure block cursor is visible after content update sp.ScrollToBlock() } diff --git a/internal/tui/state.go b/internal/tui/state.go index ff46be5..5a263db 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -75,6 +75,7 @@ func LoadCCXConfig(path string) (*Keymap, Preferences, Shortcuts, remote.Config) // Merge shortcut overrides over defaults mergeShortcuts(sc, cfg.Shortcuts) + cfg.Shortcuts = sc return &km, cfg.Preferences, sc, cfg.Remote } @@ -259,6 +260,8 @@ func sessPreviewString(mode sessPreview) string { return "mem" case sessPreviewTasksPlan: return "tasks" + case sessPreviewAgents: + return "agents" case sessPreviewLive: return "live" }