feat(tui): full event history, approval-decision events, status bar
- Events were hard-capped to the last 7 per session in addEvent, so the e-inspector and EVENTS panel could never show more. Raise to 1000 (effectively all, bounded); the EVENTS panel now shows the latest that fit (tail), and the inspector scrolls a window around the selection with a position indicator. - The TUI had no approval.resolved handling at all — the decision frame was ignored and pending only cleared on session.resumed. Add the constant + case: clear the gate and record an ApprovalResolved event (APPROVED/REJECTED/AUTO_APPROVED + reason) into the stream. - Status bar now shows the current stage and session status alongside the name. Relabel the cryptic "N bg" badge to "N elsewhere".
This commit is contained in:
@@ -227,9 +227,14 @@ func (m Model) eventInspectorModal() string {
|
|||||||
evs := m.currentEvents()
|
evs := m.currentEvents()
|
||||||
|
|
||||||
var b strings.Builder
|
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 {
|
if len(evs) == 0 {
|
||||||
b.WriteString(mbg(t, "no events", t.P.Faint))
|
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
|
// 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).
|
// (truncating a styled string would cut through escape codes and corrupt it).
|
||||||
@@ -237,7 +242,28 @@ func (m Model) eventInspectorModal() string {
|
|||||||
if detailBudget < 4 {
|
if detailBudget < 4 {
|
||||||
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)
|
cat := inferCategory(e.Type)
|
||||||
marker := mbg(t, " ", t.P.BgPanel)
|
marker := mbg(t, " ", t.P.BgPanel)
|
||||||
fg := t.P.Fg
|
fg := t.P.Fg
|
||||||
|
|||||||
@@ -177,6 +177,16 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
}
|
}
|
||||||
case protocol.TypeApprovalRequired:
|
case protocol.TypeApprovalRequired:
|
||||||
m.onApprovalRequired(msg)
|
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:
|
case protocol.TypeSessionSnapshot:
|
||||||
m.onSnapshot(msg)
|
m.onSnapshot(msg)
|
||||||
case protocol.TypeStageManifest:
|
case protocol.TypeStageManifest:
|
||||||
@@ -335,10 +345,15 @@ func (m *Model) touch(id, status string) {
|
|||||||
|
|
||||||
// --- Session helpers ---
|
// --- 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) {
|
func (s *Session) addEvent(epochMillis int64, typ, detail string) {
|
||||||
s.Events = append(s.Events, EventEntry{formatTime(epochMillis), typ, detail})
|
s.Events = append(s.Events, EventEntry{formatTime(epochMillis), typ, detail})
|
||||||
if len(s.Events) > 7 {
|
if len(s.Events) > maxSessionEvents {
|
||||||
s.Events = s.Events[len(s.Events)-7:]
|
s.Events = s.Events[len(s.Events)-maxSessionEvents:]
|
||||||
}
|
}
|
||||||
s.LastEventAt = epochMillis
|
s.LastEventAt = epochMillis
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ func (m Model) renderStatus() string {
|
|||||||
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
|
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
|
||||||
parts = append(parts, span(s.Name, t.P.FgStrong))
|
parts = append(parts, span(s.Name, t.P.FgStrong))
|
||||||
if s.CurrentStage != "" {
|
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))
|
right = append(right, lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render(m.spinner())+span(" active", t.P.Accent2))
|
||||||
}
|
}
|
||||||
if m.bgUpdates > 0 {
|
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 {
|
if len(right) == 0 {
|
||||||
return m.fill(left, m.width, bg)
|
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)
|
leftBody = m.routerRows(leftW-4, h-2)
|
||||||
leftActive = true
|
leftActive = true
|
||||||
rightTitle = "events"
|
rightTitle = "events"
|
||||||
rightBody = m.eventRows(rightW - 4)
|
rightBody = m.eventRows(rightW-4, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive)
|
left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive)
|
||||||
@@ -356,7 +359,7 @@ func (m Model) routerRows(w, h int) []string {
|
|||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) eventRows(w int) []string {
|
func (m Model) eventRows(w, h int) []string {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
|
header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") +
|
||||||
t.span(" ", t.P.Bg)
|
t.span(" ", t.P.Bg)
|
||||||
@@ -366,21 +369,24 @@ func (m Model) eventRows(w int) []string {
|
|||||||
header += t.span("○ idle", t.P.Faint)
|
header += t.span("○ idle", t.P.Faint)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows := []string{header, ""}
|
|
||||||
s := m.session(m.selectedID)
|
s := m.session(m.selectedID)
|
||||||
if s == nil || len(s.Events) == 0 {
|
if s == nil || len(s.Events) == 0 {
|
||||||
rows = append(rows, t.span("no events yet", t.P.Faint))
|
return []string{header, "", t.span("no events yet", t.P.Faint)}
|
||||||
return rows
|
|
||||||
}
|
}
|
||||||
|
evRows := make([]string, 0, len(s.Events))
|
||||||
for _, e := range s.Events {
|
for _, e := range s.Events {
|
||||||
cat := inferCategory(e.Type)
|
cat := inferCategory(e.Type)
|
||||||
catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).
|
catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).
|
||||||
Render(" " + padRaw(cat, 9) + " ")
|
Render(" " + padRaw(cat, 9) + " ")
|
||||||
rows = append(rows,
|
evRows = append(evRows,
|
||||||
t.span(e.Time+" ", t.P.Faint)+catCell+" "+
|
t.span(e.Time+" ", t.P.Faint)+catCell+" "+
|
||||||
t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim))
|
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 ---
|
// --- width helpers ---
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
TypeToolFailed = "tool.failed"
|
TypeToolFailed = "tool.failed"
|
||||||
TypeToolRejected = "tool.rejected"
|
TypeToolRejected = "tool.rejected"
|
||||||
TypeApprovalRequired = "approval.required"
|
TypeApprovalRequired = "approval.required"
|
||||||
|
TypeApprovalResolved = "approval.resolved"
|
||||||
TypeStageManifest = "stage.tool_manifest"
|
TypeStageManifest = "stage.tool_manifest"
|
||||||
TypeProviderStatus = "provider.status_changed"
|
TypeProviderStatus = "provider.status_changed"
|
||||||
TypeProtocolError = "protocol_error"
|
TypeProtocolError = "protocol_error"
|
||||||
@@ -69,6 +70,7 @@ type ServerMessage struct {
|
|||||||
ProviderID string `json:"providerId"`
|
ProviderID string `json:"providerId"`
|
||||||
Preview *string `json:"preview"`
|
Preview *string `json:"preview"`
|
||||||
Disposition string `json:"disposition"`
|
Disposition string `json:"disposition"`
|
||||||
|
Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED
|
||||||
|
|
||||||
SteeringEmitted bool `json:"steeringEmitted"`
|
SteeringEmitted bool `json:"steeringEmitted"`
|
||||||
OccurredAt int64 `json:"occurredAt"`
|
OccurredAt int64 `json:"occurredAt"`
|
||||||
@@ -178,7 +180,7 @@ func (m ServerMessage) IsEventBearing() bool {
|
|||||||
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
|
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
|
||||||
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
|
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
|
||||||
TypeToolAssessed,
|
TypeToolAssessed,
|
||||||
TypeApprovalRequired, TypeArtifactCreated:
|
TypeApprovalRequired, TypeApprovalResolved, TypeArtifactCreated:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user