Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions go/tui/internal/protocol/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")...)
Expand Down
18 changes: 18 additions & 0 deletions go/tui/internal/protocol/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
51 changes: 42 additions & 9 deletions go/tui/internal/ui/agent_chat_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)))
Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand Down
35 changes: 29 additions & 6 deletions go/tui/internal/ui/agent_transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +25,7 @@ type residentTranscript struct {
pinned bool
anchor transcriptAnchor
pendingScroll int
pinTransition int
pinTransition pinEdge
animatedCount int
}

Expand All @@ -43,8 +46,10 @@ type transcriptAnchor struct {
row int
}

type pinEdge uint8

const (
pinNone = iota
pinNone pinEdge = iota
pinScrolledAway
pinReturned
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand Down
Loading
Loading