diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 383546fe..32ebd026 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -227,9 +227,14 @@ func (m Model) eventInspectorModal() string { evs := m.currentEvents() var b strings.Builder - b.WriteString(m.titleLine("event inspector") + "\n\n") + b.WriteString(m.titleLine("event inspector")) + if len(evs) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint)) + } + b.WriteString("\n\n") if len(evs) == 0 { b.WriteString(mbg(t, "no events", t.P.Faint)) + return m.center(t.Overlay.Width(w).Render(b.String())) } // Budget the plain detail so the styled row never needs ANSI-aware truncation // (truncating a styled string would cut through escape codes and corrupt it). @@ -237,7 +242,28 @@ func (m Model) eventInspectorModal() string { if detailBudget < 4 { detailBudget = 4 } - for i, e := range evs { + // Window the (potentially long) list to a visible height, keeping the + // selected row in view — the modal must not grow past the screen. + bodyH := m.height*70/100 - 4 + if bodyH < 3 { + bodyH = 3 + } + off := 0 + if len(evs) > bodyH { + off = m.overlayEventIdx - bodyH/2 + if off < 0 { + off = 0 + } + if off > len(evs)-bodyH { + off = len(evs) - bodyH + } + } + end := off + bodyH + if end > len(evs) { + end = len(evs) + } + for i := off; i < end; i++ { + e := evs[i] cat := inferCategory(e.Type) marker := mbg(t, " ", t.P.BgPanel) fg := t.P.Fg diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index df8b9854..d2651687 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -177,6 +177,16 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { } case protocol.TypeApprovalRequired: m.onApprovalRequired(msg) + case protocol.TypeApprovalResolved: + if s := m.session(msg.SessionID); s != nil { + s.Pending = nil + detail := msg.Outcome + if msg.Reason != "" { + detail += " — " + msg.Reason + } + s.addEvent(msg.OccurredAt, "ApprovalResolved", detail) + s.LastEventAt = nowMillis() + } case protocol.TypeSessionSnapshot: m.onSnapshot(msg) case protocol.TypeStageManifest: @@ -335,10 +345,15 @@ func (m *Model) touch(id, status string) { // --- Session helpers --- +// maxSessionEvents bounds retained per-session history (effectively "all" for a +// session, while keeping memory bounded on very long runs). The EVENTS panel +// shows the tail that fits; the `e` inspector scrolls the full retained list. +const maxSessionEvents = 1000 + func (s *Session) addEvent(epochMillis int64, typ, detail string) { s.Events = append(s.Events, EventEntry{formatTime(epochMillis), typ, detail}) - if len(s.Events) > 7 { - s.Events = s.Events[len(s.Events)-7:] + if len(s.Events) > maxSessionEvents { + s.Events = s.Events[len(s.Events)-maxSessionEvents:] } s.LastEventAt = epochMillis } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 2c73be15..a16a2c95 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -69,7 +69,10 @@ func (m Model) renderStatus() string { if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { parts = append(parts, span(s.Name, t.P.FgStrong)) if s.CurrentStage != "" { - parts = append(parts, span(s.CurrentStage, t.P.Accent2)) + parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2)) + } + if s.Status != "" { + parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) } } @@ -92,7 +95,7 @@ func (m Model) renderStatus() string { right = append(right, lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render(m.spinner())+span(" active", t.P.Accent2)) } if m.bgUpdates > 0 { - right = append(right, span(itoa(m.bgUpdates)+" bg", t.P.Warn)) + right = append(right, span(itoa(m.bgUpdates)+" elsewhere", t.P.Warn)) } if len(right) == 0 { return m.fill(left, m.width, bg) @@ -185,7 +188,7 @@ func (m Model) renderMain(h int) string { leftBody = m.routerRows(leftW-4, h-2) leftActive = true rightTitle = "events" - rightBody = m.eventRows(rightW - 4) + rightBody = m.eventRows(rightW-4, h) } left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive) @@ -356,7 +359,7 @@ func (m Model) routerRows(w, h int) []string { return rows } -func (m Model) eventRows(w int) []string { +func (m Model) eventRows(w, h int) []string { t := m.theme header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") + t.span(" ", t.P.Bg) @@ -366,21 +369,24 @@ func (m Model) eventRows(w int) []string { header += t.span("○ idle", t.P.Faint) } - rows := []string{header, ""} s := m.session(m.selectedID) if s == nil || len(s.Events) == 0 { - rows = append(rows, t.span("no events yet", t.P.Faint)) - return rows + return []string{header, "", t.span("no events yet", t.P.Faint)} } + evRows := make([]string, 0, len(s.Events)) for _, e := range s.Events { cat := inferCategory(e.Type) catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel). Render(" " + padRaw(cat, 9) + " ") - rows = append(rows, + evRows = append(evRows, t.span(e.Time+" ", t.P.Faint)+catCell+" "+ t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim)) } - return rows + // Show the latest events that fit under the 2-line header (box inner = h-2). + if avail := h - 4; avail >= 1 && len(evRows) > avail { + evRows = evRows[len(evRows)-avail:] + } + return append([]string{header, ""}, evRows...) } // --- width helpers --- diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 1887d220..00fa63b4 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -31,6 +31,7 @@ const ( TypeToolFailed = "tool.failed" TypeToolRejected = "tool.rejected" TypeApprovalRequired = "approval.required" + TypeApprovalResolved = "approval.resolved" TypeStageManifest = "stage.tool_manifest" TypeProviderStatus = "provider.status_changed" TypeProtocolError = "protocol_error" @@ -69,6 +70,7 @@ type ServerMessage struct { ProviderID string `json:"providerId"` Preview *string `json:"preview"` Disposition string `json:"disposition"` + Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED SteeringEmitted bool `json:"steeringEmitted"` OccurredAt int64 `json:"occurredAt"` @@ -178,7 +180,7 @@ func (m ServerMessage) IsEventBearing() bool { TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout, TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected, TypeToolAssessed, - TypeApprovalRequired, TypeArtifactCreated: + TypeApprovalRequired, TypeApprovalResolved, TypeArtifactCreated: return true default: return false