diff --git a/go/tui/internal/protocol/commands_test.go b/go/tui/internal/protocol/commands_test.go index ec89ff6af..a3a03a64a 100644 --- a/go/tui/internal/protocol/commands_test.go +++ b/go/tui/internal/protocol/commands_test.go @@ -1257,6 +1257,35 @@ func TestDecodeAgentChatSkipsRetiredMessagesSection(t *testing.T) { } } +func TestDecodeAgentChatInputFocusedSectionRoundTripAndDefault(t *testing.T) { + for _, test := range []struct { + name string + sections [][]byte + want bool + }{ + {name: "focused", sections: [][]byte{section(0x01, []byte{1, 0}), section(0x09, []byte{1})}, want: true}, + {name: "not focused", sections: [][]byte{section(0x01, []byte{1, 0}), section(0x09, []byte{0})}, want: false}, + {name: "absent defaults false", sections: [][]byte{section(0x01, []byte{1, 0})}, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + payload := []byte{generated.OPGuiAgentChat, byte(len(test.sections))} + for _, encoded := range test.sections { + payload = append(payload, encoded...) + } + command, err := DecodeCommand(payload) + if err != nil { + t.Fatalf("DecodeCommand returned error: %v", err) + } + if command.Size != len(payload) { + t.Fatalf("consumed = %d, want %d", command.Size, len(payload)) + } + if got := command.Chrome.AgentChat.InputFocused; got != test.want { + t.Fatalf("InputFocused = %v, want %v", got, test.want) + } + }) + } +} + func TestDecodeAgentTimelineChrome(t *testing.T) { timelinePayload := []byte{1, 0xFF, 0xFF, 1, 3} timelinePayload = append(timelinePayload, string8("apply_patch")...) diff --git a/go/tui/internal/protocol/events_test.go b/go/tui/internal/protocol/events_test.go index 7fb370de4..f0f883bca 100644 --- a/go/tui/internal/protocol/events_test.go +++ b/go/tui/internal/protocol/events_test.go @@ -135,6 +135,24 @@ func TestEncodeGUIFloatPopupDismiss(t *testing.T) { } } +func TestEncodeGUIChatPinTransitions(t *testing.T) { + tests := []struct { + name string + got []byte + want []byte + }{ + {"scrolled away", EncodeGUIChatScrolledAwayFromBottom(), []byte{generated.OPGuiAction, generated.GUIActionChatScrolledAwayFromBottom}}, + {"returned", EncodeGUIChatReturnedToBottom(), []byte{generated.OPGuiAction, generated.GUIActionChatReturnedToBottom}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if !bytes.Equal(test.got, test.want) { + t.Fatalf("packet = %v, want %v", test.got, test.want) + } + }) + } +} + func TestEncodeScrollBatchDown(t *testing.T) { got := EncodeScrollBatch(42, 3, 0) want := []byte{generated.OPScrollBatch, 0, 42, 0, 3, 0} diff --git a/go/tui/internal/ui/agent_chat_panel.go b/go/tui/internal/ui/agent_chat_panel.go index aa1892739..9cd5bea97 100644 --- a/go/tui/internal/ui/agent_chat_panel.go +++ b/go/tui/internal/ui/agent_chat_panel.go @@ -154,13 +154,7 @@ func agentColumnGap() int { func (m Model) renderAgentMainColumn(chat protocol.AgentChat, width int, budget int, empty bool) []string { lines := make([]string, 0, budget) composer := m.renderAgentComposer(chat, width) - composerHeight := min(len(composer), max(budget, 0)) - transcriptBudget := max(budget-composerHeight, 0) - statusHeight := 0 - if transcriptBudget >= 4 { - statusHeight = 1 - } - contentBudget := max(transcriptBudget-statusHeight, 0) + composerHeight, statusHeight, contentBudget, messageBudget := agentMainColumnBudgets(chat, len(composer), budget) messageCount := len(m.agentTranscriptMessages()) sparse := messageCount <= 1 && chat.Pending == "" && strings.TrimSpace(chat.Prompt) == "" if chat.Pending != "" && len(lines) < contentBudget { @@ -171,7 +165,7 @@ func (m Model) renderAgentMainColumn(chat protocol.AgentChat, width int, budget lines = append(lines, m.renderAgentTranscriptHeader(width)) } - messageLines := m.renderAgentResidentTranscript(max(contentBudget-len(lines), 0), width) + messageLines := m.renderAgentResidentTranscript(messageBudget, width) lines = append(lines, messageLines...) if empty && len(lines) < contentBudget { @@ -190,6 +184,39 @@ func (m Model) renderAgentMainColumn(chat protocol.AgentChat, width int, budget return takeLines(lines, budget) } +func agentMainColumnBudgets(chat protocol.AgentChat, composerRows int, budget int) (composerHeight int, statusHeight int, contentBudget int, messageBudget int) { + composerHeight = min(composerRows, max(budget, 0)) + transcriptBudget := max(budget-composerHeight, 0) + if transcriptBudget >= 4 { + statusHeight = 1 + } + contentBudget = max(transcriptBudget-statusHeight, 0) + chromeRows := 1 + if chat.Pending != "" { + chromeRows++ + } + messageBudget = max(contentBudget-chromeRows, 0) + return composerHeight, statusHeight, contentBudget, messageBudget +} + +func (m Model) agentTranscriptPageSize() int { + chat, ok := m.agentChat() + if !ok { + return 1 + } + limit := m.bodyHeight() + panelWidth := max(m.width-2, 1) + mainWidth := panelWidth + if agentDetailsVisible(panelWidth) && limit > 5 { + detailWidth := agentDetailsWidth(panelWidth) + mainWidth = max(panelWidth-detailWidth-agentColumnGap(), 40) + } + mainBudget := max(limit-1, 0) + composerRows := len(m.renderAgentComposer(chat, mainWidth)) + _, _, _, messageBudget := agentMainColumnBudgets(chat, composerRows, mainBudget) + return max(messageBudget, 1) +} + func (m Model) renderAgentBlankLine(width int) string { p := m.palette() return lipgloss.NewStyle().Background(p.AgentPanel()).Width(width).Render(strings.Repeat(" ", max(width, 1))) @@ -210,6 +237,12 @@ func (m Model) renderAgentTranscriptHeader(width int) string { return lipgloss.NewStyle().Background(p.AgentPanel()).Width(width).Render(fitStyled(label+rule, width)) } +func (m Model) renderAgentEarlierMessagesHidden(width int) string { + p := m.palette() + label := " earlier messages hidden " + return lipgloss.NewStyle().Foreground(p.Muted()).Background(p.AgentPanel()).Width(width).Align(lipgloss.Center).Render(fit(label, width)) +} + func (m Model) renderAgentTranscriptStatus(chat protocol.AgentChat, width int) string { p := m.palette() messageCount := len(m.agentTranscriptMessages()) @@ -518,7 +551,7 @@ func (m Model) renderAgentNotice(label string, text string, width int) string { // agentTranscriptMessages is the transcript rendering source: the resident 0x86 // store (#2654). func (m Model) agentTranscriptMessages() []protocol.AgentChatMessage { - if m.transcript != nil && len(m.transcript.messages) > 0 { + if m.transcript != nil && m.transcript.hasEpoch { return m.transcript.messages } return nil diff --git a/go/tui/internal/ui/agent_transcript.go b/go/tui/internal/ui/agent_transcript.go index a16567322..8536779fa 100644 --- a/go/tui/internal/ui/agent_transcript.go +++ b/go/tui/internal/ui/agent_transcript.go @@ -11,7 +11,10 @@ import ( // and the reading anchor. Styled rows are disposable renderer state and live in // agentTranscriptRenderer instead. type residentTranscript struct { - epoch uint32 + epoch uint32 + // hasEpoch is the received-a-frame flag. It stays true for a legitimate + // empty 0x86 transcript, so callers never confuse empty resident data with + // the pre-seed state that existed before this epoch's full replacement. hasEpoch bool messages []protocol.AgentChatMessage entries []transcriptEntry @@ -22,7 +25,7 @@ type residentTranscript struct { pinned bool anchor transcriptAnchor pendingScroll int - pinTransition int + pinTransition pinEdge animatedCount int } @@ -43,8 +46,10 @@ type transcriptAnchor struct { row int } +type pinEdge uint8 + const ( - pinNone = iota + pinNone pinEdge = iota pinScrolledAway pinReturned ) @@ -102,6 +107,10 @@ func (t *residentTranscript) apply(frame protocol.AgentTranscript) transcriptDro t.epoch = frame.Epoch t.hasEpoch = true if epochChanged { + // An epoch flip is an authoritative session/reset transition. Both + // BEAM epoch sources independently reset follow-bottom, so discard a + // stale local edge and re-pin without reporting a new intent. + t.pinTransition = pinNone t.pinToBottom() } else { t.reconcileAnchor(oldEntries) @@ -329,7 +338,7 @@ func (t *residentTranscript) rebuildSlotIndex() { func (t *residentTranscript) reconcileAnchor(oldEntries []transcriptEntry) { if len(t.entries) == 0 { - t.pinToBottom() + t.returnToBottom() return } if t.pinned || t.anchor.slot == 0 { @@ -374,6 +383,14 @@ func (t *residentTranscript) pinToBottom() { t.pendingScroll = 0 } +func (t *residentTranscript) returnToBottom() { + wasPinned := t.pinned + t.pinToBottom() + if !wasPinned { + t.recordPinTransition(pinReturned) + } +} + func indexEntryByID(entries []transcriptEntry, id uint32) int { if id == 0 { return -1 @@ -390,7 +407,13 @@ func (t *residentTranscript) scrollBy(rows int) { t.pendingScroll += rows } -func (t *residentTranscript) takePinTransition() int { +func (t *residentTranscript) discardPendingScroll() int { + rows := t.pendingScroll + t.pendingScroll = 0 + return rows +} + +func (t *residentTranscript) takePinTransition() pinEdge { transition := t.pinTransition t.pinTransition = pinNone return transition @@ -400,7 +423,7 @@ func (t *residentTranscript) hasAnimatedMessages() bool { return t != nil && t.animatedCount > 0 } -func (t *residentTranscript) recordPinTransition(transition int) { +func (t *residentTranscript) recordPinTransition(transition pinEdge) { if transition != pinNone { t.pinTransition = transition } diff --git a/go/tui/internal/ui/agent_transcript_render_test.go b/go/tui/internal/ui/agent_transcript_render_test.go index c80f1bca6..0f673d4a4 100644 --- a/go/tui/internal/ui/agent_transcript_render_test.go +++ b/go/tui/internal/ui/agent_transcript_render_test.go @@ -1,6 +1,7 @@ package ui import ( + "bytes" "fmt" "strings" "testing" @@ -92,6 +93,373 @@ func TestProductionKeyAndWheelScrollUpdateAnchorSameFrame(t *testing.T) { } } +func TestAgentTranscriptPinEdgesEmitExactlyOnceFromUpdate(t *testing.T) { + for _, test := range []struct { + name string + away tea.Msg + back tea.Msg + }{ + { + name: "keys", + away: tea.KeyPressMsg(tea.Key{Code: 'k', Text: "k"}), + back: tea.KeyPressMsg(tea.Key{Code: 'G', Text: "G"}), + }, + { + name: "wheel", + away: tea.MouseWheelMsg(tea.Mouse{X: 10, Y: 1, Button: tea.MouseWheelUp}), + back: tea.MouseWheelMsg(tea.Mouse{X: 10, Y: 1, Button: tea.MouseWheelDown}), + }, + } { + t.Run(test.name, func(t *testing.T) { + out := make(chan []byte, 32) + model := residentModel(t, 100) + model.out = out + if mouse, ok := test.away.(tea.MouseMsg); ok { + value := mouse.Mouse() + value.Y = model.layout.body.Y + test.away = tea.MouseWheelMsg(value) + value = test.back.(tea.MouseMsg).Mouse() + value.Y = model.layout.body.Y + test.back = tea.MouseWheelMsg(value) + } + + updated, _ := model.Update(test.away) + model = updated.(Model) + updated, _ = model.Update(test.away) + model = updated.(Model) + updated, _ = model.Update(test.back) + model = updated.(Model) + updated, _ = model.Update(test.back) + _ = updated.(Model) + + packets := drainOutboundPackets(out) + assertPacketCount(t, packets, protocol.EncodeGUIChatScrolledAwayFromBottom(), 1) + assertPacketCount(t, packets, protocol.EncodeGUIChatReturnedToBottom(), 1) + }) + } +} + +func assertPacketCount(t *testing.T, packets [][]byte, want []byte, count int) { + t.Helper() + got := 0 + for _, packet := range packets { + if bytes.Equal(packet, want) { + got++ + } + } + if got != count { + t.Fatalf("packet %v count = %d, want %d; outbound=%v", want, got, count, packets) + } +} + +func TestAgentTranscriptNavigationMapping(t *testing.T) { + page := 17 + tests := []struct { + name string + key tea.Key + want int + handled bool + }{ + {"j", tea.Key{Code: 'j'}, 1, true}, + {"k", tea.Key{Code: 'k'}, -1, true}, + {"ctrl-d", tea.Key{Code: 'd', Mod: tea.ModCtrl}, 8, true}, + {"ctrl-u", tea.Key{Code: 'u', Mod: tea.ModCtrl}, -8, true}, + {"G", tea.Key{Code: 'G'}, 1 << 20, true}, + {"shift-G", tea.Key{Code: 'G', Mod: tea.ModShift}, 1 << 20, true}, + {"page down", tea.Key{Code: tea.KeyPgDown}, page, true}, + {"page up", tea.Key{Code: tea.KeyPgUp}, -page, true}, + {"ctrl-j", tea.Key{Code: 'j', Mod: tea.ModCtrl}, 0, false}, + {"alt-k", tea.Key{Code: 'k', Mod: tea.ModAlt}, 0, false}, + {"shift-j", tea.Key{Code: 'j', Mod: tea.ModShift}, 0, false}, + {"ctrl-shift-d", tea.Key{Code: 'd', Mod: tea.ModCtrl | tea.ModShift}, 0, false}, + {"ctrl-alt-u", tea.Key{Code: 'u', Mod: tea.ModCtrl | tea.ModAlt}, 0, false}, + {"ctrl-G", tea.Key{Code: 'G', Mod: tea.ModCtrl}, 0, false}, + {"ctrl-page-up", tea.Key{Code: tea.KeyPgUp, Mod: tea.ModCtrl}, 0, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, handled := agentTranscriptScrollRows(tea.KeyPressMsg(test.key), page) + if got != test.want || handled != test.handled { + t.Fatalf("mapping = (%d, %v), want (%d, %v)", got, handled, test.want, test.handled) + } + }) + } +} + +func TestAgentTranscriptComposerFocusGatesLocalNavigation(t *testing.T) { + for _, test := range []struct { + name string + inputFocused bool + wantPinned bool + wantEdge int + }{ + {"composer focused", true, true, 0}, + {"transcript focused", false, false, 1}, + } { + t.Run(test.name, func(t *testing.T) { + out := make(chan []byte, 8) + model := residentModel(t, 100) + chat := visibleAgentChat() + chat.InputFocused = test.inputFocused + model.chrome[generated.OPGuiAgentChat] = protocol.ChromePayload{AgentChat: chat} + model.out = out + + updated, _ := model.Update(tea.KeyPressMsg(tea.Key{Code: 'k', Text: "k"})) + model = updated.(Model) + if model.transcript.pinned != test.wantPinned { + t.Fatalf("pinned = %v, want %v", model.transcript.pinned, test.wantPinned) + } + packets := drainOutboundPackets(out) + keyPackets := 0 + for _, packet := range packets { + if len(packet) > 0 && packet[0] == generated.OPKeyPress { + keyPackets++ + } + } + if keyPackets != 1 { + t.Fatalf("key packet count = %d, want 1; outbound=%v", keyPackets, packets) + } + assertPacketCount(t, packets, protocol.EncodeGUIChatScrolledAwayFromBottom(), test.wantEdge) + }) + } +} + +func TestFocusedComposerReceivesEveryTranscriptNavigationKey(t *testing.T) { + keys := []tea.Key{ + {Code: 'j', Text: "j"}, + {Code: 'k', Text: "k"}, + {Code: 'd', Mod: tea.ModCtrl}, + {Code: 'u', Mod: tea.ModCtrl}, + {Code: 'G', Text: "G"}, + {Code: tea.KeyPgUp}, + {Code: tea.KeyPgDown}, + } + for _, key := range keys { + t.Run(key.String(), func(t *testing.T) { + out := make(chan []byte, 8) + model := residentModel(t, 100) + chat := visibleAgentChat() + chat.InputFocused = true + model.chrome[generated.OPGuiAgentChat] = protocol.ChromePayload{AgentChat: chat} + model.out = out + + updated, _ := model.Update(tea.KeyPressMsg(key)) + model = updated.(Model) + if !model.transcript.pinned || model.transcript.anchor != (transcriptAnchor{}) { + t.Fatalf("focused composer navigation changed transcript: %+v", model.transcript) + } + packets := drainOutboundPackets(out) + keyPackets := 0 + for _, packet := range packets { + if len(packet) > 0 && packet[0] == generated.OPKeyPress { + keyPackets++ + } + } + if keyPackets != 1 { + t.Fatalf("key packet count = %d, want 1; outbound=%v", keyPackets, packets) + } + assertPacketCount(t, packets, protocol.EncodeGUIChatScrolledAwayFromBottom(), 0) + assertPacketCount(t, packets, protocol.EncodeGUIChatReturnedToBottom(), 0) + }) + } +} + +func TestAgentTranscriptPageKeysUseContentBudget(t *testing.T) { + model := residentModel(t, 100) + chat, _ := model.agentChat() + panelWidth := max(model.width-2, 1) + mainBudget := model.bodyHeight() - 1 + composerRows := len(model.renderAgentComposer(chat, panelWidth)) + expected := mainBudget - composerRows - 1 - 1 + page := model.agentTranscriptPageSize() + if page != expected { + t.Fatalf("page size = %d, want transcript content budget %d", page, expected) + } + if page >= model.layout.body.Height { + t.Fatalf("page size %d should exclude body chrome from height %d", page, model.layout.body.Height) + } + if got, handled := agentTranscriptScrollRows(tea.KeyPressMsg(tea.Key{Code: tea.KeyPgDown}), page); !handled || got != expected { + t.Fatalf("page-down mapping = (%d, %v), want (%d, true)", got, handled, expected) + } +} + +func TestAgentTranscriptTruncationAffordanceOnlyAtResidentTop(t *testing.T) { + model := residentModel(t, 20) + model.transcript.truncated = true + model.transcript.pinned = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[0].slot} + width := 50 + budget := 8 + + rows := model.transcriptRenderer.render(model, model.transcript, budget, width) + if got := ansi.Strip(strings.Join(rows, "\n")); !strings.Contains(got, "earlier messages hidden") { + t.Fatalf("top of truncated transcript lacks affordance: %q", got) + } + if len(rows) != budget { + t.Fatalf("affordance should stay inside content budget: got %d rows, want %d", len(rows), budget) + } + + model.transcript.scrollBy(1) + rows = model.transcriptRenderer.render(model, model.transcript, budget, width) + if got := ansi.Strip(strings.Join(rows, "\n")); strings.Contains(got, "earlier messages hidden") { + t.Fatalf("affordance should disappear below resident top: %q", got) + } + + model.transcript.truncated = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[0].slot} + rows = model.transcriptRenderer.render(model, model.transcript, budget, width) + if got := ansi.Strip(strings.Join(rows, "\n")); strings.Contains(got, "earlier messages hidden") { + t.Fatalf("complete transcript should not show truncation affordance: %q", got) + } +} + +func TestTruncationAffordanceIsReachableWhenResidentRowsExactlyFillBudget(t *testing.T) { + model := residentModel(t, 0) + model.transcript.apply(protocol.AgentTranscript{ + Present: true, + Mode: 0, + Epoch: 1, + Truncated: true, + Messages: []protocol.AgentChatMessage{{ID: 1, Kind: agentKindSystem, Text: "retained"}}, + }) + width := 50 + budget := 1 + + rows := model.transcriptRenderer.render(model, model.transcript, budget, width) + if got := ansi.Strip(strings.Join(rows, "\n")); !strings.Contains(got, "retained") { + t.Fatalf("pinned view should keep the retained bottom row: %q", got) + } + + model.transcript.scrollBy(-1) + rows = model.transcriptRenderer.render(model, model.transcript, budget, width) + if got := ansi.Strip(strings.Join(rows, "\n")); !strings.Contains(got, "earlier messages hidden") { + t.Fatalf("scrolling to resident top should reveal truncation affordance: %q", got) + } + if model.transcript.pinned { + t.Fatal("truncation affordance should act as a scrollable row above an exact-fit transcript") + } +} + +func TestReplacementPinIntentReportingThroughUpdate(t *testing.T) { + tests := []struct { + name string + prepare func(*Model) + frame func(Model) protocol.AgentTranscript + wantReturned int + wantPinned bool + wantAnchorStable bool + }{ + { + name: "all resident rows fit after shrink", + prepare: func(model *Model) { + model.transcript.pinned = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[0].slot} + }, + frame: func(Model) protocol.AgentTranscript { + return replaceFrame(1, msg(100, "only")) + }, + wantReturned: 1, + wantPinned: true, + }, + { + name: "resident rows exactly fill viewport after shrink", + prepare: func(model *Model) { + model.transcript.pinned = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[0].slot} + }, + frame: func(model Model) protocol.AgentTranscript { + budget := model.agentTranscriptPageSize() + if budget < 3 { + t.Fatalf("transcript budget = %d, need at least 3 rows for exact-fit fixture", budget) + } + lines := make([]protocol.AgentStyledLine, budget-3) + for index := range lines { + lines[index] = protocol.AgentStyledLine{{Text: fmt.Sprintf("row %d", index)}} + } + return replaceFrame(1, protocol.AgentChatMessage{ + ID: 100, + Kind: agentKindAssistantMarkdown, + MarkdownBlocks: []protocol.AgentMarkdownBlock{{ + Kind: 0x07, + Label: "Exact fit", + Flags: 1, + Lines: lines, + }}, + }) + }, + wantReturned: 1, + wantPinned: true, + }, + { + name: "removed near-tail anchor clamps to bottom", + prepare: func(model *Model) { + model.transcript.pinned = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[90].slot} + }, + frame: func(model Model) protocol.AgentTranscript { + messages := append([]protocol.AgentChatMessage(nil), model.transcript.messages[:80]...) + return replaceFrame(1, messages...) + }, + wantReturned: 1, + wantPinned: true, + }, + { + name: "same-epoch replacement retains stable anchor", + prepare: func(model *Model) { + model.transcript.pinned = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[40].slot, row: 1} + }, + frame: func(model Model) protocol.AgentTranscript { + messages := append([]protocol.AgentChatMessage(nil), model.transcript.messages...) + messages[len(messages)-1].Text = "streamed tail revision" + return replaceFrame(1, messages...) + }, + wantPinned: false, + wantAnchorStable: true, + }, + { + name: "epoch flip re-pins without local intent", + prepare: func(model *Model) { + model.transcript.pinned = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[40].slot, row: 1} + model.transcript.pinTransition = pinScrolledAway + }, + frame: func(Model) protocol.AgentTranscript { + return replaceFrame(2, msg(1, "fresh session")) + }, + wantPinned: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out := make(chan []byte, 16) + model := residentModel(t, 100) + model.out = out + model.lastCommittedSeq = 1 + test.prepare(&model) + anchor := model.transcript.anchor + frame := test.frame(model) + + updated, _ := model.Update(port.PacketMsg{Commands: []protocol.Command{ + beginFrame(2, 1), + transcriptCommand(frame), + commitFrame(2), + }}) + model = updated.(Model) + if model.transcript.pinned != test.wantPinned { + t.Fatalf("pinned = %v, want %v", model.transcript.pinned, test.wantPinned) + } + if test.wantAnchorStable && model.transcript.anchor != anchor { + t.Fatalf("stable replacement moved anchor: before=%+v after=%+v", anchor, model.transcript.anchor) + } + packets := drainOutboundPackets(out) + assertPacketCount(t, packets, []byte{generated.OPGuiAction, 0x5D}, test.wantReturned) + assertPacketCount(t, packets, protocol.EncodeGUIChatScrolledAwayFromBottom(), 0) + }) + } +} + func TestAgentToggleUsesStableMessageID(t *testing.T) { var panel agentPanel chat := protocol.AgentChat{Visible: true} @@ -323,22 +691,37 @@ func TestTranscriptCacheEvictsAsViewportMoves(t *testing.T) { } } -func TestContentShrinkClampsAnchorAndPinsOnlyWhenEverythingFits(t *testing.T) { - model := residentModel(t, 10) - width := 50 - budget := 5 - model.transcript.pinned = false - model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[9].slot} +func TestContentShrinkClampsAnchorAndReportsReturn(t *testing.T) { + t.Run("clamp to bottom", func(t *testing.T) { + model := residentModel(t, 10) + width := 50 + budget := 5 + model.transcript.pinned = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[9].slot} - got := model.transcriptRenderer.render(model, model.transcript, budget, width) - want := windowBottom(model.agentTranscriptAllLines(model.transcript.messages, width), budget) - if model.transcript.pinned || strings.Join(got, "\n") != strings.Join(want, "\n") { - t.Fatalf("underfilled anchor did not clamp to unpinned tail: pinned=%v", model.transcript.pinned) - } + got := model.transcriptRenderer.render(model, model.transcript, budget, width) + want := windowBottom(model.agentTranscriptAllLines(model.transcript.messages, width), budget) + if !model.transcript.pinned || strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("underfilled anchor did not clamp and re-pin: pinned=%v", model.transcript.pinned) + } + if model.transcript.takePinTransition() != pinReturned { + t.Fatal("clamp-to-bottom did not report pinReturned") + } + }) - model.transcript.apply(replaceFrame(1, msg(10, "only"))) - model.transcriptRenderer.render(model, model.transcript, budget, width) - if !model.transcript.pinned || model.transcript.anchor != (transcriptAnchor{}) { - t.Fatalf("all-fitting replacement did not pin: %+v", model.transcript) - } + t.Run("everything fits", func(t *testing.T) { + model := residentModel(t, 10) + width := 50 + budget := 5 + model.transcript.pinned = false + model.transcript.anchor = transcriptAnchor{slot: model.transcript.entries[0].slot} + model.transcript.apply(replaceFrame(1, msg(10, "only"))) + model.transcriptRenderer.render(model, model.transcript, budget, width) + if !model.transcript.pinned || model.transcript.anchor != (transcriptAnchor{}) { + t.Fatalf("all-fitting replacement did not pin: %+v", model.transcript) + } + if model.transcript.takePinTransition() != pinReturned { + t.Fatal("all-fitting replacement did not report pinReturned") + } + }) } diff --git a/go/tui/internal/ui/agent_transcript_renderer.go b/go/tui/internal/ui/agent_transcript_renderer.go index 2a1247ee1..9fea132e5 100644 --- a/go/tui/internal/ui/agent_transcript_renderer.go +++ b/go/tui/internal/ui/agent_transcript_renderer.go @@ -109,16 +109,16 @@ func (r *agentTranscriptRenderer) render(m Model, transcript *residentTranscript defer r.finish() if transcript == nil || budget <= 0 || len(transcript.entries) == 0 { if transcript != nil { - transcript.pendingScroll = 0 + transcript.discardPendingScroll() } return nil } - rows := transcript.pendingScroll - transcript.pendingScroll = 0 + rows := transcript.discardPendingScroll() if transcript.pinned { - tail, allFits := r.tailStart(m, transcript, budget, width) + tail, _ := r.tailStart(m, transcript, budget, width) + allFits := r.allFits(m, transcript, budget, width) if rows >= 0 || allFits { return r.renderPinned(m, transcript, budget, width) } @@ -156,6 +156,11 @@ func (r *agentTranscriptRenderer) render(m Model, transcript *residentTranscript } } + if r.allFits(m, transcript, budget, width) { + transcript.returnToBottom() + return r.renderPinned(m, transcript, budget, width) + } + transcript.anchor = r.anchorFor(transcript, position) lines := r.renderFrom(m, transcript, position, budget, width) if len(lines) == budget { @@ -163,29 +168,37 @@ func (r *agentTranscriptRenderer) render(m Model, transcript *residentTranscript } // A trim or replacement can leave a retained anchor too close to the end to - // fill the viewport. Resolve the bounded tail and clamp there without - // scanning from the transcript start. Only an all-fitting transcript silently - // returns to pinned; otherwise the reader remains explicitly scrolled away. - tail, allFits := r.tailStart(m, transcript, budget, width) - if allFits { - transcript.pinToBottom() - return r.renderPinned(m, transcript, budget, width) - } - transcript.anchor = r.anchorFor(transcript, tail) - return r.renderFrom(m, transcript, tail, budget, width) + // fill the viewport. Reaching the bounded tail means the reader is at the + // bottom again, so re-pin and report the edge instead of leaving BEAM follow + // state disengaged after a content shrink. + transcript.returnToBottom() + return r.renderPinned(m, transcript, budget, width) } func (r *agentTranscriptRenderer) renderPinned(m Model, transcript *residentTranscript, budget int, width int) []string { overscan := min(budget, transcriptOverscanLimit) start := r.moveBackward(m, transcript, r.endPosition(m, transcript, width), budget+overscan, width) lines := r.renderForward(m, transcript, start, budget+overscan, width) - return windowBottom(lines, budget) + lines = windowBottom(lines, budget) + if transcript.truncated && len(lines) < budget && start.index == 0 && start.row == 0 { + return append([]string{m.renderAgentEarlierMessagesHidden(width)}, lines...) + } + return lines } func (r *agentTranscriptRenderer) renderFrom(m Model, transcript *residentTranscript, position transcriptPosition, budget int, width int) []string { - overscan := min(budget, transcriptOverscanLimit) - lines := r.renderForward(m, transcript, position, budget+overscan, width) - return takeLines(lines, budget) + showTruncated := transcript.truncated && position.index == 0 && position.row == 0 + rowBudget := budget + if showTruncated { + rowBudget-- + } + overscan := min(max(rowBudget, 0), transcriptOverscanLimit) + lines := r.renderForward(m, transcript, position, max(rowBudget, 0)+overscan, width) + lines = takeLines(lines, max(rowBudget, 0)) + if showTruncated { + return append([]string{m.renderAgentEarlierMessagesHidden(width)}, lines...) + } + return lines } func (r *agentTranscriptRenderer) tailStart(m Model, transcript *residentTranscript, budget int, width int) (transcriptPosition, bool) { @@ -193,6 +206,18 @@ func (r *agentTranscriptRenderer) tailStart(m Model, transcript *residentTranscr return start, start.index == 0 && start.row == 0 } +func (r *agentTranscriptRenderer) allFits(m Model, transcript *residentTranscript, budget int, width int) bool { + effectiveBudget := budget + if transcript.truncated { + effectiveBudget-- + } + if effectiveBudget <= 0 || len(transcript.entries)*2-1 > effectiveBudget { + return false + } + _, allFits := r.tailStart(m, transcript, effectiveBudget, width) + return allFits +} + func (r *agentTranscriptRenderer) endPosition(m Model, transcript *residentTranscript, width int) transcriptPosition { last := len(transcript.entries) - 1 height := r.messageHeight(m, &transcript.entries[last], width) diff --git a/go/tui/internal/ui/agent_transcript_test.go b/go/tui/internal/ui/agent_transcript_test.go index 6eb9b7f09..4424d20e0 100644 --- a/go/tui/internal/ui/agent_transcript_test.go +++ b/go/tui/internal/ui/agent_transcript_test.go @@ -144,6 +144,7 @@ func TestResidentTranscriptEpochFlipReplaces(t *testing.T) { tr.apply(replaceFrame(1, msg(1, "a"), msg(2, "b"))) tr.pinned = false tr.anchor = transcriptAnchor{slot: tr.entries[0].slot, row: 1} + tr.pinTransition = pinScrolledAway tr.apply(replaceFrame(2, msg(9, "fresh"))) if got, want := ids(tr.messages), []uint32{9}; fmt.Sprint(got) != fmt.Sprint(want) { @@ -155,6 +156,9 @@ func TestResidentTranscriptEpochFlipReplaces(t *testing.T) { if !tr.pinned || tr.anchor != (transcriptAnchor{}) { t.Fatalf("epoch flip (session switch) should re-pin to bottom: %+v", tr) } + if tr.pinTransition != pinNone { + t.Fatalf("epoch flip should clear the local pin latch without reporting intent: %d", tr.pinTransition) + } } func TestResidentTranscriptAppendDesyncDropped(t *testing.T) { @@ -323,6 +327,9 @@ func TestResidentTranscriptEmptyReplacementPins(t *testing.T) { if !tr.pinned || tr.anchor != (transcriptAnchor{}) { t.Fatalf("empty transcript did not pin: %+v", tr) } + if tr.takePinTransition() != pinReturned { + t.Fatal("same-epoch empty replacement did not report its automatic return to bottom") + } } func lineSeq(n int) []string { diff --git a/go/tui/internal/ui/conformance_transcript_test.go b/go/tui/internal/ui/conformance_transcript_test.go index 682dfb128..1fa2d5469 100644 --- a/go/tui/internal/ui/conformance_transcript_test.go +++ b/go/tui/internal/ui/conformance_transcript_test.go @@ -589,7 +589,7 @@ func conformanceTranscriptTopOffset(store *residentTranscript) int { return offset } -func assertConformanceGoSelector(t *testing.T, i int, step conformanceStep, store *residentTranscript, transition int) { +func assertConformanceGoSelector(t *testing.T, i int, step conformanceStep, store *residentTranscript, transition pinEdge) { t.Helper() sel := step.Go if sel == nil { @@ -611,7 +611,7 @@ func assertConformanceGoSelector(t *testing.T, i int, step conformanceStep, stor } } -func pinTransitionFromName(name string) int { +func pinTransitionFromName(name string) pinEdge { switch name { case "scrolled_away": return pinScrolledAway diff --git a/go/tui/internal/ui/input.go b/go/tui/internal/ui/input.go index 2b3dbd4b6..7591f9249 100644 --- a/go/tui/internal/ui/input.go +++ b/go/tui/internal/ui/input.go @@ -41,6 +41,10 @@ func keyPacket(msg tea.KeyPressMsg, seq uint32) ([]byte, bool) { return protocol.EncodeKeyPress(arrowLeft, keyModifiers(key), seq), true case tea.KeyRight: return protocol.EncodeKeyPress(arrowRight, keyModifiers(key), seq), true + case tea.KeyPgUp: + return protocol.EncodeKeyPress(pageUp, keyModifiers(key), seq), true + case tea.KeyPgDown: + return protocol.EncodeKeyPress(pageDown, keyModifiers(key), seq), true case tea.KeySpace: return protocol.EncodeKeyPress(' ', keyModifiers(key), seq), true } diff --git a/go/tui/internal/ui/input_test.go b/go/tui/internal/ui/input_test.go index f9e46a71f..6f168ed01 100644 --- a/go/tui/internal/ui/input_test.go +++ b/go/tui/internal/ui/input_test.go @@ -45,6 +45,24 @@ func TestKeyPacketEncodesSpace(t *testing.T) { } } +func TestKeyPacketPageNavigation(t *testing.T) { + for _, test := range []struct { + name string + code rune + want rune + }{ + {"page up", tea.KeyPgUp, pageUp}, + {"page down", tea.KeyPgDown, pageDown}, + } { + t.Run(test.name, func(t *testing.T) { + packet, ok := keyPacket(tea.KeyPressMsg(tea.Key{Code: test.code}), 0) + if !ok || codepoint(packet) != test.want { + t.Fatalf("key packet = %v, want codepoint %d", packet, test.want) + } + }) + } +} + func TestKeyPacketEncodesPrintableUppercaseWithoutShiftModifier(t *testing.T) { for _, key := range []tea.Key{ {Code: 'T', Text: "T"}, diff --git a/go/tui/internal/ui/model.go b/go/tui/internal/ui/model.go index 35621d998..530d0597a 100644 --- a/go/tui/internal/ui/model.go +++ b/go/tui/internal/ui/model.go @@ -22,6 +22,8 @@ const ( arrowRight rune = 57351 arrowUp rune = 57352 arrowDown rune = 57353 + pageUp rune = 57362 + pageDown rune = 57363 ) type Model struct { @@ -388,11 +390,21 @@ func (m *Model) queueAgentTranscriptScroll(msg tea.KeyPressMsg) { if !m.agentTranscriptScrollTarget() { return } - if rows, ok := agentTranscriptScrollRows(msg, m.layout.body.Height); ok { + page := 1 + if agentTranscriptUsesPageSize(msg) { + page = m.agentTranscriptPageSize() + } + if rows, ok := agentTranscriptScrollRows(msg, page); ok { m.transcript.scrollBy(rows) } } +func agentTranscriptUsesPageSize(msg tea.KeyPressMsg) bool { + key := msg.Key() + return (key.Mod == tea.ModCtrl && (key.Code == 'd' || key.Code == 'u')) || + (key.Mod == 0 && (key.Code == tea.KeyPgDown || key.Code == tea.KeyPgUp)) +} + // queueAgentWheelScroll queues a local transcript scroll for a wheel event over // the agent chat body (#2654). Unlike keys this does not need the composer-focus // gate: a wheel over the transcript always scrolls it. @@ -419,34 +431,36 @@ func agentTranscriptScrollRows(msg tea.KeyPressMsg, page int) (int, bool) { page = max(page, 1) half := max(page/2, 1) key := msg.Key() - ctrl := key.Mod.Contains(tea.ModCtrl) - alt := key.Mod.Contains(tea.ModAlt) switch key.Code { case 'j': - if !ctrl && !alt { + if key.Mod == 0 { return 1, true } case 'k': - if !ctrl && !alt { + if key.Mod == 0 { return -1, true } case 'd': - if ctrl && !alt { + if key.Mod == tea.ModCtrl { return half, true } case 'u': - if ctrl && !alt { + if key.Mod == tea.ModCtrl { return -half, true } case 'G': - if !ctrl && !alt { + if key.Mod == 0 || key.Mod == tea.ModShift { // Jump to bottom: a large downward amount the render clamps and re-pins. return 1 << 20, true } case tea.KeyPgDown: - return page, true + if key.Mod == 0 { + return page, true + } case tea.KeyPgUp: - return -page, true + if key.Mod == 0 { + return -page, true + } } return 0, false }