detached bb1e1e8

Merge pull request #146 from bnema/fix/session-info-transcript

fix(modes): keep session info in scrollback
commit
bb1e1e817e798161f9e872b49de2aaa0ba440375
author
bnema · · 2026-08-18 15:43 UTC
committer
GitHub · · 2026-08-18 15:43 UTC
merge of
f4d798e + f7259b0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +114 -14
M packages/agent/modes/interactive.go +1 -1
@@ -683,7 +683,7 @@ type Interactive struct {
683683 rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
684684 liveBlock []string // live streaming/tool progress rendered outside scrollback
685685 helpBlock []string // rendered above the chat when /help was typed
686- sessionInfoBlock []string // rendered above the chat when /info was typed
686+ sessionInfoBlocks []sessionInfoBlock
687687 cumUsage provider.Usage
688688 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
689689 busy bool
@@ -683,7 +683,7 @@ type Interactive struct {
683 rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible683 rightBarHidden bool // session-only Ctrl+B toggle; zero value keeps the rail visible
684 liveBlock []string // live streaming/tool progress rendered outside scrollback684 liveBlock []string // live streaming/tool progress rendered outside scrollback
685 helpBlock []string // rendered above the chat when /help was typed685 helpBlock []string // rendered above the chat when /help was typed
686- sessionInfoBlock []string // rendered above the chat when /info was typed686+ sessionInfoBlocks []sessionInfoBlock
687 cumUsage provider.Usage687 cumUsage provider.Usage
688 lastCtxInput int // input_tokens of the most recent turn — approximates current context size688 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
689 busy bool689 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) {
228228 i.statusErr = ""
229229 i.statusOK = ""
230230 i.helpBlock = nil
231- i.sessionInfoBlock = nil
231+ i.sessionInfoBlocks = nil
232232 i.parkedTurn = 0
233233 i.parkedTotal = 0
234234 i.scrollOffset = 0
@@ -243,7 +243,6 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
243243 case "/help":
244244 i.mu.Lock()
245245 i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
246- i.sessionInfoBlock = nil
247246 i.statusErr = ""
248247 i.statusOK = ""
249248 // 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) {
401400 i.toolOrder = nil
402401 i.toolGate = map[string]int{}
403402 i.helpBlock = nil
404- i.sessionInfoBlock = nil
403+ i.sessionInfoBlocks = nil
405404 i.parkedTurn = 0
406405 i.statusOK = "cwd " + i.cfg.CWD
407406 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 = nil230 i.helpBlock = nil
231- i.sessionInfoBlock = nil231+ i.sessionInfoBlocks = nil
232 i.parkedTurn = 0232 i.parkedTurn = 0
233 i.parkedTotal = 0233 i.parkedTotal = 0
234 i.scrollOffset = 0234 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 = nil400 i.toolOrder = nil
402 i.toolGate = map[string]int{}401 i.toolGate = map[string]int{}
403 i.helpBlock = nil402 i.helpBlock = nil
404- i.sessionInfoBlock = nil403+ i.sessionInfoBlocks = nil
405 i.parkedTurn = 0404 i.parkedTurn = 0
406 i.statusOK = "cwd " + i.cfg.CWD405 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
119119 i.toolOrder = nil
120120 i.toolGate = map[string]int{}
121121 i.helpBlock = nil
122- i.sessionInfoBlock = nil
122+ i.sessionInfoBlocks = nil
123123 i.extNotes = nil
124124 i.parkedTurn = 0
125125 i.parkedTotal = 0
@@ -175,7 +175,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
175175 i.toolOrder = nil
176176 i.toolGate = map[string]int{}
177177 i.helpBlock = nil
178- i.sessionInfoBlock = nil
178+ i.sessionInfoBlocks = nil
179179 i.parkedTurn = 0
180180 i.statusErr = ""
181181 i.mu.Unlock()
@@ -119,7 +119,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid
119 i.toolOrder = nil119 i.toolOrder = nil
120 i.toolGate = map[string]int{}120 i.toolGate = map[string]int{}
121 i.helpBlock = nil121 i.helpBlock = nil
122- i.sessionInfoBlock = nil122+ i.sessionInfoBlocks = nil
123 i.extNotes = nil123 i.extNotes = nil
124 i.parkedTurn = 0124 i.parkedTurn = 0
125 i.parkedTotal = 0125 i.parkedTotal = 0
@@ -175,7 +175,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
175 i.toolOrder = nil175 i.toolOrder = nil
176 i.toolGate = map[string]int{}176 i.toolGate = map[string]int{}
177 i.helpBlock = nil177 i.helpBlock = nil
178- i.sessionInfoBlock = nil178+ i.sessionInfoBlocks = nil
179 i.parkedTurn = 0179 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
@@ -61,7 +61,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
6161 statusOK: i.statusOK,
6262 statusErr: i.statusErr,
6363 help: strings.Join(i.helpBlock, "\n"),
64- sessionInfo: strings.Join(i.sessionInfoBlock, "\n"),
64+ sessionInfo: sessionInfoBlocksKey(i.sessionInfoBlocks),
6565 extNotes: strings.Join(i.extNotes, "\n"),
6666 extStatuses: i.extensionStatusesKeyLocked(),
6767 extWidgets: i.extensionWidgetsKeyLocked(),
@@ -246,8 +246,10 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
246246 renderView.StreamingActive = false
247247 renderView.ToolCalls = nil
248248 renderView.Err = ""
249+ infoBlocks := append([]sessionInfoBlock(nil), i.sessionInfoBlocks...)
249250 if !i.renderOutsideLock {
250- rows := renderView.Build(cols)
251+ rows, anchors := renderView.BuildWithAnchors(cols)
252+ rows = insertSessionInfoBlocks(rows, anchors, infoBlocks)
251253 i.stableChatCache = append(i.stableChatCache[:0], rows...)
252254 i.stableChatCacheKey = key
253255 i.stableChatCacheValid = true
@@ -257,7 +259,8 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
257259 // a frame. Build the immutable snapshot without holding the interactive
258260 // mutex so key processing can continue while a cold transcript renders.
259261 i.mu.Unlock()
260- rows := renderView.Build(cols)
262+ rows, anchors := renderView.BuildWithAnchors(cols)
263+ rows = insertSessionInfoBlocks(rows, anchors, infoBlocks)
261264 i.mu.Lock()
262265 if i.view.MessagesRevision == renderView.MessagesRevision &&
263266 i.view.RenderCacheRevision == renderView.RenderCacheRevision {
@@ -277,6 +280,10 @@ func (i *Interactive) buildChatLocked(cols int) []string {
277280 i.view.Messages = nil
278281 i.view.MessagesRevision = 0
279282 }
283+ // Transcript rewinds and compaction can remove messages that an info block
284+ // was anchored after. Clamp those anchors once so later turns still append
285+ // below the block instead of making it jump through the conversation.
286+ clampSessionInfoBlocks(i.sessionInfoBlocks, len(i.view.Messages))
280287 // Pacer flush: while the streaming pacer is still draining the
281288 // buffer (i.e. EvAssistantMessage already fired but more runes
282289 // are queued), the final assistant message is already in
@@ -369,9 +376,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
369376 if len(i.helpBlock) > 0 {
370377 chat = append(chat, i.helpBlock...)
371378 }
372- if len(i.sessionInfoBlock) > 0 {
373- chat = append(chat, i.sessionInfoBlock...)
374- }
375379
376380 if i.statusOK != "" {
377381 // Hard-truncate the OK line to the visible width so a long
@@ -61,7 +61,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
61 statusOK: i.statusOK,61 statusOK: i.statusOK,
62 statusErr: i.statusErr,62 statusErr: i.statusErr,
63 help: strings.Join(i.helpBlock, "\n"),63 help: strings.Join(i.helpBlock, "\n"),
64- sessionInfo: strings.Join(i.sessionInfoBlock, "\n"),64+ sessionInfo: sessionInfoBlocksKey(i.sessionInfoBlocks),
65 extNotes: strings.Join(i.extNotes, "\n"),65 extNotes: strings.Join(i.extNotes, "\n"),
66 extStatuses: i.extensionStatusesKeyLocked(),66 extStatuses: i.extensionStatusesKeyLocked(),
67 extWidgets: i.extensionWidgetsKeyLocked(),67 extWidgets: i.extensionWidgetsKeyLocked(),
@@ -246,8 +246,10 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
246 renderView.StreamingActive = false246 renderView.StreamingActive = false
247 renderView.ToolCalls = nil247 renderView.ToolCalls = nil
248 renderView.Err = ""248 renderView.Err = ""
249+ infoBlocks := append([]sessionInfoBlock(nil), i.sessionInfoBlocks...)
249 if !i.renderOutsideLock {250 if !i.renderOutsideLock {
250- rows := renderView.Build(cols)251+ rows, anchors := renderView.BuildWithAnchors(cols)
252+ rows = insertSessionInfoBlocks(rows, anchors, infoBlocks)
251 i.stableChatCache = append(i.stableChatCache[:0], rows...)253 i.stableChatCache = append(i.stableChatCache[:0], rows...)
252 i.stableChatCacheKey = key254 i.stableChatCacheKey = key
253 i.stableChatCacheValid = true255 i.stableChatCacheValid = true
@@ -257,7 +259,8 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
257 // a frame. Build the immutable snapshot without holding the interactive259 // a frame. Build the immutable snapshot without holding the interactive
258 // mutex so key processing can continue while a cold transcript renders.260 // mutex so key processing can continue while a cold transcript renders.
259 i.mu.Unlock()261 i.mu.Unlock()
260- rows := renderView.Build(cols)262+ rows, anchors := renderView.BuildWithAnchors(cols)
263+ rows = insertSessionInfoBlocks(rows, anchors, infoBlocks)
261 i.mu.Lock()264 i.mu.Lock()
262 if i.view.MessagesRevision == renderView.MessagesRevision &&265 if i.view.MessagesRevision == renderView.MessagesRevision &&
263 i.view.RenderCacheRevision == renderView.RenderCacheRevision {266 i.view.RenderCacheRevision == renderView.RenderCacheRevision {
@@ -277,6 +280,10 @@ func (i *Interactive) buildChatLocked(cols int) []string {
277 i.view.Messages = nil280 i.view.Messages = nil
278 i.view.MessagesRevision = 0281 i.view.MessagesRevision = 0
279 }282 }
283+ // Transcript rewinds and compaction can remove messages that an info block
284+ // was anchored after. Clamp those anchors once so later turns still append
285+ // below the block instead of making it jump through the conversation.
286+ clampSessionInfoBlocks(i.sessionInfoBlocks, len(i.view.Messages))
280 // Pacer flush: while the streaming pacer is still draining the287 // Pacer flush: while the streaming pacer is still draining the
281 // buffer (i.e. EvAssistantMessage already fired but more runes288 // buffer (i.e. EvAssistantMessage already fired but more runes
282 // are queued), the final assistant message is already in289 // are queued), the final assistant message is already in
@@ -369,9 +376,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
369 if len(i.helpBlock) > 0 {376 if len(i.helpBlock) > 0 {
370 chat = append(chat, i.helpBlock...)377 chat = append(chat, i.helpBlock...)
371 }378 }
372- if len(i.sessionInfoBlock) > 0 {
373- chat = append(chat, i.sessionInfoBlock...)
374- }
375 379
376 if i.statusOK != "" {380 if i.statusOK != "" {
377 // Hard-truncate the OK line to the visible width so a long381 // 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 (
1010 "github.com/mattn/go-runewidth"
1111 )
1212
13+type sessionInfoBlock struct {
14+ afterMessage int
15+ lines []string
16+}
17+
1318 // renderSessionInfoBlock formats information owned by the live interactive
1419 // session. Paths remain absolute so users can copy the transcript location.
1520 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
4449 return append(out, "", frameRule(th, width), "")
4550 }
4651
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+
4794 func valueOrUnavailable(value string) string {
4895 if strings.TrimSpace(value) == "" {
4996 return "unavailable"
@@ -84,10 +131,17 @@ func (i *Interactive) showSessionInfo() {
84131 width, _ = terminal.Size()
85132 }
86133 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+ }
87138
88139 i.mu.Lock()
89140 i.helpBlock = nil
90- i.sessionInfoBlock = block
141+ i.sessionInfoBlocks = append(i.sessionInfoBlocks, sessionInfoBlock{
142+ afterMessage: messageCount,
143+ lines: block,
144+ })
91145 i.statusErr = ""
92146 i.statusOK = ""
93147 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 interactive18 // 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 = nil140 i.helpBlock = nil
90- i.sessionInfoBlock = block141+ 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 = 0147 i.scrollOffset = 0
M packages/agent/modes/session_info_test.go +44 -1
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
1011 "github.com/bnema/zut/packages/tui"
1112 )
1213
@@ -30,7 +31,10 @@ func TestRunInfoShowsCurrentSessionDetails(t *testing.T) {
3031
3132 i.runSlash(context.Background(), "/INFO")
3233
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")
3438 for _, want := range []string{
3539 "session info",
3640 "session-123",
@@ -46,6 +50,45 @@ func TestRunInfoShowsCurrentSessionDetails(t *testing.T) {
4650 if len(i.helpBlock) != 0 {
4751 t.Fatalf("/info retained help block: %#v", i.helpBlock)
4852 }
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+ }
4992 }
5093
5194 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close