detached f7259b0

fix(modes): keep session info in scrollback

commit
f7259b026ec691e211cde9c98575bb110ad326ae
author
brice · · 2026-08-18 15:24 UTC
parent
6fc319c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
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
@@ -42,7 +42,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
4242 statusOK: i.statusOK,
4343 statusErr: i.statusErr,
4444 help: strings.Join(i.helpBlock, "\n"),
45- sessionInfo: strings.Join(i.sessionInfoBlock, "\n"),
45+ sessionInfo: sessionInfoBlocksKey(i.sessionInfoBlocks),
4646 extNotes: strings.Join(i.extNotes, "\n"),
4747 extStatuses: i.extensionStatusesKeyLocked(),
4848 extWidgets: i.extensionWidgetsKeyLocked(),
@@ -227,8 +227,10 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
227227 renderView.StreamingActive = false
228228 renderView.ToolCalls = nil
229229 renderView.Err = ""
230+ infoBlocks := append([]sessionInfoBlock(nil), i.sessionInfoBlocks...)
230231 if !i.renderOutsideLock {
231- rows := renderView.Build(cols)
232+ rows, anchors := renderView.BuildWithAnchors(cols)
233+ rows = insertSessionInfoBlocks(rows, anchors, infoBlocks)
232234 i.stableChatCache = append(i.stableChatCache[:0], rows...)
233235 i.stableChatCacheKey = key
234236 i.stableChatCacheValid = true
@@ -238,7 +240,8 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
238240 // a frame. Build the immutable snapshot without holding the interactive
239241 // mutex so key processing can continue while a cold transcript renders.
240242 i.mu.Unlock()
241- rows := renderView.Build(cols)
243+ rows, anchors := renderView.BuildWithAnchors(cols)
244+ rows = insertSessionInfoBlocks(rows, anchors, infoBlocks)
242245 i.mu.Lock()
243246 if i.view.MessagesRevision == renderView.MessagesRevision &&
244247 i.view.RenderCacheRevision == renderView.RenderCacheRevision {
@@ -258,6 +261,10 @@ func (i *Interactive) buildChatLocked(cols int) []string {
258261 i.view.Messages = nil
259262 i.view.MessagesRevision = 0
260263 }
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))
261268 // Pacer flush: while the streaming pacer is still draining the
262269 // buffer (i.e. EvAssistantMessage already fired but more runes
263270 // are queued), the final assistant message is already in
@@ -350,9 +357,6 @@ func (i *Interactive) buildChatLocked(cols int) []string {
350357 if len(i.helpBlock) > 0 {
351358 chat = append(chat, i.helpBlock...)
352359 }
353- if len(i.sessionInfoBlock) > 0 {
354- chat = append(chat, i.sessionInfoBlock...)
355- }
356360
357361 if i.statusOK != "" {
358362 // 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 = false227 renderView.StreamingActive = false
228 renderView.ToolCalls = nil228 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 = key235 i.stableChatCacheKey = key
234 i.stableChatCacheValid = true236 i.stableChatCacheValid = true
@@ -238,7 +240,8 @@ func (i *Interactive) stableChatRowsLocked(cols int) []string {
238 // a frame. Build the immutable snapshot without holding the interactive240 // 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 = nil261 i.view.Messages = nil
259 i.view.MessagesRevision = 0262 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 the268 // Pacer flush: while the streaming pacer is still draining the
262 // buffer (i.e. EvAssistantMessage already fired but more runes269 // buffer (i.e. EvAssistantMessage already fired but more runes
263 // are queued), the final assistant message is already in270 // 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 long362 // 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