feat(tui-go): session list / resume view (E)
R opens a navigable overlay of recent sessions (GET /sessions: short id, status,
workflow/stage, relative age); enter resumes via POST /sessions/{id}/resume, which
replays onto the existing global /stream (no WS re-dial). Fetch/resume errors surface
in the overlay, never panic. stdlib-only (ws.Client.HTTPBase derives the base URL).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ const (
|
|||||||
OverlayConfig
|
OverlayConfig
|
||||||
OverlayStats
|
OverlayStats
|
||||||
OverlayIdeas
|
OverlayIdeas
|
||||||
|
OverlaySessions
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// RouterEntry is one line in a session's conversation transcript.
|
||||||
@@ -259,6 +260,14 @@ type Model struct {
|
|||||||
ideasIndex int
|
ideasIndex int
|
||||||
ideasLoading bool
|
ideasLoading bool
|
||||||
|
|
||||||
|
// session browser (OverlaySessions) — recent sessions fetched over HTTP from
|
||||||
|
// GET /sessions, so prior runs are reachable after a server restart + TUI reopen
|
||||||
|
// (the WS stream only carries live sessions, not the historical roster).
|
||||||
|
sessionList []SessionSummary
|
||||||
|
sessionListIndex int
|
||||||
|
sessionListLoading bool
|
||||||
|
sessionListErr string
|
||||||
|
|
||||||
// command palette
|
// command palette
|
||||||
paletteFilter string
|
paletteFilter string
|
||||||
paletteIndex int
|
paletteIndex int
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.statsModal())
|
return m.center(m.statsModal())
|
||||||
case OverlayIdeas:
|
case OverlayIdeas:
|
||||||
return m.center(m.ideasModal())
|
return m.center(m.ideasModal())
|
||||||
|
case OverlaySessions:
|
||||||
|
return m.center(m.sessionsModal())
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionSummary is one recent session from GET /sessions. Mirrors the server's
|
||||||
|
// SessionSummaryResponse (apps/server SessionRoutes.kt) — the wire shape.
|
||||||
|
type SessionSummary struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Intent string `json:"intent"`
|
||||||
|
WorkflowID string `json:"workflowId"`
|
||||||
|
StageCount int `json:"stageCount"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
LastActivityAt string `json:"lastActivityAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionsLoadedMsg carries the result of a GET /sessions fetch. Exactly one of
|
||||||
|
// sessions / err is meaningful; err non-empty means the fetch failed and the
|
||||||
|
// overlay shows it instead of crashing.
|
||||||
|
type sessionsLoadedMsg struct {
|
||||||
|
sessions []SessionSummary
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionResumeMsg is the outcome of asking the server to resume a session
|
||||||
|
// (POST /sessions/{id}/resume). On success its events replay onto the live
|
||||||
|
// stream the app already drains; on failure the overlay shows the error.
|
||||||
|
type sessionResumeMsg struct {
|
||||||
|
sessionID string
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionsHTTPTimeout bounds the REST calls so a wedged server can't hang the UI.
|
||||||
|
const sessionsHTTPTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// fetchSessions is a tea.Cmd that GETs the recent-session roster over HTTP and
|
||||||
|
// reports it as a sessionsLoadedMsg. base is the http://host:port origin (from
|
||||||
|
// the ws client); an empty base yields an error message rather than a panic.
|
||||||
|
func fetchSessions(base string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if base == "" {
|
||||||
|
return sessionsLoadedMsg{err: "no server address"}
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: sessionsHTTPTimeout}
|
||||||
|
resp, err := client.Get(base + "/sessions")
|
||||||
|
if err != nil {
|
||||||
|
return sessionsLoadedMsg{err: "fetch failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return sessionsLoadedMsg{err: "server returned " + resp.Status}
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return sessionsLoadedMsg{err: "read failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
var out []SessionSummary
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
return sessionsLoadedMsg{err: "decode failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
return sessionsLoadedMsg{sessions: out}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resumeSession is a tea.Cmd that asks the server to rehydrate and relaunch a
|
||||||
|
// session (POST /sessions/{id}/resume). The server replays the rehydrated
|
||||||
|
// session onto the global stream this client already reads, so no WS re-dial is
|
||||||
|
// needed — the existing transport carries it.
|
||||||
|
func resumeSession(base, id string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if base == "" {
|
||||||
|
return sessionResumeMsg{sessionID: id, err: "no server address"}
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: sessionsHTTPTimeout}
|
||||||
|
resp, err := client.Post(base+"/sessions/"+id+"/resume", "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
return sessionResumeMsg{sessionID: id, err: "resume failed: " + err.Error()}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// 202 Accepted on success; 200 (already running) is fine too.
|
||||||
|
if resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusOK {
|
||||||
|
return sessionResumeMsg{sessionID: id, err: "server returned " + resp.Status}
|
||||||
|
}
|
||||||
|
return sessionResumeMsg{sessionID: id}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSessions opens the recent-session browser and kicks off the HTTP fetch.
|
||||||
|
// It is global (not bound to the selected session), so it opens from anywhere —
|
||||||
|
// including the idle list after a fresh reconnect with no live sessions yet.
|
||||||
|
func (m *Model) openSessions() tea.Cmd {
|
||||||
|
m.overlay = OverlaySessions
|
||||||
|
m.sessionListIndex = 0
|
||||||
|
m.sessionListErr = ""
|
||||||
|
m.sessionListLoading = true
|
||||||
|
return fetchSessions(m.client.HTTPBase())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSessionsKey owns every key while the session browser is open: navigate
|
||||||
|
// the roster, esc closes, enter resumes the selected session.
|
||||||
|
func (m Model) handleSessionsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case k.Type == tea.KeyEsc || runeIs(k, "R"):
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||||
|
if m.sessionListIndex > 0 {
|
||||||
|
m.sessionListIndex--
|
||||||
|
}
|
||||||
|
case k.Type == tea.KeyDown || runeIs(k, "j"):
|
||||||
|
if m.sessionListIndex < len(m.sessionList)-1 {
|
||||||
|
m.sessionListIndex++
|
||||||
|
}
|
||||||
|
case k.Type == tea.KeyEnter:
|
||||||
|
if m.sessionListIndex >= 0 && m.sessionListIndex < len(m.sessionList) {
|
||||||
|
return m.openSelectedSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSelectedSession focuses the highlighted recent session locally and asks the
|
||||||
|
// server to resume it. Focusing it first means its replayed events land on the
|
||||||
|
// session the operator is now looking at; the resume cmd does the rest over the
|
||||||
|
// existing stream (no WS re-dial — see resumeSession).
|
||||||
|
func (m Model) openSelectedSession() (tea.Model, tea.Cmd) {
|
||||||
|
sum := m.sessionList[m.sessionListIndex]
|
||||||
|
s := m.ensureSession(sum.SessionID)
|
||||||
|
if sum.WorkflowID != "" {
|
||||||
|
s.WorkflowID = sum.WorkflowID
|
||||||
|
if s.Name == "" {
|
||||||
|
s.Name = sum.WorkflowID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sum.Status != "" {
|
||||||
|
s.Status = sum.Status
|
||||||
|
}
|
||||||
|
m.selectedID = sum.SessionID
|
||||||
|
m.sessionEntered = true
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
return m, resumeSession(m.client.HTTPBase(), sum.SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) sessionsModal() string {
|
||||||
|
t := m.theme
|
||||||
|
w := m.modalWidth()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(m.titleLine("sessions"))
|
||||||
|
if len(m.sessionList) > 0 {
|
||||||
|
b.WriteString(mbg(t, " ("+itoa(m.sessionListIndex+1)+"/"+itoa(len(m.sessionList))+")", t.P.Faint))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if m.sessionListErr != "" {
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.sessionListErr, w-8)) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
if m.sessionListLoading && len(m.sessionList) == 0 {
|
||||||
|
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
if len(m.sessionList) == 0 {
|
||||||
|
b.WriteString(mbg(t, " no recent sessions", t.P.Faint) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"R/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windowed roster, keeping the selected row in view.
|
||||||
|
bodyH := m.height*70/100 - 6
|
||||||
|
if bodyH < 4 {
|
||||||
|
bodyH = 4
|
||||||
|
}
|
||||||
|
off := 0
|
||||||
|
if len(m.sessionList) > bodyH {
|
||||||
|
off = m.sessionListIndex - bodyH/2
|
||||||
|
if off < 0 {
|
||||||
|
off = 0
|
||||||
|
}
|
||||||
|
if off > len(m.sessionList)-bodyH {
|
||||||
|
off = len(m.sessionList) - bodyH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := off + bodyH
|
||||||
|
if end > len(m.sessionList) {
|
||||||
|
end = len(m.sessionList)
|
||||||
|
}
|
||||||
|
for i := off; i < end; i++ {
|
||||||
|
b.WriteString(m.sessionListRow(i) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{
|
||||||
|
{"↑↓", "select"}, {"enter", "resume"}, {"R/esc", "close"},
|
||||||
|
}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionListRow renders one roster line: marker, short id, status, current
|
||||||
|
// stage (if any), and relative age of the last activity.
|
||||||
|
func (m Model) sessionListRow(i int) string {
|
||||||
|
t := m.theme
|
||||||
|
s := m.sessionList[i]
|
||||||
|
|
||||||
|
marker := mbg(t, " ", t.P.BgPanel)
|
||||||
|
idFg := t.P.Fg
|
||||||
|
if i == m.sessionListIndex {
|
||||||
|
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||||
|
idFg = t.P.FgStrong
|
||||||
|
}
|
||||||
|
|
||||||
|
idCell := lipgloss.NewStyle().Foreground(idFg).Background(t.P.BgPanel).Render(padRaw(shortSessionID(s.SessionID), 8))
|
||||||
|
statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.BgPanel).Bold(true).
|
||||||
|
Render(padRaw(statusLabel(s.Status), 9))
|
||||||
|
|
||||||
|
stage := s.WorkflowID
|
||||||
|
if s.StageCount > 0 {
|
||||||
|
stage += " ·" + itoa(s.StageCount) + "stg"
|
||||||
|
}
|
||||||
|
stageCell := mbg(t, padRaw(stage, 22), t.P.Accent2)
|
||||||
|
|
||||||
|
age := relativeAge(s.LastActivityAt)
|
||||||
|
ageCell := mbg(t, age, t.P.Faint)
|
||||||
|
|
||||||
|
return marker + idCell + " " + statusCell + " " + stageCell + " " + ageCell
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortSessionID trims a long session id to a recognizable prefix for the list.
|
||||||
|
func shortSessionID(id string) string {
|
||||||
|
if len(id) <= 8 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return id[:8]
|
||||||
|
}
|
||||||
|
|
||||||
|
// relativeAge renders an ISO-8601 instant string (Instant.toString() on the
|
||||||
|
// server) as a compact "Ns ago" age, falling back to "—" when absent/unparseable.
|
||||||
|
func relativeAge(iso string) string {
|
||||||
|
if iso == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, iso)
|
||||||
|
if err != nil {
|
||||||
|
ts, err = time.Parse(time.RFC3339, iso)
|
||||||
|
if err != nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agoLabel(nowMillis() - ts.UnixMilli())
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sessionsModel() Model {
|
||||||
|
m := NewModel(nil)
|
||||||
|
m.width, m.height = 120, 40
|
||||||
|
m.theme = NewTheme(SoftBlue)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// sampleSummaries is a deterministic two-row roster used across the tests.
|
||||||
|
func sampleSummaries() []SessionSummary {
|
||||||
|
return []SessionSummary{
|
||||||
|
{SessionID: "aaaaaaaa-1111", Status: "ACTIVE", WorkflowID: "refactor", StageCount: 2},
|
||||||
|
{SessionID: "bbbbbbbb-2222", Status: "FAILED", WorkflowID: "healthcheck"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionsOverlayToggle(t *testing.T) {
|
||||||
|
m := sessionsModel()
|
||||||
|
// `R` opens the browser from the idle list.
|
||||||
|
updated, _ := m.handleNormalKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("R")})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.overlay != OverlaySessions {
|
||||||
|
t.Fatalf("expected OverlaySessions after R, got %d", m.overlay)
|
||||||
|
}
|
||||||
|
if !m.sessionListLoading {
|
||||||
|
t.Fatalf("expected loading state right after open")
|
||||||
|
}
|
||||||
|
// `esc` closes it (routed through the overlay key handler).
|
||||||
|
updated, _ = m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.overlay != OverlayNone {
|
||||||
|
t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionsLoadedPopulatesAndRenders(t *testing.T) {
|
||||||
|
m := sessionsModel()
|
||||||
|
m.overlay = OverlaySessions
|
||||||
|
m.sessionListLoading = true
|
||||||
|
m.applySessionsLoaded(sessionsLoadedMsg{sessions: sampleSummaries()})
|
||||||
|
if m.sessionListLoading {
|
||||||
|
t.Fatalf("expected loading cleared after load")
|
||||||
|
}
|
||||||
|
if len(m.sessionList) != 2 {
|
||||||
|
t.Fatalf("expected 2 sessions, got %d", len(m.sessionList))
|
||||||
|
}
|
||||||
|
out := m.sessionsModal()
|
||||||
|
if !strings.Contains(out, "aaaaaaaa") || !strings.Contains(out, "bbbbbbbb") {
|
||||||
|
t.Fatalf("modal missing session ids:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "ACTIVE") || !strings.Contains(out, "FAILED") {
|
||||||
|
t.Fatalf("modal missing statuses:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionsNavigationBoundsChecked(t *testing.T) {
|
||||||
|
m := sessionsModel()
|
||||||
|
m.overlay = OverlaySessions
|
||||||
|
m.sessionList = sampleSummaries()
|
||||||
|
// Up at the top is a no-op (clamped at 0).
|
||||||
|
updated, _ := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyUp})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.sessionListIndex != 0 {
|
||||||
|
t.Fatalf("expected index clamped to 0 at top, got %d", m.sessionListIndex)
|
||||||
|
}
|
||||||
|
// Down moves to row 1.
|
||||||
|
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.sessionListIndex != 1 {
|
||||||
|
t.Fatalf("expected index 1 after down, got %d", m.sessionListIndex)
|
||||||
|
}
|
||||||
|
// Down again is clamped at the last row.
|
||||||
|
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyDown})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.sessionListIndex != 1 {
|
||||||
|
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.sessionListIndex)
|
||||||
|
}
|
||||||
|
// `j` is the vim-style alias for down (still clamped here).
|
||||||
|
updated, _ = m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.sessionListIndex != 1 {
|
||||||
|
t.Fatalf("expected j clamped at bottom, got %d", m.sessionListIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionsEnterFocusesAndEmitsResume(t *testing.T) {
|
||||||
|
m := sessionsModel()
|
||||||
|
m.overlay = OverlaySessions
|
||||||
|
m.sessionList = sampleSummaries()
|
||||||
|
m.sessionListIndex = 1 // the FAILED healthcheck row
|
||||||
|
updated, cmd := m.handleSessionsKey(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(Model)
|
||||||
|
if m.overlay != OverlayNone {
|
||||||
|
t.Fatalf("expected overlay closed after resume, got %d", m.overlay)
|
||||||
|
}
|
||||||
|
if m.selectedID != "bbbbbbbb-2222" {
|
||||||
|
t.Fatalf("expected selected id bbbbbbbb-2222, got %q", m.selectedID)
|
||||||
|
}
|
||||||
|
if !m.sessionEntered {
|
||||||
|
t.Fatalf("expected session entered after resume")
|
||||||
|
}
|
||||||
|
if s := m.session("bbbbbbbb-2222"); s == nil || s.WorkflowID != "healthcheck" {
|
||||||
|
t.Fatalf("expected vivified session with workflow healthcheck, got %+v", s)
|
||||||
|
}
|
||||||
|
// A resume cmd is emitted; with a nil client (no server addr) it resolves to a
|
||||||
|
// sessionResumeMsg carrying the error — exercised here so it can't panic.
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatalf("expected a resume cmd to be emitted")
|
||||||
|
}
|
||||||
|
if msg, ok := cmd().(sessionResumeMsg); !ok {
|
||||||
|
t.Fatalf("expected sessionResumeMsg from resume cmd, got %T", cmd())
|
||||||
|
} else if msg.sessionID != "bbbbbbbb-2222" {
|
||||||
|
t.Fatalf("resume msg for wrong session: %q", msg.sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionsFetchErrorShownNoPanic(t *testing.T) {
|
||||||
|
m := sessionsModel()
|
||||||
|
m.overlay = OverlaySessions
|
||||||
|
m.sessionListLoading = true
|
||||||
|
m.applySessionsLoaded(sessionsLoadedMsg{err: "server returned 503 Service Unavailable"})
|
||||||
|
if m.sessionListLoading {
|
||||||
|
t.Fatalf("expected loading cleared on error")
|
||||||
|
}
|
||||||
|
if m.sessionListErr == "" {
|
||||||
|
t.Fatalf("expected error recorded")
|
||||||
|
}
|
||||||
|
out := m.sessionsModal()
|
||||||
|
if !strings.Contains(out, "503") {
|
||||||
|
t.Fatalf("modal should surface the fetch error:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativeAgeParsing(t *testing.T) {
|
||||||
|
if got := relativeAge(""); got != "—" {
|
||||||
|
t.Fatalf("empty age should be em-dash, got %q", got)
|
||||||
|
}
|
||||||
|
if got := relativeAge("not-a-timestamp"); got != "—" {
|
||||||
|
t.Fatalf("unparseable age should be em-dash, got %q", got)
|
||||||
|
}
|
||||||
|
if got := relativeAge("2020-01-01T00:00:00Z"); !strings.HasSuffix(got, "ago") {
|
||||||
|
t.Fatalf("a real instant should render an age, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,12 +51,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.applyServerPhased(msg.m)
|
m.applyServerPhased(msg.m)
|
||||||
return m, readServer(m.client)
|
return m, readServer(m.client)
|
||||||
|
|
||||||
|
case sessionsLoadedMsg:
|
||||||
|
m.applySessionsLoaded(msg)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case sessionResumeMsg:
|
||||||
|
if msg.err != "" {
|
||||||
|
m.sessionListErr = "resume: " + msg.err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
return m.handleKey(msg)
|
return m.handleKey(msg)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySessionsLoaded folds a GET /sessions result into the browser, clamping the
|
||||||
|
// cursor and surfacing any fetch error in place of a crash.
|
||||||
|
func (m *Model) applySessionsLoaded(msg sessionsLoadedMsg) {
|
||||||
|
m.sessionListLoading = false
|
||||||
|
if msg.err != "" {
|
||||||
|
m.sessionListErr = msg.err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.sessionListErr = ""
|
||||||
|
m.sessionList = msg.sessions
|
||||||
|
if m.sessionListIndex >= len(m.sessionList) {
|
||||||
|
m.sessionListIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) applyConn(s ws.Status) {
|
func (m *Model) applyConn(s ws.Status) {
|
||||||
switch {
|
switch {
|
||||||
case s.Connected:
|
case s.Connected:
|
||||||
@@ -178,6 +203,10 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.openArtifacts()
|
m.openArtifacts()
|
||||||
case "I":
|
case "I":
|
||||||
m.openIdeas()
|
m.openIdeas()
|
||||||
|
case "R":
|
||||||
|
// Resume browser: recent sessions over HTTP, reachable even after a server
|
||||||
|
// restart + TUI reopen (the live stream only carries running sessions).
|
||||||
|
return m, m.openSessions()
|
||||||
case "S":
|
case "S":
|
||||||
m.openStats()
|
m.openStats()
|
||||||
case "g":
|
case "g":
|
||||||
@@ -359,6 +388,11 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if m.overlay == OverlayConfig {
|
if m.overlay == OverlayConfig {
|
||||||
return m.handleConfigKey(k)
|
return m.handleConfigKey(k)
|
||||||
}
|
}
|
||||||
|
// Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't
|
||||||
|
// share the generic no-cmd esc path below.
|
||||||
|
if m.overlay == OverlaySessions {
|
||||||
|
return m.handleSessionsKey(k)
|
||||||
|
}
|
||||||
if k.Type == tea.KeyEsc {
|
if k.Type == tea.KeyEsc {
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -566,6 +600,7 @@ func paletteCommands() []paletteCmd {
|
|||||||
{"events", "event inspector", "browse the event stream"},
|
{"events", "event inspector", "browse the event stream"},
|
||||||
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
||||||
{"stats", "session stats", "metrics for the selected session"},
|
{"stats", "session stats", "metrics for the selected session"},
|
||||||
|
{"sessions", "resume session", "browse / resume recent sessions"},
|
||||||
{"config", "edit config", "view / change correx settings"},
|
{"config", "edit config", "view / change correx settings"},
|
||||||
{"mode", "toggle mode", "switch chat / steering"},
|
{"mode", "toggle mode", "switch chat / steering"},
|
||||||
{"cancel", "cancel session", "stop the selected session"},
|
{"cancel", "cancel session", "stop the selected session"},
|
||||||
@@ -605,6 +640,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
|||||||
m.openArtifacts()
|
m.openArtifacts()
|
||||||
case "stats":
|
case "stats":
|
||||||
m.openStats()
|
m.openStats()
|
||||||
|
case "sessions":
|
||||||
|
return m, m.openSessions()
|
||||||
case "config":
|
case "config":
|
||||||
m.openConfig()
|
m.openConfig()
|
||||||
case "mode":
|
case "mode":
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ func (m Model) renderFooter() string {
|
|||||||
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
|
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
|
||||||
case m.displayState() == StateIdle:
|
case m.displayState() == StateIdle:
|
||||||
if nrw {
|
if nrw {
|
||||||
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")}
|
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")}
|
||||||
} else {
|
} else {
|
||||||
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")}
|
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")}
|
||||||
}
|
}
|
||||||
default: // StateInSession
|
default: // StateInSession
|
||||||
if nrw {
|
if nrw {
|
||||||
|
|||||||
@@ -28,23 +28,36 @@ type Status struct {
|
|||||||
// Client is a reconnecting WebSocket client. Construct with New, then Run in a
|
// Client is a reconnecting WebSocket client. Construct with New, then Run in a
|
||||||
// goroutine. Incoming and Conn are drained by the UI.
|
// goroutine. Incoming and Conn are drained by the UI.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
url string
|
url string
|
||||||
send chan []byte
|
httpBase string
|
||||||
in chan protocol.ServerMessage
|
send chan []byte
|
||||||
conn chan Status
|
in chan protocol.ServerMessage
|
||||||
|
conn chan Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a client targeting ws://host:port/stream.
|
// New builds a client targeting ws://host:port/stream.
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
u := url.URL{Scheme: "ws", Host: hostPort(host, port), Path: "/stream"}
|
hp := hostPort(host, port)
|
||||||
|
u := url.URL{Scheme: "ws", Host: hp, Path: "/stream"}
|
||||||
return &Client{
|
return &Client{
|
||||||
url: u.String(),
|
url: u.String(),
|
||||||
send: make(chan []byte, 64),
|
httpBase: (&url.URL{Scheme: "http", Host: hp}).String(),
|
||||||
in: make(chan protocol.ServerMessage, 256),
|
send: make(chan []byte, 64),
|
||||||
conn: make(chan Status, 16),
|
in: make(chan protocol.ServerMessage, 256),
|
||||||
|
conn: make(chan Status, 16),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPBase is the http://host:port origin the WS client connects to, for REST
|
||||||
|
// calls (e.g. GET /sessions) that share the server's host/port. Empty for a nil
|
||||||
|
// client (the test harness constructs Models with a nil client).
|
||||||
|
func (c *Client) HTTPBase() string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.httpBase
|
||||||
|
}
|
||||||
|
|
||||||
func hostPort(host string, port int) string {
|
func hostPort(host string, port int) string {
|
||||||
h := host
|
h := host
|
||||||
if h == "" {
|
if h == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user