fix(modes): keep session info in scrollback
- commit
- f7259b026ec691e211cde9c98575bb110ad326ae
- author
- brice · · 2026-08-18 15:24 UTC
- parent
- 6fc319c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +114 -14
M packages/agent/modes/interactive.go +1 -1
| @@ -683,7 +683,7 @@ type Interactive struct { | |||
| 683 | 683 | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | |
| 684 | 684 | liveBlock []string // live streaming/tool progress rendered outside scrollback | |
| 685 | 685 | helpBlock []string // rendered above the chat when /help was typed | |
| 686 | - | sessionInfoBlock []string // rendered above the chat when /info was typed | |
| 686 | + | sessionInfoBlocks []sessionInfoBlock | |
| 687 | 687 | cumUsage provider.Usage | |
| 688 | 688 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 689 | 689 | busy bool | |
| @@ -683,7 +683,7 @@ type Interactive struct { | |||||
| 683 | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | 683 | rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible | ||
| 684 | liveBlock []string // live streaming/tool progress rendered outside scrollback | 684 | liveBlock []string // live streaming/tool progress rendered outside scrollback | ||
| 685 | helpBlock []string // rendered above the chat when /help was typed | 685 | helpBlock []string // rendered above the chat when /help was typed | ||
| 686 | - | sessionInfoBlock []string // rendered above the chat when /info was typed | 686 | + | sessionInfoBlocks []sessionInfoBlock |
| 687 | cumUsage provider.Usage | 687 | cumUsage provider.Usage | ||
| 688 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 688 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 689 | busy bool | 689 | busy bool | ||
M packages/agent/modes/interactive_commands.go +2 -3
| @@ -228,7 +228,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 228 | 228 | i.statusErr = "" | |
| 229 | 229 | i.statusOK = "" | |
| 230 | 230 | i.helpBlock = nil | |
| 231 | - | i.sessionInfoBlock = nil | |
| 231 | + | i.sessionInfoBlocks = nil | |
| 232 | 232 | i.parkedTurn = 0 | |
| 233 | 233 | i.parkedTotal = 0 | |
| 234 | 234 | i.scrollOffset = 0 | |
| @@ -243,7 +243,6 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 243 | 243 | case "/help": | |
| 244 | 244 | i.mu.Lock() | |
| 245 | 245 | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | |
| 246 | - | i.sessionInfoBlock = nil | |
| 247 | 246 | i.statusErr = "" | |
| 248 | 247 | i.statusOK = "" | |
| 249 | 248 | // Pin the viewport to the newest content so the help block, | |
| @@ -401,7 +400,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 401 | 400 | i.toolOrder = nil | |
| 402 | 401 | i.toolGate = map[string]int{} | |
| 403 | 402 | i.helpBlock = nil | |
| 404 | - | i.sessionInfoBlock = nil | |
| 403 | + | i.sessionInfoBlocks = nil | |
| 405 | 404 | i.parkedTurn = 0 | |
| 406 | 405 | i.statusOK = "cwd " + i.cfg.CWD | |
| 407 | 406 | i.statusErr = "" | |
| @@ -228,7 +228,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 228 | i.statusErr = "" | 228 | i.statusErr = "" | ||
| 229 | i.statusOK = "" | 229 | i.statusOK = "" | ||
| 230 | i.helpBlock = nil | 230 | i.helpBlock = nil | ||
| 231 | - | i.sessionInfoBlock = nil | 231 | + | i.sessionInfoBlocks = nil |
| 232 | i.parkedTurn = 0 | 232 | i.parkedTurn = 0 | ||
| 233 | i.parkedTotal = 0 | 233 | i.parkedTotal = 0 | ||
| 234 | i.scrollOffset = 0 | 234 | i.scrollOffset = 0 | ||
| @@ -243,7 +243,6 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 243 | case "/help": | 243 | case "/help": | ||
| 244 | i.mu.Lock() | 244 | i.mu.Lock() | ||
| 245 | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | 245 | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | ||
| 246 | - | i.sessionInfoBlock = nil | |||
| 247 | i.statusErr = "" | 246 | i.statusErr = "" | ||
| 248 | i.statusOK = "" | 247 | i.statusOK = "" | ||
| 249 | // Pin the viewport to the newest content so the help block, | 248 | // Pin the viewport to the newest content so the help block, | ||
| @@ -401,7 +400,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 401 | i.toolOrder = nil | 400 | i.toolOrder = nil | ||
| 402 | i.toolGate = map[string]int{} | 401 | i.toolGate = map[string]int{} | ||
| 403 | i.helpBlock = nil | 402 | i.helpBlock = nil | ||
| 404 | - | i.sessionInfoBlock = nil | 403 | + | i.sessionInfoBlocks = nil |
| 405 | i.parkedTurn = 0 | 404 | i.parkedTurn = 0 | ||
| 406 | i.statusOK = "cwd " + i.cfg.CWD | 405 | i.statusOK = "cwd " + i.cfg.CWD | ||
| 407 | i.statusErr = "" | 406 | i.statusErr = "" | ||
M packages/agent/modes/interactive_host.go +2 -2
| @@ -119,7 +119,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||
| 119 | 119 | i.toolOrder = nil | |
| 120 | 120 | i.toolGate = map[string]int{} | |
| 121 | 121 | i.helpBlock = nil | |
| 122 | - | i.sessionInfoBlock = nil | |
| 122 | + | i.sessionInfoBlocks = nil | |
| 123 | 123 | i.extNotes = nil | |
| 124 | 124 | i.parkedTurn = 0 | |
| 125 | 125 | i.parkedTotal = 0 | |
| @@ -175,7 +175,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 175 | 175 | i.toolOrder = nil | |
| 176 | 176 | i.toolGate = map[string]int{} | |
| 177 | 177 | i.helpBlock = nil | |
| 178 | - | i.sessionInfoBlock = nil | |
| 178 | + | i.sessionInfoBlocks = nil | |
| 179 | 179 | i.parkedTurn = 0 | |
| 180 | 180 | i.statusErr = "" | |
| 181 | 181 | i.mu.Unlock() | |
| @@ -119,7 +119,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||||
| 119 | i.toolOrder = nil | 119 | i.toolOrder = nil | ||
| 120 | i.toolGate = map[string]int{} | 120 | i.toolGate = map[string]int{} | ||
| 121 | i.helpBlock = nil | 121 | i.helpBlock = nil | ||
| 122 | - | i.sessionInfoBlock = nil | 122 | + | i.sessionInfoBlocks = nil |
| 123 | i.extNotes = nil | 123 | i.extNotes = nil | ||
| 124 | i.parkedTurn = 0 | 124 | i.parkedTurn = 0 | ||
| 125 | i.parkedTotal = 0 | 125 | i.parkedTotal = 0 | ||
| @@ -175,7 +175,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 175 | i.toolOrder = nil | 175 | i.toolOrder = nil | ||
| 176 | i.toolGate = map[string]int{} | 176 | i.toolGate = map[string]int{} | ||
| 177 | i.helpBlock = nil | 177 | i.helpBlock = nil | ||
| 178 | - | i.sessionInfoBlock = nil | 178 | + | i.sessionInfoBlocks = nil |
| 179 | i.parkedTurn = 0 | 179 | i.parkedTurn = 0 | ||
| 180 | i.statusErr = "" | 180 | i.statusErr = "" | ||
| 181 | i.mu.Unlock() | 181 | i.mu.Unlock() | ||
M packages/agent/modes/interactive_rendering.go +10 -6
| @@ -42,7 +42,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey { | |||
| 42 | 42 | statusOK: i.statusOK, | |
| 43 | 43 | statusErr: i.statusErr, | |
| 44 | 44 | help: strings.Join(i.helpBlock, "\n"), | |
| 45 | - | sessionInfo: strings.Join(i.sessionInfoBlock, "\n"), | |
| 45 | + | sessionInfo: sessionInfoBlocksKey(i.sessionInfoBlocks), | |
| 46 | 46 | extNotes: strings.Join(i.extNotes, "\n"), | |
| 47 | 47 | extStatuses: i.extensionStatusesKeyLocked(), | |
| 48 | 48 | extWidgets: i.extensionWidgetsKeyLocked(), | |
| @@ -227,8 +227,10 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string { | |||
| 227 | 227 | renderView.StreamingActive = false | |
| 228 | 228 | renderView.ToolCalls = nil | |
| 229 | 229 | renderView.Err = "" | |
| 230 | + | infoBlocks := append([]sessionInfoBlock(nil), i.sessionInfoBlocks...) | |
| 230 | 231 | if !i.renderOutsideLock { | |
| 231 | - | rows := renderView.Build(cols) | |
| 232 | + | rows, anchors := renderView.BuildWithAnchors(cols) | |
| 233 | + | rows = insertSessionInfoBlocks(rows, anchors, infoBlocks) | |
| 232 | 234 | i.stableChatCache = append(i.stableChatCache[:0], rows...) | |
| 233 | 235 | i.stableChatCacheKey = key | |
| 234 | 236 | i.stableChatCacheValid = true | |
| @@ -238,7 +240,8 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string { | |||
| 238 | 240 | // a frame. Build the immutable snapshot without holding the interactive | |
| 239 | 241 | // mutex so key processing can continue while a cold transcript renders. | |
| 240 | 242 | i.mu.Unlock() | |
| 241 | - | rows := renderView.Build(cols) | |
| 243 | + | rows, anchors := renderView.BuildWithAnchors(cols) | |
| 244 | + | rows = insertSessionInfoBlocks(rows, anchors, infoBlocks) | |
| 242 | 245 | i.mu.Lock() | |
| 243 | 246 | if i.view.MessagesRevision == renderView.MessagesRevision && | |
| 244 | 247 | i.view.RenderCacheRevision == renderView.RenderCacheRevision { | |
| @@ -258,6 +261,10 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 258 | 261 | i.view.Messages = nil | |
| 259 | 262 | i.view.MessagesRevision = 0 | |
| 260 | 263 | } | |
| 264 | + | // Transcript rewinds and compaction can remove messages that an info block | |
| 265 | + | // was anchored after. Clamp those anchors once so later turns still append | |
| 266 | + | // below the block instead of making it jump through the conversation. | |
| 267 | + | clampSessionInfoBlocks(i.sessionInfoBlocks, len(i.view.Messages)) | |
| 261 | 268 | // Pacer flush: while the streaming pacer is still draining the | |
| 262 | 269 | // buffer (i.e. EvAssistantMessage already fired but more runes | |
| 263 | 270 | // are queued), the final assistant message is already in | |
| @@ -350,9 +357,6 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 350 | 357 | if len(i.helpBlock) > 0 { | |
| 351 | 358 | chat = append(chat, i.helpBlock...) | |
| 352 | 359 | } | |
| 353 | - | if len(i.sessionInfoBlock) > 0 { | |
| 354 | - | chat = append(chat, i.sessionInfoBlock...) | |
| 355 | - | } | |
| 356 | 360 | ||
| 357 | 361 | if i.statusOK != "" { | |
| 358 | 362 | // Hard-truncate the OK line to the visible width so a long | |
| @@ -42,7 +42,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey { | |||||
| 42 | statusOK: i.statusOK, | 42 | statusOK: i.statusOK, | ||
| 43 | statusErr: i.statusErr, | 43 | statusErr: i.statusErr, | ||
| 44 | help: strings.Join(i.helpBlock, "\n"), | 44 | help: strings.Join(i.helpBlock, "\n"), | ||
| 45 | - | sessionInfo: strings.Join(i.sessionInfoBlock, "\n"), | 45 | + | sessionInfo: sessionInfoBlocksKey(i.sessionInfoBlocks), |
| 46 | extNotes: strings.Join(i.extNotes, "\n"), | 46 | extNotes: strings.Join(i.extNotes, "\n"), | ||
| 47 | extStatuses: i.extensionStatusesKeyLocked(), | 47 | extStatuses: i.extensionStatusesKeyLocked(), | ||
| 48 | extWidgets: i.extensionWidgetsKeyLocked(), | 48 | extWidgets: i.extensionWidgetsKeyLocked(), | ||
| @@ -227,8 +227,10 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string { | |||||
| 227 | renderView.StreamingActive = false | 227 | renderView.StreamingActive = false | ||
| 228 | renderView.ToolCalls = nil | 228 | renderView.ToolCalls = nil | ||
| 229 | renderView.Err = "" | 229 | renderView.Err = "" | ||
| 230 | + | infoBlocks := append([]sessionInfoBlock(nil), i.sessionInfoBlocks...) | |||
| 230 | if !i.renderOutsideLock { | 231 | if !i.renderOutsideLock { | ||
| 231 | - | rows := renderView.Build(cols) | 232 | + | rows, anchors := renderView.BuildWithAnchors(cols) |
| 233 | + | rows = insertSessionInfoBlocks(rows, anchors, infoBlocks) | |||
| 232 | i.stableChatCache = append(i.stableChatCache[:0], rows...) | 234 | i.stableChatCache = append(i.stableChatCache[:0], rows...) | ||
| 233 | i.stableChatCacheKey = key | 235 | i.stableChatCacheKey = key | ||
| 234 | i.stableChatCacheValid = true | 236 | i.stableChatCacheValid = true | ||
| @@ -238,7 +240,8 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string { | |||||
| 238 | // a frame. Build the immutable snapshot without holding the interactive | 240 | // a frame. Build the immutable snapshot without holding the interactive | ||
| 239 | // mutex so key processing can continue while a cold transcript renders. | 241 | // mutex so key processing can continue while a cold transcript renders. | ||
| 240 | i.mu.Unlock() | 242 | i.mu.Unlock() | ||
| 241 | - | rows := renderView.Build(cols) | 243 | + | rows, anchors := renderView.BuildWithAnchors(cols) |
| 244 | + | rows = insertSessionInfoBlocks(rows, anchors, infoBlocks) | |||
| 242 | i.mu.Lock() | 245 | i.mu.Lock() | ||
| 243 | if i.view.MessagesRevision == renderView.MessagesRevision && | 246 | if i.view.MessagesRevision == renderView.MessagesRevision && | ||
| 244 | i.view.RenderCacheRevision == renderView.RenderCacheRevision { | 247 | i.view.RenderCacheRevision == renderView.RenderCacheRevision { | ||
| @@ -258,6 +261,10 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 258 | i.view.Messages = nil | 261 | i.view.Messages = nil | ||
| 259 | i.view.MessagesRevision = 0 | 262 | i.view.MessagesRevision = 0 | ||
| 260 | } | 263 | } | ||
| 264 | + | // Transcript rewinds and compaction can remove messages that an info block | |||
| 265 | + | // was anchored after. Clamp those anchors once so later turns still append | |||
| 266 | + | // below the block instead of making it jump through the conversation. | |||
| 267 | + | clampSessionInfoBlocks(i.sessionInfoBlocks, len(i.view.Messages)) | |||
| 261 | // Pacer flush: while the streaming pacer is still draining the | 268 | // Pacer flush: while the streaming pacer is still draining the | ||
| 262 | // buffer (i.e. EvAssistantMessage already fired but more runes | 269 | // buffer (i.e. EvAssistantMessage already fired but more runes | ||
| 263 | // are queued), the final assistant message is already in | 270 | // are queued), the final assistant message is already in | ||
| @@ -350,9 +357,6 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 350 | if len(i.helpBlock) > 0 { | 357 | if len(i.helpBlock) > 0 { | ||
| 351 | chat = append(chat, i.helpBlock...) | 358 | chat = append(chat, i.helpBlock...) | ||
| 352 | } | 359 | } | ||
| 353 | - | if len(i.sessionInfoBlock) > 0 { | |||
| 354 | - | chat = append(chat, i.sessionInfoBlock...) | |||
| 355 | - | } | |||
| 356 | 360 | ||||
| 357 | if i.statusOK != "" { | 361 | if i.statusOK != "" { | ||
| 358 | // Hard-truncate the OK line to the visible width so a long | 362 | // Hard-truncate the OK line to the visible width so a long | ||
M packages/agent/modes/session_info.go +55 -1
| @@ -10,6 +10,11 @@ import ( | |||
| 10 | 10 | "github.com/mattn/go-runewidth" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | type sessionInfoBlock struct { | |
| 14 | + | afterMessage int | |
| 15 | + | lines []string | |
| 16 | + | } | |
| 17 | + | ||
| 13 | 18 | // renderSessionInfoBlock formats information owned by the live interactive | |
| 14 | 19 | // session. Paths remain absolute so users can copy the transcript location. | |
| 15 | 20 | func renderSessionInfoBlock(th tui.Theme, width int, sessionID, sessionPath, cwd, provider, model string) []string { | |
| @@ -44,6 +49,48 @@ func renderSessionInfoBlock(th tui.Theme, width int, sessionID, sessionPath, cwd | |||
| 44 | 49 | return append(out, "", frameRule(th, width), "") | |
| 45 | 50 | } | |
| 46 | 51 | ||
| 52 | + | func insertSessionInfoBlocks(rows []string, anchors []tui.MessageAnchor, blocks []sessionInfoBlock) []string { | |
| 53 | + | if len(blocks) == 0 { | |
| 54 | + | return rows | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | out := make([]string, 0, len(rows)) | |
| 58 | + | rowStart := 0 | |
| 59 | + | for _, block := range blocks { | |
| 60 | + | insertAt := len(rows) | |
| 61 | + | if block.afterMessage >= 0 && block.afterMessage < len(anchors) { | |
| 62 | + | insertAt = anchors[block.afterMessage].Row | |
| 63 | + | } | |
| 64 | + | if insertAt < rowStart { | |
| 65 | + | insertAt = rowStart | |
| 66 | + | } | |
| 67 | + | out = append(out, rows[rowStart:insertAt]...) | |
| 68 | + | out = append(out, block.lines...) | |
| 69 | + | rowStart = insertAt | |
| 70 | + | } | |
| 71 | + | return append(out, rows[rowStart:]...) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func clampSessionInfoBlocks(blocks []sessionInfoBlock, messageCount int) { | |
| 75 | + | for idx := range blocks { | |
| 76 | + | if blocks[idx].afterMessage > messageCount { | |
| 77 | + | blocks[idx].afterMessage = messageCount | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func sessionInfoBlocksKey(blocks []sessionInfoBlock) string { | |
| 83 | + | var key strings.Builder | |
| 84 | + | for _, block := range blocks { | |
| 85 | + | fmt.Fprintf(&key, "%d\n", block.afterMessage) | |
| 86 | + | for _, line := range block.lines { | |
| 87 | + | key.WriteString(line) | |
| 88 | + | key.WriteByte('\n') | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | return key.String() | |
| 92 | + | } | |
| 93 | + | ||
| 47 | 94 | func valueOrUnavailable(value string) string { | |
| 48 | 95 | if strings.TrimSpace(value) == "" { | |
| 49 | 96 | return "unavailable" | |
| @@ -84,10 +131,17 @@ func (i *Interactive) showSessionInfo() { | |||
| 84 | 131 | width, _ = terminal.Size() | |
| 85 | 132 | } | |
| 86 | 133 | block := renderSessionInfoBlock(theme, width, sessionID, absoluteSessionInfoPath(sessionPath), absoluteSessionInfoPath(cwd), providerName, model) | |
| 134 | + | messageCount := 0 | |
| 135 | + | if agent != nil { | |
| 136 | + | messageCount = len(filterHiddenTranscriptMessages(agent.Messages())) | |
| 137 | + | } | |
| 87 | 138 | ||
| 88 | 139 | i.mu.Lock() | |
| 89 | 140 | i.helpBlock = nil | |
| 90 | - | i.sessionInfoBlock = block | |
| 141 | + | i.sessionInfoBlocks = append(i.sessionInfoBlocks, sessionInfoBlock{ | |
| 142 | + | afterMessage: messageCount, | |
| 143 | + | lines: block, | |
| 144 | + | }) | |
| 91 | 145 | i.statusErr = "" | |
| 92 | 146 | i.statusOK = "" | |
| 93 | 147 | i.scrollOffset = 0 | |
| @@ -10,6 +10,11 @@ import ( | |||||
| 10 | "github.com/mattn/go-runewidth" | 10 | "github.com/mattn/go-runewidth" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | type sessionInfoBlock struct { | |||
| 14 | + | afterMessage int | |||
| 15 | + | lines []string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 13 | // renderSessionInfoBlock formats information owned by the live interactive | 18 | // renderSessionInfoBlock formats information owned by the live interactive | ||
| 14 | // session. Paths remain absolute so users can copy the transcript location. | 19 | // session. Paths remain absolute so users can copy the transcript location. | ||
| 15 | func renderSessionInfoBlock(th tui.Theme, width int, sessionID, sessionPath, cwd, provider, model string) []string { | 20 | func renderSessionInfoBlock(th tui.Theme, width int, sessionID, sessionPath, cwd, provider, model string) []string { | ||
| @@ -44,6 +49,48 @@ func renderSessionInfoBlock(th tui.Theme, width int, sessionID, sessionPath, cwd | |||||
| 44 | return append(out, "", frameRule(th, width), "") | 49 | return append(out, "", frameRule(th, width), "") | ||
| 45 | } | 50 | } | ||
| 46 | 51 | ||||
| 52 | + | func insertSessionInfoBlocks(rows []string, anchors []tui.MessageAnchor, blocks []sessionInfoBlock) []string { | |||
| 53 | + | if len(blocks) == 0 { | |||
| 54 | + | return rows | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | out := make([]string, 0, len(rows)) | |||
| 58 | + | rowStart := 0 | |||
| 59 | + | for _, block := range blocks { | |||
| 60 | + | insertAt := len(rows) | |||
| 61 | + | if block.afterMessage >= 0 && block.afterMessage < len(anchors) { | |||
| 62 | + | insertAt = anchors[block.afterMessage].Row | |||
| 63 | + | } | |||
| 64 | + | if insertAt < rowStart { | |||
| 65 | + | insertAt = rowStart | |||
| 66 | + | } | |||
| 67 | + | out = append(out, rows[rowStart:insertAt]...) | |||
| 68 | + | out = append(out, block.lines...) | |||
| 69 | + | rowStart = insertAt | |||
| 70 | + | } | |||
| 71 | + | return append(out, rows[rowStart:]...) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func clampSessionInfoBlocks(blocks []sessionInfoBlock, messageCount int) { | |||
| 75 | + | for idx := range blocks { | |||
| 76 | + | if blocks[idx].afterMessage > messageCount { | |||
| 77 | + | blocks[idx].afterMessage = messageCount | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func sessionInfoBlocksKey(blocks []sessionInfoBlock) string { | |||
| 83 | + | var key strings.Builder | |||
| 84 | + | for _, block := range blocks { | |||
| 85 | + | fmt.Fprintf(&key, "%d\n", block.afterMessage) | |||
| 86 | + | for _, line := range block.lines { | |||
| 87 | + | key.WriteString(line) | |||
| 88 | + | key.WriteByte('\n') | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | return key.String() | |||
| 92 | + | } | |||
| 93 | + | ||||
| 47 | func valueOrUnavailable(value string) string { | 94 | func valueOrUnavailable(value string) string { | ||
| 48 | if strings.TrimSpace(value) == "" { | 95 | if strings.TrimSpace(value) == "" { | ||
| 49 | return "unavailable" | 96 | return "unavailable" | ||
| @@ -84,10 +131,17 @@ func (i *Interactive) showSessionInfo() { | |||||
| 84 | width, _ = terminal.Size() | 131 | width, _ = terminal.Size() | ||
| 85 | } | 132 | } | ||
| 86 | block := renderSessionInfoBlock(theme, width, sessionID, absoluteSessionInfoPath(sessionPath), absoluteSessionInfoPath(cwd), providerName, model) | 133 | block := renderSessionInfoBlock(theme, width, sessionID, absoluteSessionInfoPath(sessionPath), absoluteSessionInfoPath(cwd), providerName, model) | ||
| 134 | + | messageCount := 0 | |||
| 135 | + | if agent != nil { | |||
| 136 | + | messageCount = len(filterHiddenTranscriptMessages(agent.Messages())) | |||
| 137 | + | } | |||
| 87 | 138 | ||||
| 88 | i.mu.Lock() | 139 | i.mu.Lock() | ||
| 89 | i.helpBlock = nil | 140 | i.helpBlock = nil | ||
| 90 | - | i.sessionInfoBlock = block | 141 | + | i.sessionInfoBlocks = append(i.sessionInfoBlocks, sessionInfoBlock{ |
| 142 | + | afterMessage: messageCount, | |||
| 143 | + | lines: block, | |||
| 144 | + | }) | |||
| 91 | i.statusErr = "" | 145 | i.statusErr = "" | ||
| 92 | i.statusOK = "" | 146 | i.statusOK = "" | ||
| 93 | i.scrollOffset = 0 | 147 | i.scrollOffset = 0 | ||
M packages/agent/modes/session_info_test.go +44 -1
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/zut/packages/core" | |
| 10 | + | "github.com/bnema/zut/packages/provider" | |
| 10 | 11 | "github.com/bnema/zut/packages/tui" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -30,7 +31,10 @@ func TestRunInfoShowsCurrentSessionDetails(t *testing.T) { | |||
| 30 | 31 | ||
| 31 | 32 | i.runSlash(context.Background(), "/INFO") | |
| 32 | 33 | ||
| 33 | - | got := strings.Join(i.sessionInfoBlock, "\n") | |
| 34 | + | if len(i.sessionInfoBlocks) != 1 { | |
| 35 | + | t.Fatalf("/info blocks = %d, want 1", len(i.sessionInfoBlocks)) | |
| 36 | + | } | |
| 37 | + | got := strings.Join(i.sessionInfoBlocks[0].lines, "\n") | |
| 34 | 38 | for _, want := range []string{ | |
| 35 | 39 | "session info", | |
| 36 | 40 | "session-123", | |
| @@ -46,6 +50,45 @@ func TestRunInfoShowsCurrentSessionDetails(t *testing.T) { | |||
| 46 | 50 | if len(i.helpBlock) != 0 { | |
| 47 | 51 | t.Fatalf("/info retained help block: %#v", i.helpBlock) | |
| 48 | 52 | } | |
| 53 | + | if got := len(agent.Messages()); got != 0 { | |
| 54 | + | t.Fatalf("/info added %d messages to provider context", got) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestRunInfoAppendsEachInvocation(t *testing.T) { | |
| 59 | + | i := &Interactive{} | |
| 60 | + | ||
| 61 | + | i.runSlash(context.Background(), "/info") | |
| 62 | + | i.runSlash(context.Background(), "/info") | |
| 63 | + | ||
| 64 | + | if got := len(i.sessionInfoBlocks); got != 2 { | |
| 65 | + | t.Fatalf("/info blocks = %d, want one per invocation", got) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestInfoScrollsWithTranscript(t *testing.T) { | |
| 70 | + | agent := &core.Agent{} | |
| 71 | + | agent.SetMessages([]provider.Message{{ | |
| 72 | + | Role: provider.RoleAssistant, | |
| 73 | + | Content: []provider.Content{provider.TextBlock{Text: "before info"}}, | |
| 74 | + | }}) | |
| 75 | + | i := &Interactive{agent: agent, view: &tui.View{}} | |
| 76 | + | ||
| 77 | + | i.runSlash(context.Background(), "/info") | |
| 78 | + | agent.SetMessages(append(agent.Messages(), provider.Message{ | |
| 79 | + | Role: provider.RoleUser, | |
| 80 | + | Content: []provider.Content{provider.TextBlock{Text: "after info"}}, | |
| 81 | + | })) | |
| 82 | + | ||
| 83 | + | i.mu.Lock() | |
| 84 | + | got := strings.Join(i.buildChatLocked(80), "\n") | |
| 85 | + | i.mu.Unlock() | |
| 86 | + | before := strings.Index(got, "before info") | |
| 87 | + | info := strings.Index(got, "session info") | |
| 88 | + | after := strings.Index(got, "after info") | |
| 89 | + | if before < 0 || info < before || after < info { | |
| 90 | + | t.Fatalf("/info did not stay at its transcript position:\n%s", got) | |
| 91 | + | } | |
| 49 | 92 | } | |
| 50 | 93 | ||
| 51 | 94 | func TestRenderSessionInfoMarksUnavailableValues(t *testing.T) { | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/zut/packages/core" | 9 | "github.com/bnema/zut/packages/core" | ||
| 10 | + | "github.com/bnema/zut/packages/provider" | |||
| 10 | "github.com/bnema/zut/packages/tui" | 11 | "github.com/bnema/zut/packages/tui" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| @@ -30,7 +31,10 @@ func TestRunInfoShowsCurrentSessionDetails(t *testing.T) { | |||||
| 30 | 31 | ||||
| 31 | i.runSlash(context.Background(), "/INFO") | 32 | i.runSlash(context.Background(), "/INFO") | ||
| 32 | 33 | ||||
| 33 | - | got := strings.Join(i.sessionInfoBlock, "\n") | 34 | + | if len(i.sessionInfoBlocks) != 1 { |
| 35 | + | t.Fatalf("/info blocks = %d, want 1", len(i.sessionInfoBlocks)) | |||
| 36 | + | } | |||
| 37 | + | got := strings.Join(i.sessionInfoBlocks[0].lines, "\n") | |||
| 34 | for _, want := range []string{ | 38 | for _, want := range []string{ | ||
| 35 | "session info", | 39 | "session info", | ||
| 36 | "session-123", | 40 | "session-123", | ||
| @@ -46,6 +50,45 @@ func TestRunInfoShowsCurrentSessionDetails(t *testing.T) { | |||||
| 46 | if len(i.helpBlock) != 0 { | 50 | if len(i.helpBlock) != 0 { | ||
| 47 | t.Fatalf("/info retained help block: %#v", i.helpBlock) | 51 | t.Fatalf("/info retained help block: %#v", i.helpBlock) | ||
| 48 | } | 52 | } | ||
| 53 | + | if got := len(agent.Messages()); got != 0 { | |||
| 54 | + | t.Fatalf("/info added %d messages to provider context", got) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestRunInfoAppendsEachInvocation(t *testing.T) { | |||
| 59 | + | i := &Interactive{} | |||
| 60 | + | ||||
| 61 | + | i.runSlash(context.Background(), "/info") | |||
| 62 | + | i.runSlash(context.Background(), "/info") | |||
| 63 | + | ||||
| 64 | + | if got := len(i.sessionInfoBlocks); got != 2 { | |||
| 65 | + | t.Fatalf("/info blocks = %d, want one per invocation", got) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestInfoScrollsWithTranscript(t *testing.T) { | |||
| 70 | + | agent := &core.Agent{} | |||
| 71 | + | agent.SetMessages([]provider.Message{{ | |||
| 72 | + | Role: provider.RoleAssistant, | |||
| 73 | + | Content: []provider.Content{provider.TextBlock{Text: "before info"}}, | |||
| 74 | + | }}) | |||
| 75 | + | i := &Interactive{agent: agent, view: &tui.View{}} | |||
| 76 | + | ||||
| 77 | + | i.runSlash(context.Background(), "/info") | |||
| 78 | + | agent.SetMessages(append(agent.Messages(), provider.Message{ | |||
| 79 | + | Role: provider.RoleUser, | |||
| 80 | + | Content: []provider.Content{provider.TextBlock{Text: "after info"}}, | |||
| 81 | + | })) | |||
| 82 | + | ||||
| 83 | + | i.mu.Lock() | |||
| 84 | + | got := strings.Join(i.buildChatLocked(80), "\n") | |||
| 85 | + | i.mu.Unlock() | |||
| 86 | + | before := strings.Index(got, "before info") | |||
| 87 | + | info := strings.Index(got, "session info") | |||
| 88 | + | after := strings.Index(got, "after info") | |||
| 89 | + | if before < 0 || info < before || after < info { | |||
| 90 | + | t.Fatalf("/info did not stay at its transcript position:\n%s", got) | |||
| 91 | + | } | |||
| 49 | } | 92 | } | ||
| 50 | 93 | ||||
| 51 | func TestRenderSessionInfoMarksUnavailableValues(t *testing.T) { | 94 | func TestRenderSessionInfoMarksUnavailableValues(t *testing.T) { | ||