merge: integrate feat/backlog-burndown into master

master had advanced 16 commits past feat/backlog-burndown's base, and the two
branches independently built four of the same features. Resolved 26 conflicts.

Overlap features — kept master's implementation (more complete / production-wired /
more robust), dropped the feature branch's parallel constellation:
- llama-server health probe: kept master's event-store-backed tps probe; dropped the
  branch's LlamaLivenessClient (liveness-only, throughput unwired).
- event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe.
- brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording);
  dropped the branch's exact-set-diff BriefEchoComparator/Extractor.
- static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner,
  wired); dropped the branch's structured-finding static_check stage (no-op seam).
  Its structured-findings model is filed as a follow-up in BACKLOG.

Feature-branch net-new work brought in and kept (master had none):
- native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer,
  dependency graph + gates, decompose, REST/CLI, TUI task board)
- critique-outcome producer (role-rel §6 — master had deferred it)
- stage-level plan checkpointing (C-A2, folded into runPostStageGates)
- CLAUDE.md/AGENTS.md L0 standing context
- cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser)

Verified: full Gradle compile (all modules + tests) green; tests pass for core:events,
core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go
go build + go test green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 11:30:41 +00:00
259 changed files with 17681 additions and 674 deletions
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// inSessionModel builds an in-session model with deliberately long fields (model name,
@@ -40,7 +40,7 @@ func assertNoOverflow(t *testing.T, out string, w int) {
func TestViewNeverOverflowsWidth(t *testing.T) {
for _, w := range []int{40, 50, 60, 80, 95, 96, 110, 160} {
m := inSessionModel(w, 30)
assertNoOverflow(t, m.View(), w)
assertNoOverflow(t, m.render(), w)
}
}
@@ -51,7 +51,7 @@ func TestStatsOverlayNeverOverflowsWidth(t *testing.T) {
m.overlay = OverlayStats
m.statsFor = m.selectedID
m.stats = sampleStats()
assertNoOverflow(t, m.View(), w)
assertNoOverflow(t, m.render(), w)
}
}
+256
View File
@@ -0,0 +1,256 @@
package app
import (
"encoding/json"
"strings"
"testing"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
// approval_test.go covers the approval-ergonomics gaps (BACKLOG §E §3): single-key
// approve/reject/steer for low tiers, an arm→confirm gate for T3+, and a navigable
// pending-approval queue (never modal-stacked). Deterministic: an unconnected ws
// client buffers Sent frames, Drain reads them back.
// approvalModel builds an entered session and queues each given approval through the
// production applyServer path, so the queue/Pending state matches real event flow.
func approvalModel(approvals ...protocol.ServerMessage) (Model, *ws.Client) {
client := ws.New("", 0) // unconnected; Send buffers, Drain reads it back
m := NewModel(client)
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
m.selectedID = "s1"
m.sessionEntered = true
m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}}
for _, a := range approvals {
m.applyServer(a)
}
return m, client
}
// apprReq builds an approval.required frame for the given request id and tier.
func apprReq(reqID, tier string) protocol.ServerMessage {
return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) {
m.RequestID, m.Tier = reqID, tier
m.RiskSummary = &protocol.RiskSummaryDto{Level: "LOW"}
})
}
// applyKey drives a key through the production handleKey entry and returns the model.
func applyKey(m Model, k keyMsg) Model {
updated, _ := m.handleKey(k)
return updated.(Model)
}
func runeKey(r rune) keyMsg { return keyMsg{Type: keyRunes, Runes: []rune{r}} }
// decodeApproval pulls the single ApprovalResponse frame off the client and returns
// its request id + decision. Fails if there isn't exactly one decision frame.
func decodeApproval(t *testing.T, client *ws.Client) (requestID, decision string) {
t.Helper()
var resp map[string]any
n := 0
for _, f := range client.Drain() {
var raw map[string]any
if err := json.Unmarshal(f, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
resp = raw
n++
}
}
if n != 1 {
t.Fatalf("expected exactly one ApprovalResponse frame, got %d", n)
}
rid, _ := resp["requestId"].(string)
dec, _ := resp["decision"].(string)
return rid, dec
}
// noApprovalSent asserts the client buffered no ApprovalResponse frame.
func noApprovalSent(t *testing.T, client *ws.Client) {
t.Helper()
for _, f := range client.Drain() {
var raw map[string]any
_ = json.Unmarshal(f, &raw)
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
t.Fatalf("did not expect an ApprovalResponse frame yet")
}
}
}
func TestApprovalLowTierApprovesImmediately(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T1"))
if m.displayState() != StateApproval {
t.Fatalf("want StateApproval, got %v", m.displayState())
}
m = applyKey(m, runeKey('y'))
rid, dec := decodeApproval(t, client)
if rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1, got %s/%s", rid, dec)
}
if s := m.session("s1"); s == nil || s.Pending != nil {
t.Fatalf("pending gate should be cleared after a low-tier approve")
}
if m.approvalArmed {
t.Fatalf("low-tier approve must not leave an armed confirm")
}
}
func TestApprovalHighTierRequiresSecondConfirm(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T3"))
// First `y` arms; nothing is sent yet.
m = applyKey(m, runeKey('y'))
if !m.approvalArmed {
t.Fatalf("T3 approve should arm a confirm, not send")
}
noApprovalSent(t, client)
if s := m.session("s1"); s == nil || s.Pending == nil {
t.Fatalf("gate must remain pending while armed")
}
// Second `y` confirms and sends.
m = applyKey(m, runeKey('y'))
rid, dec := decodeApproval(t, client)
if rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1 after confirm, got %s/%s", rid, dec)
}
if m.approvalArmed {
t.Fatalf("arm should clear after the confirming keypress")
}
}
func TestApprovalHighTierArmCancelledByOtherKey(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T3"))
m = applyKey(m, runeKey('y')) // arm
if !m.approvalArmed {
t.Fatalf("expected armed after first y")
}
// A non-confirm key (here `n` reject) cancels the arm. The arm itself must be
// gone, and no spurious APPROVE may slip through.
m = applyKey(m, runeKey('n'))
if m.approvalArmed {
t.Fatalf("arm should be cancelled by a non-confirm key")
}
rid, dec := decodeApproval(t, client)
if dec != "REJECT" || rid != "req-1" {
t.Fatalf("non-confirm key should route to its own action (reject), got %s/%s", rid, dec)
}
}
func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) {
// Two T3 gates: arming then pressing ↓ must disarm and only move the cursor —
// no decision may be emitted.
m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3"))
m = applyKey(m, runeKey('y')) // arm req-1
m = applyKey(m, keyMsg{Type: keyDown})
if m.approvalArmed {
t.Fatalf("nav key should disarm")
}
noApprovalSent(t, client)
if s := m.session("s1"); s == nil || s.PendingIdx != 1 {
t.Fatalf("expected cursor advanced to index 1, got %d", s.PendingIdx)
}
}
func TestApprovalQueueNavigatesAndCounts(t *testing.T) {
m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"), apprReq("req-3", "T1"))
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 3 {
t.Fatalf("expected 3 queued approvals, got %d", len(s.PendingQueue))
}
// The band shows a "1/3" count and the current request, never stacked modals.
out := stripANSI(m.renderApprovalBand(m.approvalBandHeight()))
if !strings.Contains(out, "1/3") {
t.Fatalf("band should show queue count 1/3:\n%s", out)
}
// ↓ advances the selection; Pending follows the cursor.
m = applyKey(m, keyMsg{Type: keyDown})
s = m.session("s1")
if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" {
t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID)
}
out = stripANSI(m.renderApprovalBand(m.approvalBandHeight()))
if !strings.Contains(out, "2/3") {
t.Fatalf("band should show 2/3 after ↓:\n%s", out)
}
// ↑ wraps back.
m = applyKey(m, keyMsg{Type: keyUp})
if m.session("s1").PendingIdx != 0 {
t.Fatalf("↑ should return to index 0")
}
}
func TestApprovalResolveFrontAdvancesQueue(t *testing.T) {
m, client := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"))
// Approve the front gate (low tier → immediate).
m = applyKey(m, runeKey('y'))
if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "APPROVE" {
t.Fatalf("want approve req-1, got %s/%s", rid, dec)
}
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-2" {
t.Fatalf("resolving the front gate should advance to req-2, got %+v", s.PendingQueue)
}
// A server-side resolve of the remaining gate empties the queue and exits approval.
m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) {
mm.RequestID, mm.Outcome = "req-2", "APPROVED"
}))
if s := m.session("s1"); s == nil || s.Pending != nil || len(s.PendingQueue) != 0 {
t.Fatalf("server resolve should clear the last gate")
}
if m.displayState() != StateInSession {
t.Fatalf("empty queue should leave approval state, got %v", m.displayState())
}
}
func TestApprovalServerResolveRemovesSpecificGate(t *testing.T) {
// A resolve for the *back* gate must drop only that one and keep the front shown.
m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"))
m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) {
mm.RequestID, mm.Outcome = "req-2", "APPROVED"
}))
s := m.session("s1")
if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-1" {
t.Fatalf("resolving req-2 should leave req-1 shown, got %+v", s.PendingQueue)
}
}
func TestApprovalRejectAndSteerRoute(t *testing.T) {
// Reject is single-key for a low tier.
m, client := approvalModel(apprReq("req-1", "T1"))
m = applyKey(m, runeKey('n'))
if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "REJECT" {
t.Fatalf("want reject req-1, got %s/%s", rid, dec)
}
// Steer enters the existing note-input path (no decision sent yet).
m2, client2 := approvalModel(apprReq("req-2", "T1"))
m2 = applyKey(m2, runeKey('e'))
if !m2.steering || m2.editMode != ModeInsert {
t.Fatalf("steer key should enter the steering-note input path")
}
noApprovalSent(t, client2)
// Typing a note then Enter approves with the note riding along.
for _, r := range "use sudo" {
m2 = applyKey(m2, runeKey(r))
}
m2 = applyKey(m2, keyMsg{Type: keyEnter})
frames := client2.Drain()
var note string
for _, f := range frames {
var raw map[string]any
_ = json.Unmarshal(f, &raw)
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
note, _ = raw["steeringNote"].(string)
if raw["decision"] != "APPROVE" {
t.Fatalf("steer+enter should approve, got %v", raw["decision"])
}
}
}
if note != "use sudo" {
t.Fatalf("steering note should ride along with the decision, got %q", note)
}
}
+16 -16
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -92,7 +92,7 @@ func (m Model) clarAnswerValue(i int, q ClarQuestion) string {
// --- key handling ---
func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleClarificationKey(k keyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Clar == nil {
return m, nil
@@ -102,21 +102,21 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleClarTyping(k), nil
}
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.clarDismissed = true
case tea.KeyUp:
case keyUp:
m.clarFocusMove(-1, qs)
case tea.KeyDown:
case keyDown:
m.clarFocusMove(1, qs)
case tea.KeyLeft:
case keyLeft:
m.clarCursorMove(-1, qs)
case tea.KeyRight:
case keyRight:
m.clarCursorMove(1, qs)
case tea.KeySpace:
case keySpace:
m.clarSelect(qs)
case tea.KeyEnter:
case keyEnter:
return m.clarSubmit()
case tea.KeyRunes:
case keyRunes:
switch string(k.Runes) {
case "k":
m.clarFocusMove(-1, qs)
@@ -136,24 +136,24 @@ func (m Model) handleClarificationKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) handleClarTyping(k tea.KeyMsg) tea.Model {
func (m Model) handleClarTyping(k keyMsg) tea.Model {
if m.clarFocus >= len(m.clarText) {
return m
}
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.clarTyping = false
case tea.KeyEnter:
case keyEnter:
m.clarTyping = false
// committing custom text supersedes any chosen option for this question
if m.clarFocus < len(m.clarChosen) {
m.clarChosen[m.clarFocus] = map[int]bool{}
}
case tea.KeyBackspace:
case keyBackspace:
if t := m.clarText[m.clarFocus]; len(t) > 0 {
m.clarText[m.clarFocus] = t[:len(t)-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.clarText[m.clarFocus] += string(k.Runes)
}
return m
+9 -10
View File
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -43,13 +42,13 @@ func TestClarificationEntersStateAndRenders(t *testing.T) {
func TestClarificationSelectAndSubmit(t *testing.T) {
m := clarModel()
// move the option cursor to "Vue" (index 1) and select it
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRight})
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
m = applyClarKey(m, keyMsg{Type: keyRight})
m = applyClarKey(m, keyMsg{Type: keySpace})
if !m.clarChosenAt(0, 1) {
t.Fatalf("expected option 1 chosen, chosen=%v", m.clarChosen)
}
// submit
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar != nil {
t.Fatalf("expected clarification cleared after submit")
}
@@ -68,8 +67,8 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
},
})
// answer only the first question, then attempt submit
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeySpace})
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keySpace})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Clar == nil {
t.Fatalf("expected clarification to remain with partial answers")
}
@@ -80,14 +79,14 @@ func TestClarificationSubmitGuardWaitsForAllAnswers(t *testing.T) {
func TestClarificationCustomTextAnswer(t *testing.T) {
m := clarModel()
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
if !m.clarTyping {
t.Fatalf("expected typing mode after 'e'")
}
for _, r := range "Solid" {
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = applyClarKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
}
m = applyClarKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyClarKey(m, keyMsg{Type: keyEnter})
if m.clarTyping {
t.Fatalf("expected typing committed")
}
@@ -99,7 +98,7 @@ func TestClarificationCustomTextAnswer(t *testing.T) {
}
}
func applyClarKey(m Model, k tea.KeyMsg) Model {
func applyClarKey(m Model, k keyMsg) Model {
updated, _ := m.handleClarificationKey(k)
return updated.(Model)
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
+11 -11
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -23,40 +23,40 @@ func (m *Model) openConfig() {
// handleConfigKey owns every key while the config overlay is open. In edit mode it captures the
// value being typed; otherwise it navigates fields, stages edits, and saves.
func (m Model) handleConfigKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleConfigKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.configEditing {
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.configEditing = false
m.configEditBuf = ""
case tea.KeyEnter:
case keyEnter:
if m.configIndex >= 0 && m.configIndex < len(m.configFields) {
m.configStaged[m.configFields[m.configIndex].Key] = m.configEditBuf
}
m.configEditing = false
m.configEditBuf = ""
case tea.KeyBackspace:
case keyBackspace:
if n := len(m.configEditBuf); n > 0 {
m.configEditBuf = m.configEditBuf[:n-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.configEditBuf += string(k.Runes)
}
return m, nil
}
switch {
case k.Type == tea.KeyEsc || runeIs(k, "g"):
case k.Type == keyEsc || runeIs(k, "g"):
m.overlay = OverlayNone
case k.Type == tea.KeyUp || runeIs(k, "k"):
case k.Type == keyUp || runeIs(k, "k"):
if m.configIndex > 0 {
m.configIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
case k.Type == keyDown || runeIs(k, "j"):
if m.configIndex < len(m.configFields)-1 {
m.configIndex++
}
case k.Type == tea.KeyEnter || k.Type == tea.KeySpace:
case k.Type == keyEnter || k.Type == keySpace:
m = m.actOnConfigField()
case runeIs(k, "s"):
if len(m.configStaged) > 0 {
@@ -4,7 +4,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
)
@@ -23,7 +22,7 @@ func configModel() Model {
func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
m := configModel()
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configStaged["project.enabled"] != "true" {
t.Fatalf("want staged project.enabled=true, got %q", m.configStaged["project.enabled"])
@@ -40,7 +39,7 @@ func TestConfigToggleBoolStagesFlippedValue(t *testing.T) {
func TestConfigEnumCycles(t *testing.T) {
m := configModel()
m.configIndex = 1 // tui.theme
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configStaged["tui.theme"] != "light" {
t.Fatalf("want staged tui.theme=light (next after dark), got %q", m.configStaged["tui.theme"])
@@ -51,7 +50,7 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) {
m := configModel()
m.configIndex = 2 // router.generation.max_tokens
// Enter edit mode.
updated, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if !m.configEditing {
t.Fatalf("expected edit mode after enter on INT field")
@@ -59,10 +58,10 @@ func TestConfigIntEditCommitsBuffer(t *testing.T) {
// Clear the seeded buffer and type a new value.
m.configEditBuf = ""
for _, r := range "1024" {
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
updated, _ = m.handleConfigKey(keyMsg{Type: keyRunes, Runes: []rune{r}})
m = updated.(Model)
}
updated, _ = m.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ = m.handleConfigKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.configEditing {
t.Fatalf("expected edit mode to end after commit")
+83
View File
@@ -0,0 +1,83 @@
package app
import (
"strings"
"testing"
)
// splitCursor must report the caret's display column/row from a composited frame
// (ANSI ignored) and swap the marker for the replacement glyph.
func TestSplitCursorCoords(t *testing.T) {
// marker sits after 3 visible cells ("a", "b", "c") on the second line.
raw := "first line\n" + "ab\x1b[38;2;1;2;3mc\x1b[m" + cursorMarker + "d"
clean, cur := splitCursor(raw, " ")
if cur == nil {
t.Fatal("expected a cursor when the marker is present")
}
if cur.X != 3 || cur.Y != 1 {
t.Fatalf("cursor at (%d,%d), want (3,1)", cur.X, cur.Y)
}
if strings.Contains(clean, cursorMarker) {
t.Error("marker must be stripped from the returned frame")
}
if strings.Count(clean, " ") == 0 {
t.Error("marker should have been replaced by the repl glyph")
}
}
// No marker (e.g. normal mode) → no cursor, frame unchanged.
func TestSplitCursorAbsent(t *testing.T) {
raw := "no caret here"
clean, cur := splitCursor(raw, "▏")
if cur != nil {
t.Error("no marker must yield a nil cursor (hidden)")
}
if clean != raw {
t.Error("frame must be unchanged when no marker is present")
}
}
// The live View hides the cursor in normal mode and shows a real one in insert
// mode; the marker must never leak into the rendered content either way.
func TestViewCursorByMode(t *testing.T) {
m := inSessionModel(120, 40)
m.editMode = ModeNormal
v := m.View()
if v.Cursor != nil {
t.Error("normal mode must hide the cursor (nil)")
}
if strings.Contains(v.Content, cursorMarker) {
t.Error("content must not contain the raw cursor marker")
}
m.editMode = ModeInsert
m.inputMode = ModeRouter
m.inputBuffer = "hello"
m.inputCursor = len(m.inputBuffer)
v = m.View()
if v.Cursor == nil {
t.Fatal("insert mode must show a real cursor")
}
if v.Cursor.Y < 0 || v.Cursor.Y >= m.height || v.Cursor.X < 0 {
t.Errorf("cursor (%d,%d) out of frame bounds", v.Cursor.X, v.Cursor.Y)
}
if strings.Contains(v.Content, cursorMarker) {
t.Error("content must not contain the raw cursor marker")
}
// The static render keeps a visible glyph and also strips the marker.
if strings.Contains(m.render(), cursorMarker) {
t.Error("render() must strip the marker")
}
// An open overlay keeps editMode==Insert (so closing resumes typing) but the
// composer must not draw a live cursor behind the modal.
m.overlay = OverlayPalette
if v = m.View(); v.Cursor != nil {
t.Error("an open overlay must suppress the composer cursor")
}
if strings.Contains(v.Content, cursorMarker) {
t.Error("content must not contain the marker with an overlay open")
}
}
+156 -7
View File
@@ -22,6 +22,43 @@ func PreviewFrame(kind string, w, h int) string {
m.bgUpdates = 3
m.selectedID = "04a546aa"
case "changes":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.rightPanel = 1
if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "write_script"
}
m.routerMessages["04a546aa"] = []RouterEntry{
{Role: "user", Content: "add a healthcheck script and document it"},
{Role: "router", Content: "I'll write the script.", Metrics: &TurnMetrics{LatencyMs: 1200, TotalTokens: 340}},
{Role: "tool", Content: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf localhost:8080/health\n+echo ok\n+exit 0\n"},
{Role: "router", Content: "Now the docs.", Metrics: &TurnMetrics{LatencyMs: 820, TotalTokens: 150}},
{Role: "tool", Content: "--- a/README.md\n+++ b/README.md\n@@ -1,1 +1,2 @@\n existing line\n+## Health\n"},
}
case "compose":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.editMode = ModeInsert
m.inputMode = ModeRouter
m.inputBuffer = "fix the healthcheck script\nso it retries three times\nbefore it fails"
m.inputCursor = len(m.inputBuffer)
case "resume":
m.connected = true
m.currentModel = "llama-cpp:default"
m.overlay = OverlaySessions
m.sessionList = []SessionSummary{
{SessionID: "04a546aa8b2c", Status: "ACTIVE", WorkflowID: "healthcheck", StageCount: 3, LastActivityAt: "2026-06-22T14:30:05Z"},
{SessionID: "0d7097bb1f3e", Status: "COMPLETED", WorkflowID: "healthcheck", StageCount: 5, LastActivityAt: "2026-06-21T09:15:00Z"},
{SessionID: "1dae17cc77aa", Status: "FAILED", WorkflowID: "chat", LastActivityAt: "2025-12-30T18:02:00Z"},
}
case "workflows":
m.connected = true
m.currentModel = "llama-cpp:default"
@@ -78,14 +115,14 @@ func PreviewFrame(kind string, w, h int) string {
s.CurrentStage = "write_script"
s.WorkspaceRoot = "/home/kami/Programs/correx"
s.Events = sampleEvents()
s.Pending = &Approval{
s.enqueueApproval(&Approval{
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
ToolName: "file_write",
Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo ok\n",
Rationale: []string{
"[PATH_OUTSIDE_WORKSPACE] Tool 'file_write' targets path outside workspace: /tmp/healthcheck.sh",
},
}
})
}
case "approval-steer":
@@ -97,11 +134,11 @@ func PreviewFrame(kind string, w, h int) string {
m.steerBuffer = "also print the current distro and kernel version"
if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "write_script"
s.Pending = &Approval{
s.enqueueApproval(&Approval{
RequestID: "req-3", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
ToolName: "file_write",
Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,2 @@\n+#!/usr/bin/env bash\n+echo ok\n",
}
})
}
case "approval-shell":
@@ -114,14 +151,14 @@ func PreviewFrame(kind string, w, h int) string {
s.CurrentStage = "execute_script"
s.Events = sampleEvents()
s.WorkspaceRoot = "/home/kami/Programs/correx"
s.Pending = &Approval{
s.enqueueApproval(&Approval{
RequestID: "req-2", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH",
ToolName: "shell",
Preview: `{"argv":["bash","-c","curl -sf https://example.com/install.sh | sudo sh"]}`,
Rationale: []string{
"[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'",
},
}
})
}
case "diff":
@@ -160,8 +197,120 @@ func PreviewFrame(kind string, w, h int) string {
m.selectedID = "04a546aa"
m.overlay = OverlayHealth
m.health = sampleHealth()
case "grant-scope":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.grantFor = &Approval{RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", ToolName: "file_write"}
m.grantScopeIndex = 1
m.overlay = OverlayGrantScope
case "actions":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.routerConnected = true
m.routerMessages["04a546aa"] = []RouterEntry{
{Role: "user", Content: "run the healthcheck and write the script"},
{Role: "router", Content: "I'll create the script, then run it."},
{Role: "action", Icon: "✎", Content: "wrote healthcheck.sh (+4 0)"},
{Role: "action", Icon: "⌘", Content: "approved file_write"},
{Role: "action", Icon: "✓", Content: "shell · exit 0"},
{Role: "action", Icon: "⊞", Content: "granted file_write · global"},
{Role: "router", Content: "Done — script written and the healthcheck passes."},
}
if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "execute_script"
s.LastEventAt = nowMillis() - 3000
}
case "help":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.overlay = OverlayHelp
case "events-filter":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
if s := m.session("04a546aa"); s != nil {
s.Events = sampleEvents()
}
m.overlay = OverlayEventInspector
m.eventFilter = "tool"
m.eventFilterTyping = true
case "grants":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.sessionEntered = true
m.grants = []protocol.GrantDto{
{GrantID: "g-1", Scope: "GLOBAL", ToolName: "read_file", Tiers: []string{"T1", "T2"}},
{GrantID: "g-2", Scope: "PROJECT", ToolName: "file_write", Tiers: []string{"T3"}, ProjectID: "/home/kami/Programs/correx"},
{GrantID: "g-3", Scope: "GLOBAL", ToolName: "shell", Tiers: []string{"T3", "T4"}},
}
m.grantIndex = 1
m.overlay = OverlayGrants
case "tasks", "task-detail":
m.connected = true
m.currentModel = "llama-cpp:default"
m.sessions = sampleSessions()
m.selectedID = "04a546aa"
m.overlay = OverlayTasks
m.taskList = sampleTaskBoard()
if kind == "task-detail" {
m.taskDetail = true
m.taskListIndex = 2 // a blocked child, to show the "waiting on" line
}
}
return m.render()
}
// sampleTaskBoard is a decomposed work graph: an epic waiting on its three
// children, one of which (webui-2) is ready now, plus an in-flight and a done
// task — exercising every readiness glyph and status color.
func sampleTaskBoard() []TaskSummary {
return []TaskSummary{
{
ID: "webui-1", Key: "webui-1", Status: "TODO", Title: "Frontend web UI for correx",
Goal: "operator can drive sessions from a browser",
BlockedBy: []string{"webui-2", "webui-3", "webui-4"},
Links: []TaskLinkWire{{TargetID: "webui-2", Type: "DEPENDS_ON", TargetKind: "TASK"}},
},
{
ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold the Vite + React app",
Goal: "buildable shell with routing", Ready: true,
AcceptanceCriteria: []string{"npm run build passes", "lands under apps/web"},
AffectedPaths: []string{"apps/web/"},
},
{
ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth / token entry view",
Goal: "operator pastes a token and connects", BlockedBy: []string{"webui-2"},
Links: []TaskLinkWire{{TargetID: "webui-1", Type: "IMPLEMENTS", TargetKind: "TASK"}},
},
{
ID: "webui-4", Key: "webui-4", Status: "TODO", Title: "Session board view",
Goal: "live roster mirroring the TUI", BlockedBy: []string{"webui-2"},
},
{
ID: "api-7", Key: "api-7", Status: "IN_PROGRESS", Title: "CORS + token endpoint",
Goal: "server accepts browser origins", Claimant: "04a546aa8b2c",
},
{ID: "api-3", Key: "api-3", Status: "DONE", Title: "Stable /sessions JSON shape"},
}
return m.View()
}
func sampleStats() *protocol.StatsDto {
+7 -3
View File
@@ -1,10 +1,11 @@
package app
import (
"image/color"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
type diffKind int
@@ -53,7 +54,10 @@ func parseUnifiedDiff(diff string) []diffRow {
}
for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") {
switch {
case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"),
// File headers carry a trailing space ("+++ b/x"); requiring it means a real
// changed line whose content starts with "++"/"--" isn't mistaken for a header
// and silently dropped from the rendered diff (and its row count).
case strings.HasPrefix(ln, "+++ "), strings.HasPrefix(ln, "--- "),
strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "):
continue
case strings.HasPrefix(ln, "@@"):
@@ -193,7 +197,7 @@ func (m Model) diffCell(num int, text string, kind diffKind, w int) string {
if kind == diffBlank {
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
}
var bg, textFg, signFg lipgloss.Color
var bg, textFg, signFg color.Color
sign := " "
switch kind {
case diffAdd:
@@ -0,0 +1,46 @@
package app
import (
"strings"
"testing"
)
// scrollDiff must clamp to [0, diffMaxScroll] against the same geometry the modal renders to,
// so PgUp/PgDn (and g/G) past either end land exactly on the edge — no dead presses unwinding
// an overshoot. Backs the fullscreen viewer for every tool preview (diff / command / plain).
func TestDiffScrollClampsToBounds(t *testing.T) {
m := inSessionModel(120, 30)
var b strings.Builder
b.WriteString("--- a/f\n+++ b/f\n@@ -0,0 +1,40 @@\n")
for i := 0; i < 40; i++ {
b.WriteString("+a new line\n")
}
// currentDiff() falls back to the last "tool" transcript entry when no approval is pending.
m.routerMessages[m.selectedID] = []RouterEntry{{Role: "tool", Content: b.String()}}
max := m.diffMaxScroll()
if max <= 0 {
t.Fatalf("fixture diff must exceed the modal body height to test scrolling; max=%d", max)
}
m.scrollDiff(10_000) // way past the bottom
if m.diffScrollOffset != max {
t.Fatalf("scroll past end clamped to %d, want max %d", m.diffScrollOffset, max)
}
m.scrollDiff(-10_000) // way past the top
if m.diffScrollOffset != 0 {
t.Fatalf("scroll past top clamped to %d, want 0", m.diffScrollOffset)
}
}
// A diff that fits the modal body never scrolls.
func TestDiffScrollNoOpWhenFits(t *testing.T) {
m := inSessionModel(120, 30)
m.routerMessages[m.selectedID] = []RouterEntry{
{Role: "tool", Content: "--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-x\n+y\n"},
}
m.scrollDiff(5)
if m.diffScrollOffset != 0 {
t.Fatalf("short diff should not scroll, got %d", m.diffScrollOffset)
}
}
@@ -0,0 +1,38 @@
package app
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
)
// editorLines splits the buffer on newlines into one display row each, in order.
func TestEditorLinesSplitsOnNewline(t *testing.T) {
m := NewModel(nil)
m.theme = NewTheme(SoftBlue)
m.inputBuffer = "one\ntwo\nthree"
m.inputCursor = len(m.inputBuffer)
lines := m.editorLines()
if len(lines) != 3 {
t.Fatalf("want 3 lines, got %d", len(lines))
}
for i, want := range []string{"one", "two", "three"} {
if got := strings.TrimRight(ansi.Strip(lines[i]), " ▏"); got != want {
t.Errorf("line %d = %q, want %q", i, got, want)
}
}
}
// A buffer taller than maxInputLines shows only the last maxInputLines rows (cursor tail).
func TestEditorLinesCapsHeight(t *testing.T) {
m := NewModel(nil)
m.theme = NewTheme(SoftBlue)
m.inputBuffer = strings.Repeat("x\n", maxInputLines+4) + "last"
m.inputCursor = len(m.inputBuffer)
if got := len(m.editorLines()); got != maxInputLines {
t.Fatalf("want capped at %d lines, got %d", maxInputLines, got)
}
}
@@ -0,0 +1,33 @@
package app
import (
"strings"
"testing"
)
// The event-stream badge must track the selected session's lifecycle, not just the socket:
// a terminal/paused session emits no more events, so it must not keep reading "● live".
func TestEventStreamBadgeReflectsSessionStatus(t *testing.T) {
cases := []struct{ status, want string }{
{"ACTIVE", "● live"},
{"COMPLETED", "✓ done"},
{"FAILED", "✗ failed"},
{"PAUSED awaiting approval", "‖ paused"},
}
for _, c := range cases {
m := inSessionModel(120, 40)
m.connected = true
m.sessions[0].Status = c.status
if got := m.eventStreamBadge(); !strings.Contains(got, c.want) {
t.Errorf("status %q: badge = %q, want substring %q", c.status, got, c.want)
}
}
// Disconnected always reads idle, regardless of the session's status.
m := inSessionModel(120, 40)
m.connected = false
m.sessions[0].Status = "ACTIVE"
if got := m.eventStreamBadge(); !strings.Contains(got, "○ idle") {
t.Errorf("disconnected: badge = %q, want idle", got)
}
}
@@ -0,0 +1,62 @@
package app
import "testing"
func TestAtTokenBoundary(t *testing.T) {
cases := []struct {
buf string
cur int
want bool
}{
{"", 0, true}, // start of empty line
{"see ", 4, true}, // just after a space
{"email", 5, false}, // mid-word (e.g. user@host)
{"a b", 2, true}, // after the space
{"a b", 3, false}, // after 'b'
}
for _, c := range cases {
m := NewModel(nil)
m.inputBuffer = c.buf
m.inputCursor = c.cur
if got := m.atTokenBoundary(); got != c.want {
t.Errorf("atTokenBoundary(%q,@%d) = %v, want %v", c.buf, c.cur, got, c.want)
}
}
}
// `@` consumes itself to open the picker; selecting a file inserts `@path ` at the cursor.
func TestInsertFileRef(t *testing.T) {
m := NewModel(nil)
m.inputBuffer = "look at "
m.inputCursor = len(m.inputBuffer)
m.insertFileRef("src/main.go")
if want := "look at @src/main.go "; m.inputBuffer != want {
t.Fatalf("buffer = %q, want %q", m.inputBuffer, want)
}
if m.inputCursor != len(m.inputBuffer) {
t.Fatalf("cursor = %d, want %d", m.inputCursor, len(m.inputBuffer))
}
}
func TestFilteredFiles(t *testing.T) {
m := NewModel(nil)
m.files = []string{"cmd/main.go", "internal/app/view.go", "README.md", "internal/app/model.go"}
m.fileFilter = ""
if len(m.filteredFiles()) != 4 {
t.Fatalf("empty filter should pass all 4, got %d", len(m.filteredFiles()))
}
m.fileFilter = "app/"
got := m.filteredFiles()
if len(got) != 2 {
t.Fatalf("filter %q matched %d, want 2 (%v)", m.fileFilter, len(got), got)
}
m.fileFilter = "README"
if got := m.filteredFiles(); len(got) != 1 || got[0] != "README.md" {
t.Fatalf("filter README = %v, want [README.md]", got)
}
}
@@ -0,0 +1,62 @@
package app
import (
"strings"
"testing"
)
// Every key the TUI binds (normal mode, approval band, and the diff/preview viewer) must
// have a row in the ? overlay. Keyed by a distinctive description phrase so deleting a row
// fails loudly here — keep this list in step with handleNormalKey / handleApprovalKey and
// the OverlayDiff handler whenever a binding is added or changed.
func TestHelpCoversEveryKeybind(t *testing.T) {
m := inSessionModel(120, 80)
help := strings.Join(m.helpBody(), "\n")
want := []string{
// navigate
"move the session / list selection", // ↑↓ / j k
"open the selected session", // enter
"cycle launch target", // Tab / w
"filter the session list", // /
"jump to your previous / next message", // ^↑ / ^↓
"scroll output", // PgUp / PgDn (+ ^u / ^d)
"drop selection", // esc
"back to the session list", // l
// session
"compose a message", // i
"right panel: events / changes", // d
"toggle chat / steering mode", // s
"show workflows", // w
"copy the selected message", // y
"open the latest diff / preview", // ^x
"cancel the running session", // c
"re-open a dismissed approval gate", // a
"quit", // q
// overlays
"command palette", // p
"this keybindings help", // ?
"event inspector", // e
"tools / artifacts", // t / v
"resume past sessions", // S / R
"swap model / edit config", // m / g
"grants / idea board", // G / I
// approval band
"approve once", // y / a / enter
"reject", // n / r
"steer (add a note)", // e / s
"approve-always", // A
"walk the pending queue", // ↑ / ↓
"fullscreen the diff / command preview", // ^x
"dismiss for now", // esc
// diff / preview viewer
"scroll a line", // ↑ / ↓
"scroll a page", // PgUp / PgDn
"jump to top / end", // g / G
}
for _, w := range want {
if !strings.Contains(help, w) {
t.Errorf("? help is missing a row for %q", w)
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package app
import (
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// ideacard.go renders the cross-session idea board (OverlayIdeas): the ideas the router
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -43,7 +42,7 @@ func TestIdeaRemoveSendsDiscardAndDropsRow(t *testing.T) {
m, client := ideaModel()
_ = client.Drain() // drop the ListIdeas frame from openIdeas
updated, _ := m.handleOverlayKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
updated, _ := m.handleOverlayKey(keyMsg{Type: keyRunes, Runes: []rune("x")})
m = updated.(Model)
if len(m.ideas) != 1 || m.ideas[0].IdeaID != "i2" {
@@ -0,0 +1,101 @@
package app
import "testing"
// History is a single global ring shared by every input surface (free-chat, the
// workflow-intent line, in-session chat), so ↑/↓ recalls anything you've sent — including
// outside a session, which the per-session map could not do.
func TestRecordInputGlobalRing(t *testing.T) {
m := NewModel(nil)
m.recordInput("first")
m.recordInput(" second ") // trimmed
m.recordInput("second") // immediate dup ignored
m.recordInput("") // blank ignored
m.recordInput(" ") // blank-after-trim ignored
want := []string{"first", "second"}
if len(m.inputHistory) != len(want) {
t.Fatalf("inputHistory = %q, want %q", m.inputHistory, want)
}
for i := range want {
if m.inputHistory[i] != want[i] {
t.Fatalf("inputHistory[%d] = %q, want %q", i, m.inputHistory[i], want[i])
}
}
}
func TestRecordInputCaps(t *testing.T) {
m := NewModel(nil)
for i := 0; i < 250; i++ {
m.recordInput(string(rune('a'+i%26)) + itoa(i))
}
if len(m.inputHistory) != 100 {
t.Fatalf("ring len = %d, want 100 (capped)", len(m.inputHistory))
}
}
// (itoa is defined in view.go and reused here for the cap test.)
// ↑/↓ walks the ring with no session selected (selectedID == ""), proving history
// recall works in free-chat / intent contexts, not just in-session.
func TestHistoryWalkWithoutSession(t *testing.T) {
m := NewModel(nil)
m.recordInput("one")
m.recordInput("two")
m.inputBuffer = "draft"
m.historyPrev() // newest first
if m.inputBuffer != "two" {
t.Fatalf("prev#1 = %q, want %q", m.inputBuffer, "two")
}
m.historyPrev()
if m.inputBuffer != "one" {
t.Fatalf("prev#2 = %q, want %q", m.inputBuffer, "one")
}
m.historyPrev() // clamp at oldest
if m.inputBuffer != "one" {
t.Fatalf("prev#3 (clamp) = %q, want %q", m.inputBuffer, "one")
}
m.historyNext()
if m.inputBuffer != "two" {
t.Fatalf("next#1 = %q, want %q", m.inputBuffer, "two")
}
m.historyNext() // back to the stashed live draft
if m.inputBuffer != "draft" {
t.Fatalf("next#2 = %q, want restored draft %q", m.inputBuffer, "draft")
}
}
// (animating + caps tests below.)
// animating() gates the redraw tick: it must be FALSE on an idle screen (normal mode,
// no active session) so terminal selection survives, and TRUE while typing or spinning.
func TestAnimatingGatesIdleRedraw(t *testing.T) {
m := NewModel(nil)
m.editMode = ModeNormal
if m.animating() {
t.Fatal("idle (normal mode, no active session) must NOT animate — else selection is wiped")
}
// Insert mode must NOT force the frame loop anymore: the composer caret is a real
// terminal cursor (View.Cursor) that the terminal blinks itself, so an idle insert
// screen holds still (and native text selection survives).
m.editMode = ModeInsert
if m.animating() {
t.Fatal("insert mode must NOT animate — the native cursor blinks itself")
}
m.editMode = ModeNormal
// Other typing surfaces still draw their own carets and keep ticking.
m.steering = true
if !m.animating() {
t.Fatal("steering (drawn caret) must animate")
}
m.steering = false
m.sessions = []Session{{ID: "s1", Active: true}}
if !m.animating() {
t.Fatal("an active session must animate (spinner)")
}
}
+123
View File
@@ -0,0 +1,123 @@
package app
import tea "charm.land/bubbletea/v2"
// bubbletea v2 collapsed key events into a single (Code rune, Mod KeyMod) form
// and dropped the v1 KeyType enum the handlers were written against. Rather than
// rewrite ~190 key-match sites, this shim adapts a v2 key press into the same
// v1-shaped struct (Type + Runes + modifier flags), so all the matching logic
// downstream stays untouched. The only place that ever touches the raw v2 key
// is toKeyMsg below, called once at the Update boundary.
type keyType int
const (
keyRunes keyType = iota
keyEnter
keyUp
keyDown
keyLeft
keyRight
keySpace
keyEsc
keyBackspace
keyTab
keyPgUp
keyPgDown
keyCtrlA
keyCtrlC
keyCtrlD
keyCtrlJ
keyCtrlR
keyCtrlU
keyCtrlX
keyCtrlUp
keyCtrlDown
keyOther
)
// keyMsg is the v1-shaped key event the handlers consume: a Type plus the typed
// Runes and modifier flags. Shift is newly meaningful under v2's kitty key
// disambiguation — it powers Shift+Enter newlines (see handleInsertKey).
type keyMsg struct {
Type keyType
Runes []rune
Alt bool
Shift bool
str string
}
func (k keyMsg) String() string { return k.str }
// toKeyMsg converts a bubbletea v2 key press into the shim's v1-shaped form.
func toKeyMsg(p tea.KeyPressMsg) keyMsg {
k := p.Key()
out := keyMsg{
Alt: k.Mod.Contains(tea.ModAlt),
Shift: k.Mod.Contains(tea.ModShift),
str: k.Keystroke(),
}
ctrl := k.Mod.Contains(tea.ModCtrl)
switch k.Code {
case tea.KeyEnter:
out.Type = keyEnter
case tea.KeyEscape:
out.Type = keyEsc
case tea.KeyTab:
out.Type = keyTab
case tea.KeyBackspace:
out.Type = keyBackspace
case tea.KeySpace:
out.Type = keySpace
out.Runes = []rune{' '}
case tea.KeyUp:
if ctrl {
out.Type = keyCtrlUp
} else {
out.Type = keyUp
}
case tea.KeyDown:
if ctrl {
out.Type = keyCtrlDown
} else {
out.Type = keyDown
}
case tea.KeyLeft:
out.Type = keyLeft
case tea.KeyRight:
out.Type = keyRight
case tea.KeyPgUp:
out.Type = keyPgUp
case tea.KeyPgDown:
out.Type = keyPgDown
default:
if ctrl {
switch k.Code {
case 'a':
out.Type = keyCtrlA
case 'c':
out.Type = keyCtrlC
case 'd':
out.Type = keyCtrlD
case 'j':
out.Type = keyCtrlJ
case 'r':
out.Type = keyCtrlR
case 'u':
out.Type = keyCtrlU
case 'x':
out.Type = keyCtrlX
default:
out.Type = keyOther
}
return out
}
if k.Text != "" {
out.Type = keyRunes
out.Runes = []rune(k.Text)
} else {
out.Type = keyOther
}
}
return out
}
+76
View File
@@ -0,0 +1,76 @@
package app
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
)
// defaultMarkdownWidth is the wrap width used when the caller doesn't yet know
// the terminal width (e.g. a zero/negative value). 80 columns is a safe terminal
// default that never blows up glamour's word-wrap.
const defaultMarkdownWidth = 80
// markdownRenderers memoizes one glamour renderer per wrap width. A glamour
// renderer bakes its word-wrap width in at construction, so we key the cache by
// width and build lazily. Constructing a renderer is comparatively expensive
// (it compiles a style + goldmark parser), hence the cache; the TUI renders the
// same handful of widths over its lifetime.
var (
markdownMu sync.Mutex
markdownRenderers = map[int]*glamour.TermRenderer{}
)
// rendererForWidth returns a cached dark-styled glamour renderer wrapped to w,
// or nil if glamour could not build one (caller falls back to plain text).
func rendererForWidth(w int) *glamour.TermRenderer {
markdownMu.Lock()
defer markdownMu.Unlock()
if r, ok := markdownRenderers[w]; ok {
return r
}
// Pin the "dark" standard style so output is deterministic and independent
// of the ambient terminal's background detection (important for tests and
// for headless/over-the-wire sessions).
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(w),
)
if err != nil {
// Cache the failure as nil so we don't retry-and-fail every frame.
markdownRenderers[w] = nil
return nil
}
markdownRenderers[w] = r
return r
}
// renderMarkdown renders content as terminal markdown (bold, italics, lists,
// headings, code blocks/inline code) word-wrapped to width, styled for a dark
// terminal. It is robust by construction: on any glamour error — or if the
// renderer can't be built — it returns the original plain content rather than
// crashing the TUI. Trailing whitespace glamour appends is trimmed so it doesn't
// disrupt the surrounding layout.
func renderMarkdown(content string, width int) string {
if width < 1 {
width = defaultMarkdownWidth
}
r := rendererForWidth(width)
if r == nil {
return content
}
out, err := r.Render(content)
if err != nil {
return content
}
// glamour pads block output with leading/trailing blank lines; strip them so
// the rendered turn slots into the feed without extra gaps.
out = strings.Trim(out, "\n")
if strings.TrimSpace(out) == "" {
// Nothing survived rendering (e.g. content was only whitespace) — prefer
// the original so callers never lose the underlying text.
return content
}
return out
}
+94
View File
@@ -0,0 +1,94 @@
package app
import (
"strings"
"testing"
)
// stripANSI removes CSI escape sequences so we can assert on the visible text
// glamour produced without depending on exact styling codes.
func stripANSI(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
j := i + 2
for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) {
j++
}
if j < len(s) {
j++ // consume the final byte
}
i = j
continue
}
b.WriteByte(s[i])
i++
}
return b.String()
}
// Rich markdown should be transformed (output differs from raw input) while the
// literal words survive the round-trip.
func TestRenderMarkdownTransformsAndPreservesText(t *testing.T) {
in := "# Heading\n\nSome **bold** and *italic* text.\n\n- first item\n- second item\n\n`inline code` and:\n\n```go\nfmt.Println(\"hi\")\n```\n"
out := renderMarkdown(in, 80)
if out == in {
t.Fatalf("markdown input should be rendered, got identical output")
}
plain := stripANSI(out)
for _, want := range []string{"Heading", "bold", "italic", "first item", "second item", "inline code", "fmt.Println"} {
if !strings.Contains(plain, want) {
t.Errorf("rendered output missing %q\n--- visible ---\n%s", want, plain)
}
}
}
// A plain, short string must round-trip to contain its text and not panic,
// regardless of how much (or how little) styling glamour adds.
func TestRenderMarkdownPlainStringRoundTrips(t *testing.T) {
in := "just a short plain sentence"
out := renderMarkdown(in, 80)
if !strings.Contains(stripANSI(out), in) {
t.Fatalf("plain string did not survive rendering: %q", stripANSI(out))
}
}
// Pathological / empty inputs must never panic and must return something
// containing the input text (trivially true for empty input).
func TestRenderMarkdownPathologicalDoesNotPanic(t *testing.T) {
cases := []string{
"",
" ",
"\n\n\n",
"```unterminated code fence\nstill going",
strings.Repeat("a", 10_000),
"[](()]][[**__",
}
for _, in := range cases {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("renderMarkdown panicked on %q: %v", in, r)
}
}()
out := renderMarkdown(in, 40)
// Empty/whitespace-only inputs fall back to the original content.
if strings.TrimSpace(in) == "" {
if out != in {
t.Errorf("whitespace input %q should fall back to itself, got %q", in, out)
}
}
}()
}
}
// A non-positive width must not crash and should fall back to the default
// wrap width rather than feeding glamour an invalid value.
func TestRenderMarkdownZeroWidthFallsBack(t *testing.T) {
out := renderMarkdown("**hello** world", 0)
if !strings.Contains(stripANSI(out), "hello") {
t.Fatalf("zero-width render dropped text: %q", stripANSI(out))
}
}
@@ -0,0 +1,39 @@
package app
import "testing"
// scrollModal must clamp the body offset of a tall fixed-content modal (help / stats) to
// [0, scrollableModalMax] so paging past either end lands exactly — the modal scrolls
// instead of clipping its bottom (incl. the close hint) off-screen.
func TestModalScrollClampsToBounds(t *testing.T) {
m := inSessionModel(120, 24) // short enough that the help cheat-sheet overflows
m.overlay = OverlayHelp
body := m.activeModalBody()
max := m.scrollableModalMax(len(body))
if max <= 0 {
t.Fatalf("help body (%d lines) must exceed the modal viewport (%d) to test scrolling", len(body), m.modalBodyRows())
}
m.scrollModal(10_000) // past the bottom
if m.modalScroll != max {
t.Fatalf("scroll past end clamped to %d, want max %d", m.modalScroll, max)
}
m.scrollModal(-10_000) // past the top
if m.modalScroll != 0 {
t.Fatalf("scroll past top clamped to %d, want 0", m.modalScroll)
}
}
// A modal that fits the screen never scrolls.
func TestModalScrollNoOpWhenFits(t *testing.T) {
m := inSessionModel(120, 60) // tall enough that help fits in full
m.overlay = OverlayHelp
if max := m.scrollableModalMax(len(m.activeModalBody())); max != 0 {
t.Fatalf("help should fit at height 60, got max scroll %d", max)
}
m.scrollModal(5)
if m.modalScroll != 0 {
t.Fatalf("a modal that fits should not scroll, got %d", m.modalScroll)
}
}
+202 -11
View File
@@ -1,6 +1,9 @@
package app
import (
"strconv"
"strings"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -54,12 +57,20 @@ const (
OverlayStats
OverlayIdeas
OverlayHealth
OverlaySessions
OverlayTasks
OverlayFiles
OverlayStatusbar
OverlayGrants
OverlayGrantScope
OverlayHelp
)
// RouterEntry is one line in a session's conversation transcript.
type RouterEntry struct {
Role string // user | router | tool | narration | narration_llm
Role string // user | router | tool | narration | narration_llm | action
Content string
Icon string // action role only: the gutter glyph (✓ ✎ ✗ ⌘ ✕ ⊞ ⊟)
Metrics *TurnMetrics
}
@@ -111,6 +122,27 @@ type Approval struct {
Rationale []string
}
// tierNum parses the numeric part of a tier label ("T3" → 3). Unparseable or
// empty tiers return -1, which is treated as low-risk (never requires confirm).
func tierNum(tier string) int {
t := strings.TrimSpace(strings.ToUpper(tier))
t = strings.TrimPrefix(t, "T")
if t == "" {
return -1
}
n, err := strconv.Atoi(t)
if err != nil {
return -1
}
return n
}
// HighTier reports whether this gate is destructive/high-risk (T3+), so an approve
// must be confirmed with a second keypress rather than acting on one keystroke.
func (a *Approval) HighTier() bool {
return tierNum(a.Tier) >= 3
}
// ClarQuestion is one open question a stage raised (mirrors the wire DTO).
type ClarQuestion struct {
ID string
@@ -159,10 +191,15 @@ type Session struct {
Tools []ToolRecord
ToolsByStage map[string][]ManifestTool
Events []EventEntry
Pending *Approval
Clar *Clarification // open questions awaiting answers (clarification view)
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
Active bool // an inference/tool is in flight (drives the spinner)
// Pending is the *current* approval gate shown in the band. It always mirrors
// PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can
// keep reading a single pointer while multiple gates queue up behind it.
Pending *Approval
PendingQueue []*Approval // all pending gates, oldest first; navigated with ↑/↓
PendingIdx int // selected index into PendingQueue
Clar *Clarification // open questions awaiting answers (clarification view)
Propose *Proposal // router workflow suggestion awaiting a pick (propose view)
Active bool // an inference/tool is in flight (drives the spinner)
}
// Workflow is a launchable workflow advertised by the server.
@@ -192,16 +229,21 @@ type Model struct {
wfVisible bool
wfPendingID string // workflow chosen, awaiting an intent line before StartSession
wfPendingName string
bgUpdates int
// launcher (idle screen): the active "what to launch" selection — 0 = chat, 1..N =
// workflows[i-1] — cycled with Tab and shown at the input's lower-right. railHidden
// folds away the idle status/quick-keys rail.
launcherWf int
railHidden bool
bgUpdates int
// input
editMode EditMode
inputMode InputMode
inputBuffer string
inputCursor int
history map[string][]string
historyIndex int
savedBuffer string
inputHistory []string // submitted lines across all contexts (chat, intent, in-session), newest last
historyIndex int // -1 = editing the live buffer; else an index into inputHistory
savedBuffer string // live buffer stashed while walking history
// flow flags
sessionEntered bool
@@ -212,6 +254,11 @@ type Model struct {
routerMessages map[string][]RouterEntry
routerConnected bool
chatMode string
// transcriptSel is the index (into the selected session's transcript) of the message
// the operator has navigated to with ctrl+↑/↓; -1 = no selection (tail-follow). A
// selected message is highlighted, scrolled into view, and is what `y` copies.
transcriptSel int
copiedFlash int // frame at which a copy happened, for a brief "copied" footer note
// provider
currentModel string
@@ -231,7 +278,11 @@ type Model struct {
overlay OverlayKind
overlayEventIdx int
diffScrollOffset int
modalScroll int // body scroll for tall fixed-content modals (help, stats)
eventStripShown bool
// in-session right panel: 0 = events, 1 = changes (token usage + changed files),
// 2 = off (output full-width). Cycled with `d`.
rightPanel int
// artifact viewer (OverlayArtifacts) — populated by the artifact.list response
artifacts []protocol.ArtifactDto
@@ -264,20 +315,85 @@ type Model struct {
health *protocol.HealthDto
healthLoading 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
// task board (OverlayTasks) — all tasks across projects, fetched over HTTP from
// GET /tasks. taskDetail toggles the per-task detail pane (built from the list payload).
taskList []TaskSummary
taskListIndex int
taskListLoading bool
taskListErr string
taskDetail bool
taskDetailScroll int
taskFilter string // `/` substring narrow over id/title/goal
taskFilterTyping bool
// command palette
paletteFilter string
paletteIndex int
// @ file picker (OverlayFiles) — workspace paths from the file.list reply
files []string // all workspace-relative paths for filesFor
filesFor string // sessionId the file list belongs to
filesLoading bool
fileFilter string // the query typed after @ (narrows the list)
fileIndex int
// status-bar segment visibility (OverlayStatusbar) — persisted TUI-local in tui-prefs.json.
// A segment id present in sbHidden is hidden; absent = shown. sbIndex is the toggle cursor.
sbHidden map[string]bool
sbIndex int
// actionsHidden mutes the inline action rows (tool calls / writes / approvals / grants) in
// the OUTPUT transcript; they always stay in the EVENTS panel. Persisted in tui-prefs.json.
actionsHidden bool
// outputScroll is how many rows up from the bottom the OUTPUT transcript is scrolled
// (0 = tail-follow the newest output). PgUp/PgDn + ctrl+u/d move it; esc snaps back.
outputScroll int
// event-inspector filter (OverlayEventInspector): narrows the event list by a substring
// of type/detail. eventFilterTyping is true while the operator is editing the query after /.
eventFilter string
eventFilterTyping bool
// standing grants (OverlayGrants) — active PROJECT/GLOBAL grants from the grant.list reply.
grants []protocol.GrantDto
grantsLoading bool
grantIndex int
// grant scope picker (OverlayGrantScope) — chosen when the operator presses A on an approval.
// grantScopeIndex selects session/project/global; grantFor holds the approval being widened.
grantScopeIndex int
grantFor *Approval
// animation
frame int // tick counter; drives spinner + caret blink
frame int // tick counter; drives spinner + caret blink
ticking bool // true while a tick loop is scheduled. The loop is gated on animating()
// so an idle screen stops redrawing — which lets native terminal selection survive
// (the 120ms redraw used to wipe a mouse drag every frame).
// snapshot phase
snapshotPhase bool
pendingEvents []protocol.ServerMessage
// lastBase is the full-screen render behind the active modal, stashed by View each
// frame so center() can composite a modal over a dimmed copy of it (transparent
// backdrop) instead of an opaque scrim. Transient render scratch — not real state.
lastBase string
// approval steering input buffer
steerBuffer string
steering bool
// approvalArmed is set while a high-tier (T3+) approve is awaiting its second
// confirming keypress — the destructive-action safety. Any non-confirm key
// disarms it; a confirming `y`/`a`/enter sends the decision.
approvalArmed bool
// clarification view (the interactive question form)
clarFocus int // focused question index
@@ -301,7 +417,6 @@ func NewModel(client *ws.Client) Model {
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
history: map[string][]string{},
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
@@ -309,6 +424,9 @@ func NewModel(client *ws.Client) Model {
providerType: "LOCAL",
snapshotPhase: true,
eventStripShown: true,
transcriptSel: -1,
sbHidden: loadStatusbarHidden(),
actionsHidden: loadPrefs().InlineActionsHidden,
}
}
@@ -348,6 +466,79 @@ func (m Model) session(id string) *Session {
return nil
}
// syncPending re-points Pending at the selected queue entry, clamping the index
// so it stays in range as the queue grows and shrinks. Called after any queue
// mutation; Pending is nil exactly when the queue is empty.
func (s *Session) syncPending() {
if len(s.PendingQueue) == 0 {
s.PendingQueue = nil
s.PendingIdx = 0
s.Pending = nil
return
}
if s.PendingIdx < 0 {
s.PendingIdx = 0
}
if s.PendingIdx >= len(s.PendingQueue) {
s.PendingIdx = len(s.PendingQueue) - 1
}
s.Pending = s.PendingQueue[s.PendingIdx]
}
// enqueueApproval adds a gate to the queue (replacing one with the same request
// id, so a re-sent gate doesn't duplicate). The freshly-added gate is left where
// it is in the order; the current selection is preserved.
func (s *Session) enqueueApproval(a *Approval) {
for i, p := range s.PendingQueue {
if p.RequestID == a.RequestID {
s.PendingQueue[i] = a
s.syncPending()
return
}
}
s.PendingQueue = append(s.PendingQueue, a)
s.syncPending()
}
// removeApproval drops the gate with requestID and advances the selection. If the
// removed entry was before the cursor the index shifts down to stay on the same
// gate; if it was the selected one the cursor stays put (now showing the next gate).
func (s *Session) removeApproval(requestID string) {
idx := -1
for i, p := range s.PendingQueue {
if p.RequestID == requestID {
idx = i
break
}
}
if idx < 0 {
return
}
s.PendingQueue = append(s.PendingQueue[:idx], s.PendingQueue[idx+1:]...)
if idx < s.PendingIdx {
s.PendingIdx--
}
s.syncPending()
}
// clearApprovals empties the queue (used on resume/completion, where the server
// invalidates every gate at once).
func (s *Session) clearApprovals() {
s.PendingQueue = nil
s.PendingIdx = 0
s.Pending = nil
}
// navApproval moves the queue selection by dir (wrapping) and re-syncs Pending.
func (s *Session) navApproval(dir int) {
n := len(s.PendingQueue)
if n <= 1 {
return
}
s.PendingIdx = (s.PendingIdx + dir + n) % n
s.syncPending()
}
// ensureSession returns the session with id, creating an ACTIVE entry if absent.
// Session existence is derived from the event stream (any event for an unknown
// session vivifies it) rather than a dedicated control frame.
@@ -0,0 +1,41 @@
package app
import "testing"
// scrollOutput must clamp to [0, totalRows-panelHeight] against the same geometry the renderer
// windows to — so PgUp past the top and PgDn past the bottom land exactly on the edges (no
// dead presses unwinding an overshoot).
func TestOutputScrollClampsToBounds(t *testing.T) {
m := inSessionModel(120, 30)
msgs := make([]RouterEntry, 0, 60)
for i := 0; i < 60; i++ {
msgs = append(msgs, RouterEntry{Role: "router", Content: "a transcript line"})
}
m.routerMessages[m.selectedID] = msgs
w, h := m.outputViewport()
rows, _ := m.buildTranscriptRows(w)
max := len(rows) - h
if max <= 0 {
t.Fatalf("fixture transcript (%d rows) must exceed panel height (%d) to test scrolling", len(rows), h)
}
m.scrollOutput(10_000) // way past the top
if m.outputScroll != max {
t.Fatalf("scroll up clamped to %d, want max %d", m.outputScroll, max)
}
m.scrollOutput(-10_000) // way past the bottom
if m.outputScroll != 0 {
t.Fatalf("scroll down clamped to %d, want 0 (tail-follow)", m.outputScroll)
}
}
// A transcript that fits the panel never scrolls (stays tail-following).
func TestOutputScrollNoOpWhenFits(t *testing.T) {
m := inSessionModel(120, 30)
m.routerMessages[m.selectedID] = []RouterEntry{{Role: "router", Content: "one line"}}
m.scrollOutput(5)
if m.outputScroll != 0 {
t.Fatalf("short transcript should not scroll, got %d", m.outputScroll)
}
}
@@ -0,0 +1,52 @@
package app
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
func fullScreen(ch string, w, h int) string {
row := strings.Repeat(ch, w)
rows := make([]string, h)
for i := range rows {
rows[i] = row
}
return strings.Join(rows, "\n")
}
// A composited modal must still fill the whole screen exactly (h lines, each w cols),
// so the alt-screen never shows a torn/short region around a "transparent" modal.
func TestCompositeOverlayGeometry(t *testing.T) {
m := NewModel(nil)
m.width, m.height = 40, 12
m.lastBase = fullScreen("x", 40, 12)
modal := lipgloss.NewStyle().Width(10).Border(lipgloss.RoundedBorder()).Render("hi\nthere")
out := m.center(modal)
lines := strings.Split(out, "\n")
if len(lines) != 12 {
t.Fatalf("height = %d, want 12", len(lines))
}
for i, ln := range lines {
if w := ansi.StringWidth(ln); w != 40 {
t.Fatalf("line %d visible width = %d, want 40", i, w)
}
}
}
// The idempotence guard: a modal that is already full-screen (a builder that centered
// internally, then got re-centered) must pass through unchanged — not get re-dimmed.
func TestCenterIdempotentOnFullScreen(t *testing.T) {
m := NewModel(nil)
m.width, m.height = 30, 8
m.lastBase = fullScreen("y", 30, 8)
full := fullScreen("M", 30, 8)
if got := m.center(full); got != full {
t.Fatal("center should pass a full-screen modal through unchanged")
}
}
+502 -39
View File
@@ -2,20 +2,82 @@ package app
import (
"fmt"
"image/color"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
// center composites a modal over a scrim-filled screen. Immediate-mode: the modal
// is recomputed from state every frame, so there is no separate show/restore path
// to desync (the "diff won't come back" bug class can't occur here).
// center draws a modal centered over a *dimmed* copy of the screen behind it, so the
// transcript stays faintly visible around the modal (a transparent backdrop) rather
// than being hidden by an opaque scrim. Immediate-mode: recomputed from state every
// frame, so there is no show/restore path to desync.
//
// Idempotence guard: some modal builders call center() internally and then get centered
// again by renderOverlay. The inner call already produced a full-screen composite, so a
// second call (input already width×height) returns it unchanged instead of dimming the
// modal itself. Falls back to an opaque scrim when no base was stashed (defensive).
func (m Model) center(modal string) string {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep),
lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep))
if m.lastBase == "" {
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
Background(m.theme.P.BgDeep).Foreground(m.theme.P.BgDeep)))
}
if lipgloss.Height(modal) >= m.height && lipgloss.Width(modal) >= m.width {
return modal // already a full-screen composite; don't re-dim it
}
return m.compositeOverlay(m.lastBase, modal)
}
// compositeOverlay strips + dims base into a faint scrim, then splices the modal box
// over it centered. ANSI-aware so the side margins keep the (dimmed) content behind.
// base and the result are both m.width × m.height.
func (m Model) compositeOverlay(base, modal string) string {
w, h := m.width, m.height
dim := lipgloss.NewStyle().Foreground(m.theme.P.Faint).Background(m.theme.P.BgDeep)
baseLines := strings.Split(base, "\n")
scrim := make([]string, h)
for i := 0; i < h; i++ {
raw := ""
if i < len(baseLines) {
raw = ansi.Strip(baseLines[i])
}
scrim[i] = dim.Render(padRightRaw(raw, w))
}
modalLines := strings.Split(modal, "\n")
mw := lipgloss.Width(modal)
top := (h - len(modalLines)) / 2
left := (w - mw) / 2
if top < 0 {
top = 0
}
if left < 0 {
left = 0
}
for r, ml := range modalLines {
row := top + r
if row < 0 || row >= h {
continue
}
leftPart := ansi.Truncate(scrim[row], left, "")
rightPart := ansi.TruncateLeft(scrim[row], left+mw, "")
scrim[row] = leftPart + ml + rightPart
}
return strings.Join(scrim, "\n")
}
// padRightRaw pads an unstyled string with spaces to exactly w columns (or truncates).
func padRightRaw(s string, w int) string {
switch sw := ansi.StringWidth(s); {
case sw > w:
return ansi.Truncate(s, w, "")
case sw < w:
return s + strings.Repeat(" ", w-sw)
default:
return s
}
}
func (m Model) modalWidth() int {
@@ -63,6 +125,20 @@ func (m Model) renderOverlay(base string) string {
return m.center(m.ideasModal())
case OverlayHealth:
return m.center(m.healthModal())
case OverlaySessions:
return m.center(m.sessionsModal())
case OverlayTasks:
return m.center(m.tasksModal())
case OverlayFiles:
return m.center(m.filesModal())
case OverlayStatusbar:
return m.center(m.statusbarModal())
case OverlayGrants:
return m.center(m.grantsModal())
case OverlayGrantScope:
return m.center(m.grantScopeModal())
case OverlayHelp:
return m.center(m.helpModal())
}
return base
}
@@ -72,7 +148,7 @@ func (m Model) titleLine(text string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text)
}
func mbg(t Theme, s string, fg lipgloss.Color) string {
func mbg(t Theme, s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s)
}
@@ -132,6 +208,13 @@ func (m Model) renderApprovalBand(h int) string {
rightHdr := t.span("tier ", t.P.Faint) + t.span(a.Tier, t.P.Accent2) +
t.span(" · risk ", t.P.Faint) +
lipgloss.NewStyle().Foreground(riskColor).Background(t.P.Bg).Bold(true).Render(strings.ToUpper(a.Risk))
// When more than one gate is queued, show "approval i/n" so the operator knows
// there are others behind this one (↑/↓ to walk them) — never stack modals.
if n := len(s.PendingQueue); n > 1 {
rightHdr = t.span("approval ", t.P.Faint) +
t.span(itoa(s.PendingIdx+1)+"/"+itoa(n), t.P.Accent) +
t.span(" · ", t.P.Faint) + rightHdr
}
innerH := h - 2
ratBlock := 0
@@ -218,8 +301,19 @@ func (m Model) approvalActions() string {
hint("enter", "approve + send note"), hint("esc", "keep note, decide below"),
}, gap)
}
parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject")}
if s := m.session(m.selectedID); s != nil && s.Pending != nil &&
s := m.session(m.selectedID)
// Armed T3+ approve: a single decisive line so an accidental keystroke can't slip
// a destructive action through — the confirm key must be pressed again.
if m.approvalArmed && s != nil && s.Pending != nil {
warn := lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true).
Render("press y again to confirm " + s.Pending.Tier + " approval")
return warn + gap + hint("any other key", "cancel")
}
parts := []string{hint("y", "approve"), hint("n", "reject"), hint("e", "steer"), hint("A", "auto")}
if s != nil && len(s.PendingQueue) > 1 {
parts = append(parts, hint("↑↓", "queue"))
}
if s != nil && s.Pending != nil &&
(isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) {
parts = append(parts, hint("^x", "fullscreen"))
}
@@ -264,6 +358,20 @@ func (m Model) diffMaxScroll() int {
return max
}
// scrollDiff moves the diff/preview/command modal offset by delta rows, clamped to
// [0, diffMaxScroll] so paging past either end lands exactly on the edge (no dead
// presses unwinding an overshoot — same contract as the OUTPUT free-scroll).
func (m *Model) scrollDiff(delta int) {
off := m.diffScrollOffset + delta
if mx := m.diffMaxScroll(); off > mx {
off = mx
}
if off < 0 {
off = 0
}
m.diffScrollOffset = off
}
func (m Model) diffModal() string {
if cmd := m.pendingCommand(); cmd != nil {
return m.commandModal(cmd)
@@ -301,7 +409,7 @@ func (m Model) diffModal() string {
for _, ln := range lines {
b.WriteString(ln + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}}))
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
@@ -335,7 +443,7 @@ func (m Model) commandModal(a *Approval) string {
for i := off; i < end; i++ {
b.WriteString(lines[i] + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}}))
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "line"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"^x/esc", "close"}}))
return m.center(t.Overlay.Width(w).Render(b.String()))
}
@@ -350,8 +458,25 @@ func (m Model) eventInspectorModal() string {
b.WriteString(mbg(t, " ("+itoa(m.overlayEventIdx+1)+"/"+itoa(len(evs))+")", t.P.Faint))
}
b.WriteString("\n\n")
// Filter line (shown while editing or when a query is set): a `/` prompt over the query.
if m.eventFilterTyping || m.eventFilter != "" {
caret := mbg(t, "", t.P.BgPanel)
if m.eventFilterTyping && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
}
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ")
if m.eventFilter == "" {
b.WriteString(prompt + caret + mbg(t, "type to filter events…", t.P.Faint) + "\n\n")
} else {
b.WriteString(prompt + mbg(t, m.eventFilter, t.P.FgStrong) + caret + "\n\n")
}
}
if len(evs) == 0 {
b.WriteString(mbg(t, "no events", t.P.Faint))
msg := "no events"
if m.eventFilter != "" {
msg = "no events match \"" + m.eventFilter + "\""
}
b.WriteString(mbg(t, msg, 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
@@ -395,7 +520,7 @@ func (m Model) eventInspectorModal() string {
mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim)
b.WriteString(row + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"esc", "close"}}))
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"/", "filter"}, {"c", "clear"}, {"esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
@@ -432,8 +557,13 @@ func (m Model) paletteModal() string {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
titleFg = t.P.FgStrong
}
b.WriteString(marker +
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) +
// keybind column: the bare-key shortcut, so the palette teaches the shortcuts.
keyCell := mbg(t, padRaw("", 4), t.P.BgPanel)
if c.key != "" {
keyCell = lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(padRaw(c.key, 4))
}
b.WriteString(marker + keyCell +
lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 16)) +
mbg(t, " "+c.hint, t.P.Faint) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}}))
@@ -441,6 +571,312 @@ func (m Model) paletteModal() string {
return m.center(modal)
}
// filesModal is the `@` file-reference picker: a filter line over the session workspace's
// paths, windowed around the selection; enter inserts `@path` into the chat input.
func (m Model) filesModal() string {
t := m.theme
w := m.modalWidth()
files := m.filteredFiles()
var b strings.Builder
b.WriteString(m.titleLine("@ file reference") + "\n\n")
caret := mbg(t, " ", t.P.BgPanel)
if m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
}
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("@ ")
if m.fileFilter == "" {
b.WriteString(prompt + caret + mbg(t, "type to filter files…", t.P.Faint) + "\n\n")
} else {
b.WriteString(prompt + mbg(t, m.fileFilter, t.P.FgStrong) + caret + "\n\n")
}
switch {
case m.filesLoading:
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
case len(m.files) == 0:
b.WriteString(mbg(t, " no workspace files (is a workspace bound?)", t.P.Faint) + "\n")
case len(files) == 0:
b.WriteString(mbg(t, " no matching files", t.P.Faint) + "\n")
default:
const maxRows = 12
start := 0
if m.fileIndex >= maxRows {
start = m.fileIndex - maxRows + 1
}
end := start + maxRows
if end > len(files) {
end = len(files)
}
for i := start; i < end; i++ {
marker := mbg(t, " ", t.P.BgPanel)
fg := t.P.Fg
if i == m.fileIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
fg = t.P.FgStrong
}
b.WriteString(marker +
lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(files[i], w-6)) + "\n")
}
if len(files) > maxRows {
b.WriteString(mbg(t, " "+itoa(len(files))+" matches", t.P.Faint) + "\n")
}
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "insert"}, {"esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// statusbarModal toggles which status-bar segments are shown. Choices persist to the local
// tui-prefs.json so the bar stays as configured across launches.
func (m Model) statusbarModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("status bar") + "\n\n")
b.WriteString(mbg(t, " show / hide segments (correx, connection, session name stay)", t.P.Faint) + "\n\n")
for i, seg := range statusSegments {
marker := mbg(t, " ", t.P.BgPanel)
labelFg := t.P.Fg
if i == m.sbIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
labelFg = t.P.FgStrong
}
box := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render("[✓] ")
if !m.sbShow(seg.id) {
box = mbg(t, "[ ] ", t.P.Faint)
}
b.WriteString(marker + box +
lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(seg.label) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"space", "toggle"}, {"esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// grantsModal lists the active standing (PROJECT/GLOBAL) grants and lets the operator revoke one.
// These are cross-session auto-approvals, so the board opens from anywhere.
func (m Model) grantsModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("standing grants") + "\n\n")
b.WriteString(mbg(t, " cross-session auto-approvals in force (tool-bound)", t.P.Faint) + "\n\n")
switch {
case m.grantsLoading:
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
case len(m.grants) == 0:
b.WriteString(mbg(t, " none — press A on an approval to grant project / global", t.P.Faint) + "\n")
default:
for i, g := range m.grants {
marker := mbg(t, " ", t.P.BgPanel)
fg := t.P.Fg
if i == m.grantIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
fg = t.P.FgStrong
}
scope := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(g.Scope, 8))
tool := lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(g.ToolName, 16))
tiers := mbg(t, "≤"+strings.Join(g.Tiers, ","), t.P.Faint)
b.WriteString(marker + scope + tool + tiers)
if g.Scope == "PROJECT" && g.ProjectID != "" {
b.WriteString(mbg(t, " "+clip(g.ProjectID, w-52), t.P.Faint))
}
b.WriteString("\n")
}
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"x", "revoke"}, {"G/esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// grantScopeModal is the A (approve-always) breadth picker: choose how widely to stop being asked
// for the pending tool — this session, this project, or everywhere.
func (m Model) grantScopeModal() string {
t := m.theme
w := m.modalWidth()
tool := ""
if m.grantFor != nil {
tool = m.grantFor.ToolName
}
var b strings.Builder
b.WriteString(m.titleLine("approve always — choose scope") + "\n\n")
b.WriteString(mbg(t, " stop asking for ", t.P.Faint) +
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(tool) + "\n\n")
for i, opt := range grantScopeOptions() {
marker := mbg(t, " ", t.P.BgPanel)
labelFg := t.P.Fg
if i == m.grantScopeIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
labelFg = t.P.FgStrong
}
key := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Render(padRaw(opt.key, 3))
title := lipgloss.NewStyle().Foreground(labelFg).Background(t.P.BgPanel).Render(padRaw(opt.title, 16))
b.WriteString(marker + key + title + mbg(t, opt.hint, t.P.Faint) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "move"}, {"enter", "grant"}, {"esc", "cancel"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
// helpModal is the keybinding cheat-sheet (?), grouped by context. Any key dismisses it.
// modalBodyRows is how many body lines a scrollable fixed-content modal shows: the
// screen height minus the modal chrome (border 2 + padding 2 + title block 2 + hint
// block 2). Sized so a modal that fits shows in full and a taller one scrolls rather
// than clipping its bottom off-screen (the old compositeOverlay behaviour).
func (m Model) modalBodyRows() int {
if r := m.height - 8; r > 3 {
return r
}
return 3
}
// scrollableModalMax is the largest scroll offset for a body of n lines, anchoring the
// last page to the bottom.
func (m Model) scrollableModalMax(n int) int {
if max := n - m.modalBodyRows(); max > 0 {
return max
}
return 0
}
// activeModalBody returns the body lines of whichever scrollable fixed-content modal is
// open, so the key handler can clamp m.modalScroll against the same content it renders.
func (m Model) activeModalBody() []string {
switch m.overlay {
case OverlayHelp:
return m.helpBody()
case OverlayStats:
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
return nil
}
return m.statsBody()
}
return nil
}
// scrollModal moves the active fixed-content modal's body offset by delta lines, clamped
// to [0, scrollableModalMax] — same contract as the diff/output scrollers.
func (m *Model) scrollModal(delta int) {
max := m.scrollableModalMax(len(m.activeModalBody()))
off := m.modalScroll + delta
if off > max {
off = max
}
if off < 0 {
off = 0
}
m.modalScroll = off
}
// renderScrollModal lays out a title + scrollable body + pinned hint inside the modal box,
// windowing body by m.modalScroll so the box never exceeds the screen. A position indicator
// appears in the title row whenever the body is taller than the viewport.
func (m Model) renderScrollModal(title, titleSuffix string, body []string, hint [][2]string) string {
t := m.theme
w := m.modalWidth()
rows := m.modalBodyRows()
off := m.modalScroll
if mx := m.scrollableModalMax(len(body)); off > mx {
off = mx
}
if off < 0 {
off = 0
}
end := off + rows
if end > len(body) {
end = len(body)
}
var b strings.Builder
b.WriteString(m.titleLine(title))
if titleSuffix != "" {
b.WriteString(mbg(t, titleSuffix, t.P.Faint))
}
if len(body) > rows {
b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(end)+"/"+itoa(len(body))+")", t.P.Faint))
}
b.WriteString("\n\n")
for i := off; i < end; i++ {
b.WriteString(body[i] + "\n")
}
b.WriteString("\n" + modalHints(t, hint))
return t.Overlay.Width(w).Render(b.String())
}
// helpBody is the keybinding cheat-sheet as discrete lines (so renderScrollModal can window it).
func (m Model) helpBody() []string {
t := m.theme
type kb struct{ key, desc string }
var out []string
section := func(title string, rows []kb) {
out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(title))
for _, r := range rows {
key := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(padRaw(r.key, 16))
out = append(out, " "+key+mbg(t, r.desc, t.P.Fg))
}
out = append(out, "")
}
section("navigate", []kb{
{"↑↓ / j k", "move the session / list selection"},
{"enter", "open the selected session"},
{"Tab / w", "idle: cycle launch target (chat / workflow)"},
{"/", "filter the session list"},
{"^↑ / ^↓", "jump to your previous / next message"},
{"PgUp / PgDn", "scroll output (^u / ^d half-page)"},
{"esc", "drop selection / scroll → follow newest"},
{"l", "back to the session list"},
})
section("session", []kb{
{"i", "compose a message"},
{"d", "right panel: events / changes / off"},
{"s", "toggle chat / steering mode"},
{"w", "show workflows (idle screen)"},
{"y", "copy the selected message (OSC52)"},
{"^x", "open the latest diff / preview"},
{"c", "cancel the running session"},
{"a", "re-open a dismissed approval gate"},
{"q", "quit"},
})
section("overlays", []kb{
{"p", "command palette (all shortcuts)"},
{"?", "this keybindings help"},
{"e", "event inspector (/ to filter)"},
{"t / v", "tools / artifacts"},
{"S / R", "session stats / resume past sessions"},
{"T", "task board (across projects)"},
{"m / g", "swap model / edit config"},
{"G / I", "grants / idea board"},
})
section("approval band", []kb{
{"y / a / enter", "approve once (T3+ asks again to confirm)"},
{"n / r", "reject"},
{"e / s", "steer (add a note)"},
{"A", "approve-always — choose scope"},
{"↑ / ↓", "walk the pending queue"},
{"^x", "fullscreen the diff / command preview"},
{"esc", "dismiss for now (a re-opens)"},
})
section("diff / preview viewer (^x)", []kb{
{"↑ / ↓", "scroll a line"},
{"PgUp / PgDn", "scroll a page (^u / ^d half)"},
{"g / G", "jump to top / end"},
{"^x / esc", "close"},
})
return out
}
func (m Model) helpModal() string {
return m.renderScrollModal("keybindings", "", m.helpBody(),
[][2]string{{"↑↓", "scroll"}, {"esc", "close"}})
}
func (m Model) toolPaletteModal() string {
t := m.theme
w := m.modalWidth()
@@ -550,6 +986,20 @@ func (m Model) artifactContentMaxScroll(w int) int {
return max
}
// scrollArtifact moves the selected artifact's content offset by delta lines, clamped to
// the scrollable range (same contract as the diff/output scrollers).
func (m *Model) scrollArtifact(delta int) {
max := m.artifactContentMaxScroll(m.modalWidth() - 6)
off := m.artifactScroll + delta
if off > max {
off = max
}
if off < 0 {
off = 0
}
m.artifactScroll = off
}
func (m Model) artifactsModal() string {
t := m.theme
w := m.modalWidth()
@@ -628,7 +1078,7 @@ func (m Model) artifactsModal() string {
b.WriteString(mbg(t, " "+lines[i], t.P.Fg) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "scroll"}, {"v/esc", "close"}}))
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"PgUp/Dn", "page"}, {"g/G", "ends"}, {"v/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
@@ -660,14 +1110,14 @@ func (m Model) statsModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("session stats"))
if m.selectedID != "" {
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
}
b.WriteString("\n\n")
// Loading / empty: a small fixed modal, no scroll needed.
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
var b strings.Builder
b.WriteString(m.titleLine("session stats"))
if m.selectedID != "" {
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
}
b.WriteString("\n\n")
msg := "loading…"
if !m.statsLoading && m.stats == nil {
msg = "no stats for this session"
@@ -677,15 +1127,29 @@ func (m Model) statsModal() string {
return t.Overlay.Width(w).Render(b.String())
}
suffix := ""
if m.selectedID != "" {
suffix = " (" + shortID(m.selectedID) + ")"
}
return m.renderScrollModal("session stats", suffix, m.statsBody(),
[][2]string{{"↑↓", "scroll"}, {"S/esc", "close"}})
}
// statsBody is the session-stats report as discrete lines (so renderScrollModal can window it).
// Callers must ensure m.stats is loaded for the selected session.
func (m Model) statsBody() []string {
t := m.theme
s := m.stats
cw := w - 4 // modal inner content width (borders + padding)
cw := m.modalWidth() - 4 // modal inner content width (borders + padding)
var out []string
section := func(label string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
}
// put clips each data line to the inner width so it never wraps on a slim modal.
put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") }
put := func(styled string) { out = append(out, clip(styled, cw)) }
line := func(s string) string { return mbg(t, s, t.P.Fg) }
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
blank := func() { out = append(out, "") }
// nameW shrinks the breakdown name column on slim terminals.
nameW := 20
if cw < 52 {
@@ -695,10 +1159,10 @@ func (m Model) statsModal() string {
// header row: duration + event count
put(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
faint(" events ") + line(itoa(int(s.EventCount))))
b.WriteString("\n")
blank()
// inference
b.WriteString(section("Inference") + "\n")
out = append(out, section("Inference"))
put(line(fmt.Sprintf(" %d calls · %d+%d tok · %.1f tok/s",
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.TokensPerSecond)))
for i, p := range s.PerProvider {
@@ -709,10 +1173,10 @@ func (m Model) statsModal() string {
put(faint(fmt.Sprintf(" %-*s %d× %d tok %.1f t/s",
nameW, padRaw(p.Provider, nameW), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TokensPerSecond)))
}
b.WriteString("\n")
blank()
// tools
b.WriteString(section("Tools") + "\n")
out = append(out, section("Tools"))
put(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)))
for i, tl := range s.PerTool {
if i >= statsBreakdownRows {
@@ -721,35 +1185,34 @@ func (m Model) statsModal() string {
}
put(faint(fmt.Sprintf(" %-*s %d× %d ms", nameW, padRaw(tl.ToolName, nameW), tl.CompletedCount, tl.TotalDurationMs)))
}
b.WriteString("\n")
blank()
// approvals
b.WriteString(section("Approvals") + "\n")
out = append(out, section("Approvals"))
put(line(fmt.Sprintf(" %d req · %d res · %d pending · avg %d ms",
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)))
for _, tr := range s.PerTier {
put(faint(fmt.Sprintf(" %-3s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)))
}
b.WriteString("\n")
blank()
// failures
f := s.Failures
b.WriteString(section("Failures") + "\n")
out = append(out, section("Failures"))
put(line(fmt.Sprintf(" inf %d · t/o %d · tool %d · rej %d · stg %d · wf %d",
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)))
b.WriteString("\n")
blank()
// time accounting
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
if other < 0 {
other = 0
}
b.WriteString(section("Time") + "\n")
out = append(out, section("Time"))
put(line(fmt.Sprintf(" inf %.1f%% · tools %.1f%% · wait %.1f%% · other %.1f%%",
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)))
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
return out
}
func (m Model) healthModal() string {
@@ -820,7 +1283,7 @@ func shortID(id string) string {
return id[:13] + "…"
}
func tierColor(t Theme, tier int) lipgloss.Color {
func tierColor(t Theme, tier int) color.Color {
switch {
case tier >= 3:
return t.P.Bad
+99
View File
@@ -0,0 +1,99 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
)
// prefs is the TUI's local, persisted preferences (display-only client state — kept out
// of the server config, which is shared/replayed). Stored as JSON in the correx config dir.
type prefs struct {
StatusbarHidden []string `json:"statusbarHidden"`
InlineActionsHidden bool `json:"inlineActionsHidden"`
}
// statusSegment is one toggleable status-bar segment. The always-on segments (correx label,
// connection, session name, spinner, background-updates) are not listed — they are load-bearing.
type statusSegment struct{ id, label string }
var statusSegments = []statusSegment{
{"stage", "current stage (⟐)"},
{"status", "session status"},
{"workspace", "workspace path (⌂)"},
{"model", "model name"},
{"clock", "last-event clock"},
{"gauge", "resource gauge"},
}
func prefsPath() string {
dir := os.Getenv("CORREX_CONFIG_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
dir = filepath.Join(home, ".config", "correx")
}
return filepath.Join(dir, "tui-prefs.json")
}
// loadPrefs reads the whole prefs file (best-effort: a missing or corrupt file yields the
// zero value, i.e. everything shown / actions visible).
func loadPrefs() prefs {
var p prefs
path := prefsPath()
if path == "" {
return p
}
if data, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(data, &p)
}
return p
}
// savePrefs writes the whole prefs file (best-effort; ignores IO errors so a read-only home
// never crashes the UI). Callers load, mutate one field, then save so nothing is clobbered.
func savePrefs(p prefs) {
path := prefsPath()
if path == "" {
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
if data, err := json.MarshalIndent(p, "", " "); err == nil {
_ = os.WriteFile(path, data, 0o644)
}
}
// loadStatusbarHidden reads the persisted hidden-segment set (best-effort: a missing or
// corrupt file yields an empty set, i.e. everything shown).
func loadStatusbarHidden() map[string]bool {
out := map[string]bool{}
for _, id := range loadPrefs().StatusbarHidden {
out[id] = true
}
return out
}
// saveStatusbarHidden writes the hidden set back to disk, preserving the other prefs fields.
// Order follows statusSegments for a stable file.
func saveStatusbarHidden(hidden map[string]bool) {
var ids []string
for _, seg := range statusSegments {
if hidden[seg.id] {
ids = append(ids, seg.id)
}
}
p := loadPrefs()
p.StatusbarHidden = ids
savePrefs(p)
}
// saveInlineActionsHidden persists the inline-action-rows toggle, preserving other prefs fields.
func saveInlineActionsHidden(hidden bool) {
p := loadPrefs()
p.InlineActionsHidden = hidden
savePrefs(p)
}
+13 -13
View File
@@ -3,8 +3,8 @@ package app
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
@@ -24,7 +24,7 @@ func (m *Model) proposeInitState() {
// --- key handling ---
func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleProposeKey(k keyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Propose == nil {
return m, nil
@@ -34,15 +34,15 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
n := len(s.Propose.Candidates) // index n == the custom slot
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.proposeDismissed = true
case tea.KeyUp:
case keyUp:
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
case tea.KeyDown:
case keyDown:
m.proposeCursor = clampInt(m.proposeCursor+1, 0, n)
case tea.KeyEnter, tea.KeySpace:
case keyEnter, keySpace:
return m.proposeSubmit()
case tea.KeyRunes:
case keyRunes:
switch string(k.Runes) {
case "k":
m.proposeCursor = clampInt(m.proposeCursor-1, 0, n)
@@ -56,17 +56,17 @@ func (m Model) handleProposeKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m Model) handleProposeTyping(k tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) handleProposeTyping(k keyMsg) (tea.Model, tea.Cmd) {
switch k.Type {
case tea.KeyEsc:
case keyEsc:
m.proposeTyping = false
case tea.KeyEnter:
case keyEnter:
return m.proposeSubmit() // commit the custom answer and send it as chat
case tea.KeyBackspace:
case keyBackspace:
if len(m.proposeText) > 0 {
m.proposeText = m.proposeText[:len(m.proposeText)-1]
}
case tea.KeyRunes, tea.KeySpace:
case keyRunes, keySpace:
m.proposeText += string(k.Runes)
}
return m, nil
+8 -9
View File
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
@@ -47,8 +46,8 @@ func TestProposeEntersStateAndRenders(t *testing.T) {
func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
m, client := proposeModel()
// move cursor to the second candidate (role_pipeline) and launch it
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyDown})
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyProposeKey(m, keyMsg{Type: keyDown})
m = applyProposeKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after launch")
@@ -67,7 +66,7 @@ func TestProposePickLaunchesChosenWorkflow(t *testing.T) {
func TestProposeFirstCandidateIsDefault(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter}) // no nav → first candidate
m = applyProposeKey(m, keyMsg{Type: keyEnter}) // no nav → first candidate
wf, _ := decodeStart(t, client)
if wf != "research" {
t.Fatalf("expected first candidate launched by default, got %q", wf)
@@ -76,14 +75,14 @@ func TestProposeFirstCandidateIsDefault(t *testing.T) {
func TestProposeCustomAnswerContinuesChat(t *testing.T) {
m, client := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")})
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune("e")})
if !m.proposeTyping {
t.Fatalf("expected typing mode after 'e'")
}
for _, r := range "do it manually" {
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = applyProposeKey(m, keyMsg{Type: keyRunes, Runes: []rune{r}})
}
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEnter})
m = applyProposeKey(m, keyMsg{Type: keyEnter})
if s := m.session("s1"); s == nil || s.Propose != nil {
t.Fatalf("expected proposal cleared after custom answer")
@@ -109,7 +108,7 @@ func TestProposeCustomAnswerContinuesChat(t *testing.T) {
func TestProposeEscDismisses(t *testing.T) {
m, _ := proposeModel()
m = applyProposeKey(m, tea.KeyMsg{Type: tea.KeyEsc})
m = applyProposeKey(m, keyMsg{Type: keyEsc})
if m.displayState() != StateInSession {
t.Fatalf("expected StateInSession after esc, got %v", m.displayState())
}
@@ -118,7 +117,7 @@ func TestProposeEscDismisses(t *testing.T) {
}
}
func applyProposeKey(m Model, k tea.KeyMsg) Model {
func applyProposeKey(m Model, k keyMsg) Model {
updated, _ := m.handleProposeKey(k)
return updated.(Model)
}
@@ -0,0 +1,671 @@
package app
import (
"bufio"
"os"
"regexp"
"strings"
"testing"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
// render_matrix_test.go is the per-event-type golden render matrix (BACKLOG §E §2):
// one case per server event/entry kind the TUI handles. Each case drives a
// representative protocol.ServerMessage through the *production* path (applyServer),
// renders the surface it lands on (View / a card / a modal / the router transcript),
// strips ANSI, and asserts the render carries that kind's defining, stable text.
//
// Goldens are inline expected substrings rather than golden files: it matches the
// module's existing render-test convention (clarcard_test, ideacard_test, …), needs no
// -update dance, and pins the *meaning* of each render (the tool name, stage id, status
// label, card title) without coupling to exact lipgloss styling or timestamps.
//
// The coverage guard (TestRenderMatrixCoversEveryEventType) is the load-bearing part:
// it parses every protocol.Type* constant and fails if a new event kind is added
// without either a matrix case or an explicit non-rendering classification.
// matrixSurface selects which render surface a case asserts against — each is a real
// production render entry point, fed off the same Model that applyServer mutated.
type matrixSurface int
const (
surfaceView matrixSurface = iota // full immediate-mode frame, View()
surfaceEvents // right-panel event-stream rows (eventRows)
surfaceRouter // left transcript rows (routerRows)
surfaceApprovalBand // docked approval band (renderApprovalBand)
surfaceClarModal // clarification modal
surfaceProposeModal // workflow-propose modal
surfaceIdeasModal // idea board overlay
surfaceStatsModal // session-stats overlay
surfaceConfigModal // config editor overlay
surfaceArtifactsModal // artifact viewer overlay
surfaceModelsModal // model swap overlay
surfaceStatusBar // top status bar (renderStatus)
)
// fixedNow is a deterministic timestamp used for every event carrying OccurredAt. The
// matrix asserts only event type/detail text, never the rendered clock — formatTime is
// local (machine-TZ dependent), so the time string is intentionally not part of any golden.
const fixedNow int64 = 1_700_000_000_000
// matrixCase is one event/entry kind in the matrix.
type matrixCase struct {
name string // human label / subtest name
kinds []string // protocol.Type* values this case exercises
build func() protocol.ServerMessage // a representative frame
surface matrixSurface // where its render lands
// prep mutates the model before applyServer (e.g. select + enter a session so an
// in-session surface is reachable, or open the overlay an *.list reply fills).
prep func(m *Model)
want []string // ANSI-stripped substrings the render must contain
}
// newMatrixModel builds a deterministic Model: fixed width/theme, an entered session,
// an unconnected ws client (Send buffers, never dials).
func newMatrixModel() Model {
m := NewModel(ws.New("", 0))
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
m.connected = true
m.selectedID = "s1"
m.sessionEntered = true
m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}}
return m
}
// renderSurface renders the requested surface off m and strips ANSI.
func renderSurface(m Model, s matrixSurface) string {
var raw string
switch s {
case surfaceView:
raw = m.render()
case surfaceEvents:
// Render the event-stream rows at full panel width so a row's detail isn't
// clipped at the slim right-panel edge (the production builder, just wide).
raw = strings.Join(m.eventRows(110, 40), "\n")
case surfaceRouter:
raw = strings.Join(m.routerRows(100, 30), "\n")
case surfaceApprovalBand:
raw = m.renderApprovalBand(m.approvalBandHeight())
case surfaceClarModal:
raw = m.clarificationModal()
case surfaceProposeModal:
raw = m.proposeModal()
case surfaceIdeasModal:
raw = m.ideasModal()
case surfaceStatsModal:
raw = m.statsModal()
case surfaceConfigModal:
raw = m.configModal()
case surfaceArtifactsModal:
raw = m.artifactsModal()
case surfaceModelsModal:
raw = m.modelsModal()
case surfaceStatusBar:
raw = m.renderStatus()
}
return stripANSI(raw)
}
// matrixCases is the matrix: one entry per event/entry kind the renderer handles.
func matrixCases() []matrixCase {
str := func(s string) *string { return &s }
return []matrixCase{
// --- session lifecycle (status bar + narration feed) ---
{
name: "session.announced",
kinds: []string{protocol.TypeSessionAnnounced},
surface: surfaceStatusBar,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionAnnounced, withWF("healthcheck")) },
want: []string{"healthcheck", "ACTIVE"},
},
{
name: "session.paused",
kinds: []string{protocol.TypeSessionPaused},
surface: surfaceRouter,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionPaused) },
want: []string{"paused"},
},
{
name: "session.resumed",
kinds: []string{protocol.TypeSessionResumed},
surface: surfaceRouter,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionResumed) },
want: []string{"resumed"},
},
{
name: "session.completed",
kinds: []string{protocol.TypeSessionCompleted},
surface: surfaceRouter,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionCompleted) },
want: []string{"workflow complete"},
},
{
name: "session.failed",
kinds: []string{protocol.TypeSessionFailed},
surface: surfaceRouter,
build: func() protocol.ServerMessage { return msg(protocol.TypeSessionFailed, withReason("schema mismatch")) },
want: []string{"workflow failed", "schema mismatch"},
},
// --- chat / router transcript ---
{
name: "chat.turn (router)",
kinds: []string{protocol.TypeChatTurn},
surface: surfaceRouter,
build: func() protocol.ServerMessage {
return msg(protocol.TypeChatTurn, func(m *protocol.ServerMessage) {
m.Role, m.Content = "ROUTER", "running the healthcheck now"
})
},
want: []string{"running the healthcheck now"},
},
{
name: "router.narration",
kinds: []string{protocol.TypeRouterNarration},
surface: surfaceRouter,
build: func() protocol.ServerMessage {
return msg(protocol.TypeRouterNarration, func(m *protocol.ServerMessage) {
m.Content = "moving on to validation"
})
},
want: []string{"moving on to validation", "◆"},
},
// --- stage lifecycle (event stream rows) ---
{
name: "stage.started",
kinds: []string{protocol.TypeStageStarted},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeStageStarted, withStage("write_script")) },
want: []string{"StageStarted", "write_script"},
},
{
name: "stage.completed",
kinds: []string{protocol.TypeStageCompleted},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeStageCompleted, withStage("write_script")) },
want: []string{"StageCompleted", "write_script"},
},
{
name: "stage.failed",
kinds: []string{protocol.TypeStageFailed},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeStageFailed, withStage("generate")) },
want: []string{"StageFailed", "generate"},
},
// --- inference lifecycle (event stream rows) ---
{
name: "inference.started",
kinds: []string{protocol.TypeInferenceStarted},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceStarted, withStage("plan")) },
want: []string{"InferenceStarted", "plan"},
},
{
name: "inference.completed",
kinds: []string{protocol.TypeInferenceDone},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeInferenceDone, withStage("plan"), func(m *protocol.ServerMessage) { m.Summary = "ok" })
},
want: []string{"InferenceCompleted", "plan"},
},
{
name: "inference.timed_out",
kinds: []string{protocol.TypeInferenceTimeout},
surface: surfaceEvents,
build: func() protocol.ServerMessage { return msg(protocol.TypeInferenceTimeout, withStage("plan")) },
want: []string{"InferenceTimedOut", "plan"},
},
{
name: "inference.failed",
kinds: []string{protocol.TypeInferenceFailed},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeInferenceFailed, withStage("plan"), withReason("oom"))
},
want: []string{"InferenceFailed", "plan", "oom"},
},
{
name: "inference.retry",
kinds: []string{protocol.TypeInferenceRetry},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeInferenceRetry, withStage("plan"), func(m *protocol.ServerMessage) {
m.AttemptNumber, m.MaxAttempts, m.FailureReason = 2, 3, "rate limited"
})
},
want: []string{"RetryAttempted", "plan", "2/3", "rate limited"},
},
// --- tools ---
{
name: "tool.started",
kinds: []string{protocol.TypeToolStarted},
surface: surfaceStatusBar, // started shows as the active spinner; record lands in s.Tools
build: func() protocol.ServerMessage { return msg(protocol.TypeToolStarted, withTool("file_write")) },
want: []string{"active"},
},
{
name: "tool.completed",
kinds: []string{protocol.TypeToolCompleted},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeToolCompleted, withTool("file_write"), func(m *protocol.ServerMessage) { m.Summary = "wrote 2 files" })
},
want: []string{"ToolCompleted", "file_write"},
},
{
name: "tool.failed",
kinds: []string{protocol.TypeToolFailed},
surface: surfaceView,
prep: func(m *Model) {
// failed marks an already-started tool record; seed one so the status flips.
s := m.session("s1")
s.Tools = []ToolRecord{{Name: "file_write", Status: ToolStarted}}
},
build: func() protocol.ServerMessage { return msg(protocol.TypeToolFailed, withTool("file_write")) },
// tool.failed is a state-only mutation (no event row, no card); assert it
// renders the in-session frame without crashing and keeps the session header.
want: []string{"healthcheck"},
},
{
name: "tool.rejected",
kinds: []string{protocol.TypeToolRejected},
surface: surfaceView,
prep: func(m *Model) {
s := m.session("s1")
s.Tools = []ToolRecord{{Name: "shell_exec", Status: ToolStarted}}
},
build: func() protocol.ServerMessage { return msg(protocol.TypeToolRejected, withTool("shell_exec")) },
want: []string{"healthcheck"},
},
{
name: "tool.assessed",
kinds: []string{protocol.TypeToolAssessed},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeToolAssessed, withTool("file_write"), func(m *protocol.ServerMessage) {
m.Disposition = "BLOCK"
m.AssessedIssues = []protocol.AssessedIssueDto{{Code: "PATH_ESCAPE", Message: "outside workspace", Severity: "HIGH"}}
})
},
want: []string{"ToolAssessed", "BLOCK", "PATH_ESCAPE"},
},
// --- artifacts / plan (event stream rows) ---
{
name: "artifact.created",
kinds: []string{protocol.TypeArtifactCreated},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeArtifactCreated, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" })
},
want: []string{"ArtifactCreated", "art-7"},
},
{
name: "artifact.validated",
kinds: []string{protocol.TypeArtifactValid},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeArtifactValid, func(m *protocol.ServerMessage) { m.ArtifactID = "art-7" })
},
want: []string{"ArtifactValidated", "art-7"},
},
{
name: "plan.locked",
kinds: []string{protocol.TypePlanLocked},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypePlanLocked, func(m *protocol.ServerMessage) { m.StageIDs = []string{"plan", "build", "verify"} })
},
want: []string{"PlanLocked", "plan", "build", "verify"},
},
// --- workspace bind (status bar) ---
{
name: "session.workspace_bound",
kinds: []string{protocol.TypeWorkspaceBound},
surface: surfaceStatusBar,
build: func() protocol.ServerMessage {
return msg(protocol.TypeWorkspaceBound, func(m *protocol.ServerMessage) { m.WorkspaceRoot = "/tmp/corx-ws" })
},
want: []string{"corx-ws"},
},
// --- approval gate (docked band card) ---
{
name: "approval.required",
kinds: []string{protocol.TypeApprovalRequired},
surface: surfaceApprovalBand,
build: func() protocol.ServerMessage {
return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) {
m.RequestID, m.Tier = "req-1", "T3"
m.Preview = str("--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n")
m.RiskSummary = &protocol.RiskSummaryDto{Level: "HIGH", Rationale: []string{"[PATH_OUTSIDE_WORKSPACE] /etc/hosts"}}
})
},
want: []string{"file_write", "T3", "HIGH", "PATH_OUTSIDE_WORKSPACE"},
},
{
name: "approval.resolved",
kinds: []string{protocol.TypeApprovalResolved},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return msg(protocol.TypeApprovalResolved, func(m *protocol.ServerMessage) { m.Outcome, m.Reason = "APPROVED", "operator ok" })
},
want: []string{"ApprovalResolved", "APPROVED", "operator ok"},
},
// --- clarification (modal card) ---
{
name: "clarification.required",
kinds: []string{protocol.TypeClarification},
surface: surfaceClarModal,
build: func() protocol.ServerMessage {
return msg(protocol.TypeClarification, withStage("analyst"), func(m *protocol.ServerMessage) {
m.RequestID = "req-1"
m.Questions = []protocol.ClarificationQuestionDto{
{ID: "stack", Prompt: "Which stack?", Options: []string{"React", "Vue"}, Header: "Stack"},
}
})
},
want: []string{"clarifying questions", "Which stack?", "React", "Vue", "Stack"},
},
// --- workflow proposal (modal card) ---
{
name: "workflow.proposed",
kinds: []string{protocol.TypeWorkflowProposed},
surface: surfaceProposeModal,
build: func() protocol.ServerMessage {
return msg(protocol.TypeWorkflowProposed, func(m *protocol.ServerMessage) {
m.ProposalID, m.Prompt, m.OriginalRequest = "prop-1", "Run one of these?", "scan the repo"
m.Candidates = []protocol.ProposedWorkflowDto{
{WorkflowID: "research", Reason: "gather + report"},
{WorkflowID: "role_pipeline", Reason: "full build"},
}
})
},
want: []string{"workflow suggestion", "Run one of these?", "research", "gather + report", "role_pipeline"},
},
// --- idea board (overlay; cross-session, no session scope) ---
{
name: "idea.list",
kinds: []string{protocol.TypeIdeaList},
surface: surfaceIdeasModal,
prep: func(m *Model) { m.overlay = OverlayIdeas },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeIdeaList, Ideas: []protocol.IdeaDto{
{IdeaID: "i1", Text: "cache the repo map", CapturedAtMs: 1000},
}}
},
want: []string{"ideas", "cache the repo map"},
},
// --- session stats (overlay) ---
{
name: "session.stats",
kinds: []string{protocol.TypeSessionStats},
surface: surfaceStatsModal,
prep: func(m *Model) { m.overlay = OverlayStats; m.statsFor = "s1" },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeSessionStats, SessionID: "s1", Stats: sampleStats()}
},
want: []string{"file_write", "read_file"},
},
// --- config snapshot (overlay) ---
{
name: "config.snapshot",
kinds: []string{protocol.TypeConfigSnapshot},
surface: surfaceConfigModal,
prep: func(m *Model) { m.overlay = OverlayConfig },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeConfigSnapshot, ConfigFields: []protocol.ConfigFieldDto{
{Key: "router.model", Type: "string", Value: "qwen2.5-coder"},
}}
},
want: []string{"router.model", "qwen2.5-coder"},
},
// --- artifact list (overlay) ---
{
name: "artifact.list",
kinds: []string{protocol.TypeArtifactList},
surface: surfaceArtifactsModal,
prep: func(m *Model) { m.overlay = OverlayArtifacts; m.artifactsFor = "s1" },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeArtifactList, SessionID: "s1", Artifacts: []protocol.ArtifactDto{
{ArtifactID: "art-7", StageID: "plan", SchemaVersion: 1, Phase: "PLAN", Content: str("{\"ok\":true}")},
}}
},
want: []string{"art-7"},
},
// --- model list / changed (overlay + status chrome) ---
{
name: "model.list",
kinds: []string{protocol.TypeModelList},
surface: surfaceModelsModal,
prep: func(m *Model) { m.overlay = OverlayModels },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeModelList, Models: []string{"qwen2.5-coder", "llama3"}, Current: "qwen2.5-coder"}
},
want: []string{"qwen2.5-coder", "llama3"},
},
{
name: "model.changed",
kinds: []string{protocol.TypeModelChanged},
surface: surfaceStatusBar,
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeModelChanged, ModelID: "llama3-swapped", Loaded: true}
},
want: []string{"llama3-swapped"},
},
// --- provider status (status bar model + locality) ---
{
name: "provider.status_changed",
kinds: []string{protocol.TypeProviderStatus},
surface: surfaceStatusBar,
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeProviderStatus, ProviderID: "llama-cpp:qwen"}
},
want: []string{"llama-cpp:qwen", "local"},
},
// --- resource gauge (status bar) ---
{
name: "resource.status",
kinds: []string{protocol.TypeResourceStatus},
surface: surfaceStatusBar,
prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false }, // gauge shows on the idle bar
build: func() protocol.ServerMessage {
used, total, util := int64(4096), int64(8192), 42
return protocol.ServerMessage{Type: protocol.TypeResourceStatus,
GpuMemoryUsedMb: &used, GpuMemoryTotalMb: &total, GpuUtilizationPct: &util}
},
want: []string{"VRAM 4096/8192M", "GPU 42%"},
},
// --- workflow list → idle launch target (Tab-selected, shown at the input) ---
{
name: "workflow.list",
kinds: []string{protocol.TypeWorkflowList},
surface: surfaceView,
prep: func(m *Model) { m.selectedID = ""; m.sessionEntered = false; m.launcherWf = 1 },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeWorkflowList, Workflows: []protocol.WorkflowDto{
{WorkflowID: "healthcheck", Description: "kick the tires"},
}}
},
want: []string{"healthcheck"},
},
// --- session snapshot (reopen: rebuilds a session from the recent-events tail) ---
{
name: "session_snapshot",
kinds: []string{protocol.TypeSessionSnapshot},
surface: surfaceEvents,
prep: func(m *Model) { m.sessions = nil; m.selectedID = "snap1"; m.sessionEntered = true },
build: func() protocol.ServerMessage {
return protocol.ServerMessage{
Type: protocol.TypeSessionSnapshot, SessionID: "snap1", WorkflowID: "refactor",
State: &protocol.SessionStateDto{Status: "PAUSED"},
RecentEvents: []protocol.EventEntryDto{{Timestamp: fixedNow, Type: "StageStarted", Detail: "plan"}},
}
},
want: []string{"StageStarted", "plan"},
},
// --- stage tool manifest (declared, not-yet-run tools — state only) ---
{
name: "stage.tool_manifest",
kinds: []string{protocol.TypeStageManifest},
surface: surfaceView,
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: protocol.TypeStageManifest, SessionID: "s1", Stages: []protocol.StageToolDecl{
{StageID: "plan", Tools: []protocol.ToolDecl{{Name: "read_file", Tier: 1}}},
}}
},
// manifest seeds s.ToolsByStage (surfaced by the tool palette, not the main
// frame); assert the in-session frame still renders cleanly with the session.
want: []string{"healthcheck"},
},
// --- unknown / future event: raw fallback row, never dropped (§2) ---
{
name: "unknown event (raw fallback)",
kinds: []string{"some.future.event"},
surface: surfaceEvents,
build: func() protocol.ServerMessage {
return protocol.ServerMessage{Type: "some.future.event", SessionID: "s1", OccurredAt: fixedNow}
},
want: []string{"some.future.event", "raw"},
},
}
}
// --- frame builders (keep cases terse) ---
type msgOpt func(*protocol.ServerMessage)
func msg(typ string, opts ...msgOpt) protocol.ServerMessage {
m := protocol.ServerMessage{Type: typ, SessionID: "s1", OccurredAt: fixedNow}
for _, o := range opts {
o(&m)
}
return m
}
func withWF(id string) msgOpt { return func(m *protocol.ServerMessage) { m.WorkflowID = id } }
func withStage(id string) msgOpt { return func(m *protocol.ServerMessage) { m.StageID = id } }
func withTool(name string) msgOpt { return func(m *protocol.ServerMessage) { m.ToolName = name } }
func withReason(r string) msgOpt { return func(m *protocol.ServerMessage) { m.Reason = r } }
// TestRenderMatrix is the table-driven golden render matrix: every event/entry kind,
// driven through applyServer, asserts its render carries its defining stable text.
func TestRenderMatrix(t *testing.T) {
for _, c := range matrixCases() {
t.Run(c.name, func(t *testing.T) {
m := newMatrixModel()
if c.prep != nil {
c.prep(&m)
}
m.applyServer(c.build())
out := renderSurface(m, c.surface)
for _, want := range c.want {
if !strings.Contains(out, want) {
t.Fatalf("render for %q missing %q\n--- visible ---\n%s", c.name, want, out)
}
}
})
}
}
// nonRenderingEventTypes are protocol.Type* constants the TUI handles but that have no
// per-kind render of their own — they are explicitly classified here so the coverage
// guard stays exhaustive. Adding a new Type* constant forces a choice: give it a matrix
// case, or list it here with a reason. Either way the guard can't silently pass.
var nonRenderingEventTypes = map[string]string{
protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render",
protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream",
protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced",
protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript",
protocol.TypeGrantList: "query reply: populates the standing-grants overlay, not the transcript",
protocol.TypeHealthChecks: "query reply: populates the health-checks overlay, not the transcript",
}
// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans
// every protocol.Type* constant declared in protocol.go and fails if any is neither
// covered by a matrix case nor explicitly classified as non-rendering. A new event kind
// added to the protocol without a matching render assertion breaks this test.
func TestRenderMatrixCoversEveryEventType(t *testing.T) {
declared := declaredEventTypes(t)
if len(declared) < 30 {
t.Fatalf("expected to scan ~40 protocol Type* constants, found only %d — parser drift?", len(declared))
}
covered := map[string]bool{}
for _, c := range matrixCases() {
for _, k := range c.kinds {
covered[k] = true
}
}
var missing []string
for typ := range declared {
if covered[typ] {
continue
}
if _, ok := nonRenderingEventTypes[typ]; ok {
continue
}
missing = append(missing, typ)
}
if len(missing) > 0 {
t.Fatalf("event types with no matrix case and no non-rendering classification: %v\n"+
"add a case to matrixCases() (preferred) or list it in nonRenderingEventTypes with a reason", missing)
}
// Reverse guard: every classified non-rendering type must still be a real constant
// (catches a typo or a removed constant rotting the allowlist).
for typ := range nonRenderingEventTypes {
if !declared[typ] {
t.Errorf("nonRenderingEventTypes lists %q which is not a declared protocol Type* value", typ)
}
}
}
// declaredEventTypes parses protocol.go and returns the value of every Type* string
// constant (e.g. "stage.started"). Deriving the authoritative set from source — rather
// than a hand-kept list — is what makes the coverage guard catch *new* event kinds.
func declaredEventTypes(t *testing.T) map[string]bool {
t.Helper()
const src = "../protocol/protocol.go"
f, err := os.Open(src)
if err != nil {
t.Fatalf("open %s: %v", src, err)
}
defer f.Close()
// Matches lines like: TypeStageStarted = "stage.started"
re := regexp.MustCompile(`^\s*Type[A-Za-z0-9]+\s*=\s*"([^"]+)"`)
out := map[string]bool{}
sc := bufio.NewScanner(f)
for sc.Scan() {
if mm := re.FindStringSubmatch(sc.Text()); mm != nil {
out[mm[1]] = true
}
}
if err := sc.Err(); err != nil {
t.Fatalf("scan %s: %v", src, err)
}
return out
}
+120 -12
View File
@@ -22,8 +22,11 @@ func containsFold(haystack, needle string) bool {
return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
}
// formatTime renders an event/activity instant as a local wall-clock time, matching the
// operator's clock (and the local date shown in the session list). It was previously forced
// to UTC, so every event timestamp read hours off the user's actual time.
func formatTime(epochMillis int64) string {
return time.UnixMilli(epochMillis).UTC().Format("15:04:05")
return time.UnixMilli(epochMillis).Format("15:04:05")
}
// inferCategory maps an event type string to a display category, matching the
@@ -125,7 +128,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeSessionResumed:
if s := m.session(msg.SessionID); s != nil {
s.Status = "ACTIVE"
s.Pending = nil
s.clearApprovals()
s.LastEventAt = nowMillis()
}
case protocol.TypeSessionCompleted:
@@ -134,6 +137,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.Active = false
s.Clar = nil
s.Propose = nil
s.clearApprovals()
}
case protocol.TypeSessionFailed:
m.touch(msg.SessionID, "FAILED")
@@ -141,6 +145,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.Active = false
s.Clar = nil
s.Propose = nil
s.clearApprovals()
}
case protocol.TypeStageStarted:
if s := m.session(msg.SessionID); s != nil {
@@ -196,6 +201,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
}
s.LastEventAt = nowMillis()
}
// Tool-start is not surfaced inline (it doubles up with the ✓/✎ result row); it
// stays in the tool list + EVENTS panel.
case protocol.TypeToolCompleted:
if s := m.session(msg.SessionID); s != nil {
s.Active = false
@@ -204,7 +211,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
}
if msg.Diff != nil && *msg.Diff != "" {
// A diff = a write/edit: name the file + the line delta, then keep the
// existing collapsed diff row (^x opens the full diff).
path, add, del := diffSummary(*msg.Diff)
m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del))
m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff})
} else {
m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary))
}
case protocol.TypeToolAssessed:
if s := m.session(msg.SessionID); s != nil {
@@ -222,11 +235,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.markTool(msg.ToolName, ToolFailed)
s.LastEventAt = msg.OccurredAt
}
m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason))
case protocol.TypeToolRejected:
if s := m.session(msg.SessionID); s != nil {
s.markTool(msg.ToolName, ToolRejected)
s.LastEventAt = nowMillis()
}
m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason))
case protocol.TypeArtifactCreated:
if s := m.session(msg.SessionID); s != nil {
s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
@@ -252,13 +267,25 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
m.onWorkflowProposed(msg)
case protocol.TypeApprovalResolved:
if s := m.session(msg.SessionID); s != nil {
s.Pending = nil
// The resolved gate names no tool on the wire; recover it from the pending
// queue before dropping the gate, for the inline row.
tool := ""
for _, a := range s.PendingQueue {
if a.RequestID == msg.RequestID {
tool = a.ToolName
break
}
}
// Drop just the resolved gate; any others stay queued and the band
// advances to the next rather than vanishing entirely.
s.removeApproval(msg.RequestID)
detail := msg.Outcome
if msg.Reason != "" {
detail += " — " + msg.Reason
}
s.addEvent(msg.OccurredAt, "ApprovalResolved", detail)
s.LastEventAt = nowMillis()
m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason))
}
case protocol.TypeSessionSnapshot:
m.onSnapshot(msg)
@@ -325,6 +352,19 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeHealthChecks:
m.health = msg.Health
m.healthLoading = false
case protocol.TypeFileList:
m.files = msg.Paths
m.filesFor = msg.SessionID
m.filesLoading = false
if m.fileIndex >= len(m.filteredFiles()) {
m.fileIndex = 0
}
case protocol.TypeGrantList:
m.grants = msg.Grants
m.grantsLoading = false
if m.grantIndex >= len(m.grants) {
m.grantIndex = 0
}
case protocol.TypeConfigSnapshot:
m.configFields = msg.ConfigFields
m.configRestart = msg.ConfigRestartRequired
@@ -370,13 +410,81 @@ func sessionIDOf(msg protocol.ServerMessage) string {
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
protocol.TypeIdeaList, protocol.TypeHealthChecks:
protocol.TypeIdeaList, protocol.TypeHealthChecks,
protocol.TypeFileList, protocol.TypeGrantList:
return ""
default:
return msg.SessionID
}
}
// --- inline action-row helpers (external-feedback events surfaced in OUTPUT) ---
// diffSummary extracts the written path and +/- line counts from a unified diff for the
// inline write row. Falls back to "file" when no `+++` header is present.
func diffSummary(diff string) (path string, added, removed int) {
path = "file"
for _, ln := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(ln, "+++ "):
p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ "))
p = strings.TrimPrefix(p, "b/")
if p != "" && p != "/dev/null" {
path = p
}
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++ "):
added++
case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "--- "):
removed++
}
}
return path, added, removed
}
// countSuffix renders the " (+a b)" delta, or "" when nothing changed.
func countSuffix(added, removed int) string {
if added == 0 && removed == 0 {
return ""
}
return " (+" + itoa(added) + " " + itoa(removed) + ")"
}
// actionToolText joins a tool label with a short, clipped result summary.
func actionToolText(label, summary string) string {
s := strings.TrimSpace(summary)
if s == "" {
return label
}
return label + " · " + clip(s, 48)
}
func approvalIcon(outcome string) string {
if outcome == "REJECTED" {
return "✕"
}
return "⌘"
}
// approvalActionText renders the inline approval row, noting an auto-approval that fired via a
// standing grant (reason "grant:<id>").
func approvalActionText(outcome, tool, reason string) string {
verb := "approved"
switch outcome {
case "REJECTED":
verb = "rejected"
case "AUTO_APPROVED":
verb = "auto-approved"
}
txt := verb
if tool != "" {
txt = verb + " " + tool
}
if strings.HasPrefix(reason, "grant:") {
txt += " · via grant"
}
return txt
}
// onSessionAnnounced fills in a session's workflow identity (the announce is the
// only event carrying workflowId) and applies auto-focus. The session entry itself
// was already created by the auto-vivify path in applyServer.
@@ -414,7 +522,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
Rationale: rationale,
}
if s := m.session(msg.SessionID); s != nil {
s.Pending = info
s.enqueueApproval(info)
}
}
@@ -456,25 +564,25 @@ func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) {
}
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
var pending *Approval
if len(msg.PendingAppr) > 0 {
a := msg.PendingAppr[0]
pending = &Approval{
var queue []*Approval
for _, a := range msg.PendingAppr {
queue = append(queue, &Approval{
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
}
})
}
status := "running"
if msg.State != nil {
status = msg.State.Status
}
if pending != nil {
if len(queue) > 0 {
status = "PAUSED awaiting approval"
}
sess := Session{
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending,
Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue,
}
sess.syncPending()
if msg.State != nil && msg.State.CurrentStageID != nil {
sess.CurrentStage = *msg.State.CurrentStageID
}
@@ -0,0 +1,274 @@
package app
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// 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 keyMsg) (tea.Model, tea.Cmd) {
switch {
case k.Type == keyEsc || runeIs(k, "R"):
m.overlay = OverlayNone
case k.Type == keyUp || runeIs(k, "k"):
if m.sessionListIndex > 0 {
m.sessionListIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.sessionListIndex < len(m.sessionList)-1 {
m.sessionListIndex++
}
case k.Type == 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)
dateCell := mbg(t, padRaw(absDateISO(s.LastActivityAt), 12), t.P.Dim)
ageCell := mbg(t, "· "+relativeAge(s.LastActivityAt), t.P.Faint)
return marker + idCell + " " + statusCell + " " + stageCell + " " + dateCell + " " + ageCell
}
// absDateISO renders an ISO-8601 instant (the server's LastActivityAt) as a compact local
// date via shortDateTime, or "—" when absent/unparseable.
func absDateISO(iso string) string {
if iso == "" {
return "—"
}
ts, err := time.Parse(time.RFC3339Nano, iso)
if err != nil {
if ts, err = time.Parse(time.RFC3339, iso); err != nil {
return "—"
}
}
return shortDateTime(ts.UnixMilli())
}
// 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,150 @@
package app
import (
"strings"
"testing"
)
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(keyMsg{Type: 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(keyMsg{Type: 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(keyMsg{Type: 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(keyMsg{Type: 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(keyMsg{Type: 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(keyMsg{Type: 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(keyMsg{Type: 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)
}
}
+43
View File
@@ -0,0 +1,43 @@
package app
import (
"testing"
)
// `/` as the first char of a chat line opens the command palette inline (opencode-style),
// without typing a literal slash.
func TestSlashOpensPaletteOnEmptyLine(t *testing.T) {
m := NewModel(nil)
m.editMode = ModeInsert
m.inputMode = ModeRouter
m.inputBuffer = ""
next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
nm := next.(Model)
if nm.overlay != OverlayPalette {
t.Fatalf("'/' on an empty line should open the palette; overlay=%d", nm.overlay)
}
if nm.inputBuffer != "" {
t.Fatalf("'/' should not be typed literally; buffer=%q", nm.inputBuffer)
}
}
// Mid-line `/` is a literal slash (e.g. a path), not a command trigger.
func TestSlashLiteralMidLine(t *testing.T) {
m := NewModel(nil)
m.editMode = ModeInsert
m.inputMode = ModeRouter
m.inputBuffer = "path"
m.inputCursor = len("path")
next, _ := m.handleInsertKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
nm := next.(Model)
if nm.overlay != OverlayNone {
t.Fatalf("mid-line '/' must stay literal, not open the palette; overlay=%d", nm.overlay)
}
if nm.inputBuffer != "path/" {
t.Fatalf("mid-line '/' should be typed; buffer=%q", nm.inputBuffer)
}
}
@@ -0,0 +1,57 @@
package app
import (
"strings"
"testing"
)
// Toggling a segment hides it from the rendered status bar; toggling again restores it.
// Reuses inSessionModel (adaptive_layout_test.go): stage "write_script", a long model id.
func TestStatusBarToggleHidesSegment(t *testing.T) {
t.Setenv("CORREX_CONFIG_HOME", t.TempDir()) // isolate persistence
m := inSessionModel(220, 30)
m.sbHidden = map[string]bool{} // start from a clean prefs state
if !strings.Contains(m.renderStatus(), "write_script") {
t.Fatal("stage should be visible by default")
}
m.toggleStatusSegment("stage")
if strings.Contains(m.renderStatus(), "write_script") {
t.Fatal("stage should be hidden after toggle")
}
if !strings.Contains(m.renderStatus(), "llama-cpp") {
t.Fatal("model should still be visible (only stage was hidden)")
}
m.toggleStatusSegment("stage")
if !strings.Contains(m.renderStatus(), "write_script") {
t.Fatal("stage should be visible again after second toggle")
}
}
func TestStatusBarModelHide(t *testing.T) {
t.Setenv("CORREX_CONFIG_HOME", t.TempDir())
m := inSessionModel(220, 30)
m.sbHidden = map[string]bool{}
m.toggleStatusSegment("model")
if strings.Contains(m.renderStatus(), "llama-cpp") {
t.Fatal("model should be hidden after toggle")
}
}
// A toggle persists to tui-prefs.json and is reloaded on the next launch.
func TestStatusBarPrefsPersist(t *testing.T) {
t.Setenv("CORREX_CONFIG_HOME", t.TempDir())
m := inSessionModel(220, 30)
m.sbHidden = map[string]bool{}
m.toggleStatusSegment("gauge")
m.toggleStatusSegment("clock")
reloaded := loadStatusbarHidden()
if !reloaded["gauge"] || !reloaded["clock"] {
t.Fatalf("persisted hidden set = %v, want gauge+clock hidden", reloaded)
}
if reloaded["stage"] {
t.Fatal("stage was never toggled; should not be persisted as hidden")
}
}
+493
View File
@@ -0,0 +1,493 @@
package app
import (
"encoding/json"
"image/color"
"io"
"net/http"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// TaskSummary is one task from GET /tasks. Mirrors the server's TaskResponse
// (apps/server routes/TaskRoutes.kt) — the wire shape. Nullable server fields
// decode to the zero value ("" / nil) here.
type TaskSummary struct {
ID string `json:"id"`
Key string `json:"key"`
Status string `json:"status"`
Title string `json:"title"`
Goal string `json:"goal"`
AcceptanceCriteria []string `json:"acceptanceCriteria"`
AffectedPaths []string `json:"affectedPaths"`
Claimant string `json:"claimant"`
Links []TaskLinkWire `json:"links"`
Notes []TaskNoteWire `json:"notes"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
// Dependency-graph status from the server: Ready = workable now (TODO, no unmet deps);
// BlockedBy = ids of unfinished tasks this one waits on. Absent on older servers → zero values.
Ready bool `json:"ready"`
BlockedBy []string `json:"blockedBy"`
}
type TaskLinkWire struct {
TargetID string `json:"targetId"`
Type string `json:"type"`
TargetKind string `json:"targetKind"`
}
type TaskNoteWire struct {
Author string `json:"author"`
Body string `json:"body"`
At string `json:"at"`
}
// tasksLoadedMsg carries the result of a GET /tasks fetch. Exactly one of tasks /
// err is meaningful; a non-empty err means the fetch failed and the board shows it.
type tasksLoadedMsg struct {
tasks []TaskSummary
err string
}
// fetchTasks is a tea.Cmd that GETs the cross-project task board over HTTP and
// reports it as a tasksLoadedMsg. base is the http://host:port origin (from the ws
// client); an empty base yields an error message rather than a panic.
func fetchTasks(base string) tea.Cmd {
return func() tea.Msg {
if base == "" {
return tasksLoadedMsg{err: "no server address"}
}
client := &http.Client{Timeout: sessionsHTTPTimeout}
resp, err := client.Get(base + "/tasks")
if err != nil {
return tasksLoadedMsg{err: "fetch failed: " + err.Error()}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return tasksLoadedMsg{err: "server returned " + resp.Status}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return tasksLoadedMsg{err: "read failed: " + err.Error()}
}
var out []TaskSummary
if err := json.Unmarshal(body, &out); err != nil {
return tasksLoadedMsg{err: "decode failed: " + err.Error()}
}
return tasksLoadedMsg{tasks: out}
}
}
// openTasks opens the cross-project task board and kicks off the HTTP fetch. It is
// global (not bound to a session), so it opens from anywhere.
func (m *Model) openTasks() tea.Cmd {
m.overlay = OverlayTasks
m.taskListIndex = 0
m.taskListErr = ""
m.taskDetail = false
m.taskDetailScroll = 0
m.taskFilter = ""
m.taskFilterTyping = false
m.taskListLoading = true
return fetchTasks(m.client.HTTPBase())
}
// filteredTasks narrows the board by the `/` query — a case-insensitive substring over
// id/title/goal. An empty query returns the whole list.
func (m Model) filteredTasks() []TaskSummary {
if strings.TrimSpace(m.taskFilter) == "" {
return m.taskList
}
q := strings.ToLower(m.taskFilter)
var out []TaskSummary
for _, tk := range m.taskList {
if strings.Contains(strings.ToLower(tk.ID+" "+tk.Title+" "+tk.Goal), q) {
out = append(out, tk)
}
}
return out
}
// applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and
// surfacing any fetch error in place of a crash.
func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) {
m.taskListLoading = false
if msg.err != "" {
m.taskListErr = msg.err
return
}
m.taskListErr = ""
m.taskList = msg.tasks
if m.taskListIndex >= len(m.taskList) {
m.taskListIndex = 0
}
}
// selectedTask returns the highlighted task, or false when the list is empty / the
// cursor is out of range.
func (m Model) selectedTask() (TaskSummary, bool) {
f := m.filteredTasks()
if m.taskListIndex < 0 || m.taskListIndex >= len(f) {
return TaskSummary{}, false
}
return f[m.taskListIndex], true
}
// handleTasksKey owns every key while the board is open. In list mode: navigate, r
// refreshes, enter opens the detail pane, T/esc closes. In detail mode: scroll, and
// enter/esc returns to the list.
func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.taskDetail {
switch {
case k.Type == keyEsc || k.Type == keyEnter:
m.taskDetail = false
case k.Type == keyUp || runeIs(k, "k"):
m.scrollTaskDetail(-1)
case k.Type == keyDown || runeIs(k, "j"):
m.scrollTaskDetail(1)
case k.Type == keyPgUp:
m.scrollTaskDetail(-m.taskDetailBodyH())
case k.Type == keyPgDown:
m.scrollTaskDetail(m.taskDetailBodyH())
case runeIs(k, "g"):
m.taskDetailScroll = 0
case runeIs(k, "G"):
m.taskDetailScroll = m.taskDetailMaxScroll()
}
return m, nil
}
// While editing the `/` filter, keys feed the query (esc/enter stop editing, keeping it).
if m.taskFilterTyping {
switch {
case k.Type == keyEnter || k.Type == keyEsc:
m.taskFilterTyping = false
case k.Type == keyBackspace:
if n := len(m.taskFilter); n > 0 {
m.taskFilter = m.taskFilter[:n-1]
m.taskListIndex = 0
}
case k.Type == keyRunes || k.Type == keySpace:
m.taskFilter += string(k.Runes)
m.taskListIndex = 0
}
return m, nil
}
switch {
case k.Type == keyEsc || runeIs(k, "T"):
m.overlay = OverlayNone
case runeIs(k, "/"):
m.taskFilterTyping = true
case runeIs(k, "c"):
m.taskFilter = ""
m.taskListIndex = 0
case k.Type == keyUp || runeIs(k, "k"):
if m.taskListIndex > 0 {
m.taskListIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.taskListIndex < len(m.filteredTasks())-1 {
m.taskListIndex++
}
case runeIs(k, "r"):
return m, m.openTasks()
case k.Type == keyEnter:
if _, ok := m.selectedTask(); ok {
m.taskDetail = true
m.taskDetailScroll = 0
}
}
return m, nil
}
// taskDetailBodyH is the number of detail lines visible in the detail pane.
func (m Model) taskDetailBodyH() int {
h := m.height*70/100 - 6
if h < 4 {
h = 4
}
return h
}
// taskDetailMaxScroll keeps the last page of detail anchored to the body bottom.
func (m Model) taskDetailMaxScroll() int {
max := len(m.taskDetailBody(m.modalWidth()-4)) - m.taskDetailBodyH()
if max < 0 {
max = 0
}
return max
}
// scrollTaskDetail moves the detail offset by delta lines, clamped to [0, max].
func (m *Model) scrollTaskDetail(delta int) {
max := m.taskDetailMaxScroll()
off := m.taskDetailScroll + delta
if off > max {
off = max
}
if off < 0 {
off = 0
}
m.taskDetailScroll = off
}
func (m Model) tasksModal() string {
if m.taskDetail {
return m.taskDetailModal()
}
t := m.theme
w := m.modalWidth()
tasks := m.filteredTasks()
var b strings.Builder
b.WriteString(m.titleLine("tasks"))
if len(tasks) > 0 {
b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint))
}
b.WriteString("\n\n")
if m.taskListErr != "" {
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.taskListErr, w-8)) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
if m.taskListLoading && len(m.taskList) == 0 {
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
// Filter prompt: shown while editing the `/` query or whenever one is set.
if m.taskFilterTyping || m.taskFilter != "" {
b.WriteString(m.taskFilterLine() + "\n\n")
}
if len(tasks) == 0 {
msg := " no tasks"
if m.taskFilter != "" {
msg = " no tasks match \"" + m.taskFilter + "\""
}
b.WriteString(mbg(t, msg, t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, m.taskListHints()))
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(tasks) > bodyH {
off = m.taskListIndex - bodyH/2
if off < 0 {
off = 0
}
if off > len(tasks)-bodyH {
off = len(tasks) - bodyH
}
}
end := off + bodyH
if end > len(tasks) {
end = len(tasks)
}
for i := off; i < end; i++ {
b.WriteString(m.taskListRow(tasks, i) + "\n")
}
b.WriteString("\n" + modalHints(t, m.taskListHints()))
return t.Overlay.Width(w).Render(b.String())
}
// taskListHints is the list-mode footer (shared by the populated and empty-filter views).
func (m Model) taskListHints() [][2]string {
return [][2]string{{"↑↓", "select"}, {"enter", "detail"}, {"/", "filter"}, {"r", "refresh"}, {"T/esc", "close"}}
}
// taskFilterLine renders the `/` filter prompt over the current query, with a caret while editing.
func (m Model) taskFilterLine() string {
t := m.theme
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ")
caret := mbg(t, "", t.P.BgPanel)
if m.taskFilterTyping && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
}
if m.taskFilter == "" {
return prompt + caret + mbg(t, "type to filter tasks…", t.P.Faint)
}
return prompt + mbg(t, m.taskFilter, t.P.FgStrong) + caret
}
// taskListRow renders one board line: marker, id, status, title, and claimant.
func (m Model) taskListRow(tasks []TaskSummary, i int) string {
t := m.theme
tk := tasks[i]
marker := mbg(t, " ", t.P.BgPanel)
idFg := t.P.Fg
if i == m.taskListIndex {
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(tk.ID, 12))
statusCell := lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
Render(padRaw(tk.Status, 12))
claim := ""
if tk.Claimant != "" {
claim = " @" + shortSessionID(tk.Claimant)
}
readyCell := taskReadyCell(t, tk) // 2-col dependency-readiness glyph
titleW := m.modalWidth() - 6 - 12 - 1 - 12 - 1 - 2 - 1 - len([]rune(claim))
if titleW < 8 {
titleW = 8
}
titleCell := mbg(t, padRaw(truncate(tk.Title, titleW), titleW), t.P.Fg)
claimCell := mbg(t, claim, t.P.Faint)
return marker + idCell + " " + statusCell + " " + readyCell + " " + titleCell + claimCell
}
// taskReadyCell is a 2-column glyph showing dependency-graph readiness, so a decomposed graph reads
// at a glance: a filled dot when the task is workable now (TODO, no unmet deps), a hollow dot when it
// is waiting on unfinished blockers, blank for anything already in flight or finished.
func taskReadyCell(t Theme, tk TaskSummary) string {
switch {
case tk.Ready:
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.BgPanel).Render(padRaw("●", 2))
case len(tk.BlockedBy) > 0:
return lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(padRaw("○", 2))
default:
return mbg(t, " ", t.P.BgPanel)
}
}
func (m Model) taskDetailModal() string {
t := m.theme
w := m.modalWidth()
tk, _ := m.selectedTask()
body := m.taskDetailBody(w - 4)
bodyH := m.taskDetailBodyH()
off := m.taskDetailScroll
if mx := m.taskDetailMaxScroll(); off > mx {
off = mx
}
if off < 0 {
off = 0
}
var b strings.Builder
b.WriteString(m.titleLine("task " + tk.ID))
b.WriteString(lipgloss.NewStyle().Foreground(taskStatusColor(t, tk.Status)).Background(t.P.BgPanel).Bold(true).
Render(" " + tk.Status))
if len(body) > bodyH {
b.WriteString(mbg(t, " ("+itoa(off+1)+"-"+itoa(min(off+bodyH, len(body)))+"/"+itoa(len(body))+")", t.P.Faint))
}
b.WriteString("\n\n")
end := off + bodyH
if end > len(body) {
end = len(body)
}
for i := off; i < end; i++ {
b.WriteString(body[i] + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"g/G", "ends"}, {"enter/esc", "back"}}))
return t.Overlay.Width(w).Render(b.String())
}
// taskDetailBody renders the selected task as styled, wrapped lines: goal, acceptance
// criteria, affected paths, links, and notes. Built entirely from the list payload, so
// it needs no extra fetch.
func (m Model) taskDetailBody(width int) []string {
t := m.theme
tk, ok := m.selectedTask()
if !ok {
return []string{mbg(t, " (no task selected)", t.P.Faint)}
}
if width < 8 {
width = 8
}
var out []string
section := func(label string) {
out = append(out, lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label))
}
put := func(s string, fg color.Color) {
for _, ln := range wrapLines([]string{s}, width) {
out = append(out, mbg(t, ln, fg))
}
}
put(tk.Title, t.P.FgStrong)
if tk.Claimant != "" {
out = append(out, mbg(t, "claimant: "+tk.Claimant, t.P.Faint))
}
if tk.Ready {
out = append(out, mbg(t, "● ready to work (no unmet dependencies)", t.P.OK))
} else if len(tk.BlockedBy) > 0 {
out = append(out, mbg(t, "○ blocked — waiting on "+strings.Join(tk.BlockedBy, ", "), t.P.Dim))
}
out = append(out, "")
if tk.Goal != "" {
section("goal")
put(tk.Goal, t.P.Fg)
out = append(out, "")
}
if len(tk.AcceptanceCriteria) > 0 {
section("acceptance criteria")
for _, c := range tk.AcceptanceCriteria {
put("- "+c, t.P.Fg)
}
out = append(out, "")
}
if len(tk.AffectedPaths) > 0 {
section("affected paths")
for _, p := range tk.AffectedPaths {
out = append(out, mbg(t, clip(" "+p, width), t.P.Dim))
}
out = append(out, "")
}
if len(tk.Links) > 0 {
section("links")
for _, l := range tk.Links {
out = append(out, mbg(t, clip(" "+l.TargetID+" ("+l.Type+" → "+l.TargetKind+")", width), t.P.Fg))
}
out = append(out, "")
}
if len(tk.Notes) > 0 {
section("notes")
for _, n := range tk.Notes {
put("["+n.Author+"] "+n.Body, t.P.Fg)
}
out = append(out, "")
}
// Drop the trailing blank so the body never has a dangling empty line.
for len(out) > 0 && out[len(out)-1] == "" {
out = out[:len(out)-1]
}
return out
}
// taskStatusColor maps a task status to a palette color for the board / detail header.
func taskStatusColor(t Theme, status string) color.Color {
switch status {
case "DONE":
return t.P.OK
case "IN_PROGRESS":
return t.P.Accent
case "IN_REVIEW":
return t.P.Accent2
case "BLOCKED":
return t.P.Bad
case "CANCELLED":
return t.P.Dim
default: // TODO
return t.P.Faint
}
}
@@ -0,0 +1,186 @@
package app
import (
"strings"
"testing"
)
func tasksModel() Model {
m := NewModel(nil)
m.width, m.height = 120, 40
m.theme = NewTheme(SoftBlue)
return m
}
// sampleTasks is a deterministic two-row board used across the tests.
func sampleTasks() []TaskSummary {
return []TaskSummary{
{
ID: "auth-1", Key: "auth-1", Status: "IN_PROGRESS", Title: "JWT refresh",
Goal: "users stay authenticated", AcceptanceCriteria: []string{"rotates"},
Claimant: "aaaaaaaa-1111",
Links: []TaskLinkWire{{TargetID: "adr-7", Type: "IMPLEMENTS", TargetKind: "DOC"}},
Notes: []TaskNoteWire{{Author: "AGENT", Body: "kickoff"}},
},
{ID: "billing-3", Key: "billing-3", Status: "DONE", Title: "Invoice export"},
}
}
func TestTasksOverlayToggle(t *testing.T) {
m := tasksModel()
// `T` opens the board from the idle list.
updated, _ := m.handleNormalKey(keyMsg{Type: keyRunes, Runes: []rune("T")})
m = updated.(Model)
if m.overlay != OverlayTasks {
t.Fatalf("expected OverlayTasks after T, got %d", m.overlay)
}
if !m.taskListLoading {
t.Fatalf("expected loading state right after open")
}
// `esc` closes it (routed through the overlay key handler).
updated, _ = m.handleOverlayKey(keyMsg{Type: keyEsc})
m = updated.(Model)
if m.overlay != OverlayNone {
t.Fatalf("expected overlay closed after esc, got %d", m.overlay)
}
}
func TestTasksLoadedPopulatesAndRenders(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskListLoading = true
m.applyTasksLoaded(tasksLoadedMsg{tasks: sampleTasks()})
if m.taskListLoading {
t.Fatalf("expected loading cleared after load")
}
if len(m.taskList) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(m.taskList))
}
out := m.tasksModal()
if !strings.Contains(out, "auth-1") || !strings.Contains(out, "billing-3") {
t.Fatalf("modal missing task ids:\n%s", out)
}
if !strings.Contains(out, "IN_PROGRESS") || !strings.Contains(out, "DONE") {
t.Fatalf("modal missing statuses:\n%s", out)
}
}
func TestTasksEnterOpensDetailWithBundleFields(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = sampleTasks()
m.taskListIndex = 0
updated, _ := m.handleTasksKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if !m.taskDetail {
t.Fatalf("expected detail pane open after enter")
}
out := m.tasksModal()
for _, want := range []string{"users stay authenticated", "rotates", "adr-7", "kickoff"} {
if !strings.Contains(out, want) {
t.Fatalf("detail missing %q:\n%s", want, out)
}
}
// esc returns to the list, not all the way out.
updated, _ = m.handleTasksKey(keyMsg{Type: keyEsc})
m = updated.(Model)
if m.taskDetail {
t.Fatalf("expected detail closed after esc")
}
if m.overlay != OverlayTasks {
t.Fatalf("expected to stay on the board after esc from detail, got %d", m.overlay)
}
}
func TestTasksNavigationBoundsChecked(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = sampleTasks()
// Up at the top is a no-op (clamped at 0).
updated, _ := m.handleTasksKey(keyMsg{Type: keyUp})
m = updated.(Model)
if m.taskListIndex != 0 {
t.Fatalf("expected index clamped to 0 at top, got %d", m.taskListIndex)
}
// Down then clamped at the last row.
updated, _ = m.handleTasksKey(keyMsg{Type: keyDown})
m = updated.(Model)
updated, _ = m.handleTasksKey(keyMsg{Type: keyDown})
m = updated.(Model)
if m.taskListIndex != 1 {
t.Fatalf("expected index clamped to 1 at bottom, got %d", m.taskListIndex)
}
}
func TestTasksFilterNarrowsList(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = sampleTasks()
// `/` enters filter typing; a query narrows the board to matching rows.
updated, _ := m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("/")})
m = updated.(Model)
if !m.taskFilterTyping {
t.Fatalf("expected filter typing after /")
}
for _, r := range "invoice" {
updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune{r}})
m = updated.(Model)
}
if got := m.filteredTasks(); len(got) != 1 || got[0].ID != "billing-3" {
t.Fatalf("expected only billing-3 to match 'invoice', got %+v", got)
}
// enter stops editing but keeps the query; the selected task is the filtered one.
updated, _ = m.handleTasksKey(keyMsg{Type: keyEnter})
m = updated.(Model)
if m.taskFilterTyping {
t.Fatalf("expected typing to stop on enter")
}
if sel, ok := m.selectedTask(); !ok || sel.ID != "billing-3" {
t.Fatalf("expected selected task billing-3 under filter, got %+v ok=%v", sel, ok)
}
// `c` clears the filter back to the full board.
updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("c")})
m = updated.(Model)
if len(m.filteredTasks()) != 2 {
t.Fatalf("expected full board after clear, got %d", len(m.filteredTasks()))
}
}
func TestTasksReadyAndBlockedSurfaced(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskList = []TaskSummary{
{ID: "webui-2", Key: "webui-2", Status: "TODO", Title: "Scaffold app", Ready: true},
{ID: "webui-3", Key: "webui-3", Status: "TODO", Title: "Auth view", BlockedBy: []string{"webui-2"}},
}
// A ready task's detail says so; a blocked task's detail names what it waits on.
m.taskDetail = true
m.taskListIndex = 0
if out := m.tasksModal(); !strings.Contains(out, "ready to work") {
t.Fatalf("expected ready marker in detail:\n%s", out)
}
m.taskListIndex = 1
if out := m.tasksModal(); !strings.Contains(out, "waiting on webui-2") {
t.Fatalf("expected blocked-by detail naming the blocker:\n%s", out)
}
}
func TestTasksFetchErrorShownNoPanic(t *testing.T) {
m := tasksModel()
m.overlay = OverlayTasks
m.taskListLoading = true
m.applyTasksLoaded(tasksLoadedMsg{err: "server returned 503 Service Unavailable"})
if m.taskListLoading {
t.Fatalf("expected loading cleared on error")
}
if m.taskListErr == "" {
t.Fatalf("expected error recorded")
}
out := m.tasksModal()
if !strings.Contains(out, "503") {
t.Fatalf("modal should surface the fetch error:\n%s", out)
}
}
+30 -26
View File
@@ -1,36 +1,40 @@
package app
import "github.com/charmbracelet/lipgloss"
import (
"image/color"
"charm.land/lipgloss/v2"
)
// Palette is the "Soft" chrome from docs/visual/ref with the blue accent:
// rounded borders, opaque dark panels, generous spacing.
type Palette struct {
Bg lipgloss.Color // panel/background fill
BgDeep lipgloss.Color // outermost backdrop (slightly darker)
BgPanel lipgloss.Color // inside-panel fill
Sel lipgloss.Color // selection row background
Border lipgloss.Color // idle panel border
BorderHi lipgloss.Color // active panel border
Fg lipgloss.Color // primary text
FgStrong lipgloss.Color // headings / emphasis
Dim lipgloss.Color // labels, secondary text
Faint lipgloss.Color // tertiary, hints
Accent lipgloss.Color // blue accent
Accent2 lipgloss.Color // secondary accent
Bg color.Color // panel/background fill
BgDeep color.Color // outermost backdrop (slightly darker)
BgPanel color.Color // inside-panel fill
Sel color.Color // selection row background
Border color.Color // idle panel border
BorderHi color.Color // active panel border
Fg color.Color // primary text
FgStrong color.Color // headings / emphasis
Dim color.Color // labels, secondary text
Faint color.Color // tertiary, hints
Accent color.Color // blue accent
Accent2 color.Color // secondary accent
OK lipgloss.Color
Warn lipgloss.Color
Bad lipgloss.Color
OK color.Color
Warn color.Color
Bad color.Color
DiffAdd lipgloss.Color // added-line cell background
DiffDel lipgloss.Color // removed-line cell background
DiffAdd color.Color // added-line cell background
DiffDel color.Color // removed-line cell background
CatLifecycle lipgloss.Color
CatContext lipgloss.Color
CatInference lipgloss.Color
CatTool lipgloss.Color
CatDomain lipgloss.Color
CatApproval lipgloss.Color
CatLifecycle color.Color
CatContext color.Color
CatInference color.Color
CatTool color.Color
CatDomain color.Color
CatApproval color.Color
}
// SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the
@@ -91,7 +95,7 @@ type Theme struct {
// NewTheme builds the style set for a palette.
func NewTheme(p Palette) Theme {
base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg)
roundBase := func(border lipgloss.Color) lipgloss.Style {
roundBase := func(border color.Color) lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(border).
@@ -128,7 +132,7 @@ func NewTheme(p Palette) Theme {
}
// categoryColor maps an event category label to its accent color.
func (t Theme) categoryColor(cat string) lipgloss.Color {
func (t Theme) categoryColor(cat string) color.Color {
switch cat {
case "Lifecycle":
return t.P.CatLifecycle
@@ -0,0 +1,47 @@
package app
import (
"strings"
"testing"
)
// The collapsed tool row for a write/edit must summarise the diff by its row count (what
// ^x shows), not the raw diff byte length that used to read as a meaningless "N chars".
func TestToolRowSummary_DiffReportsRows(t *testing.T) {
diff := "--- a/f\n+++ b/f\n@@ -1,2 +1,3 @@\n context\n-old line\n+new line\n+added line\n"
got := toolRowSummary(diff)
wantRows := previewRowCount(diff)
if !strings.Contains(got, "diff (") || !strings.Contains(got, itoa(wantRows)+" rows") {
t.Fatalf("diff summary = %q, want a diff row count of %d", got, wantRows)
}
if strings.Contains(got, "tool output") {
t.Errorf("a diff should not be labelled 'tool output': %q", got)
}
}
// Non-diff output reports a true character (rune) count, not a byte count.
func TestToolRowSummary_NonDiffCountsRunes(t *testing.T) {
content := "café — déjà" // 11 runes, but 14 bytes (é, —, à are multi-byte)
got := toolRowSummary(content)
if !strings.Contains(got, "(11 chars)") {
t.Fatalf("non-diff summary = %q, want 11 chars (runes, not bytes)", got)
}
}
// A changed line whose content starts with "++"/"--" must still be counted (and rendered):
// requiring the trailing space on the file-header guard prevents mistaking it for a header.
func TestDiffSummary_CountsDoublePrefixContentLines(t *testing.T) {
// The added line's content is "++flag"; in the diff that's "+" + "++flag" = "+++flag".
diff := "--- a/f\n+++ b/f\n@@ -0,0 +1,2 @@\n+++flag\n+normal\n"
_, added, removed := diffSummary(diff)
if added != 2 {
t.Errorf("added = %d, want 2 (the '+++flag' content line must count)", added)
}
if removed != 0 {
t.Errorf("removed = %d, want 0", removed)
}
// The renderer must keep the line too (2 add rows), not drop it as a header.
if n := previewRowCount(diff); n != 2 {
t.Errorf("rendered rows = %d, want 2", n)
}
}
@@ -0,0 +1,67 @@
package app
import "testing"
func transcriptModel(roles ...string) Model {
m := NewModel(nil)
m.selectedID = "s1"
msgs := make([]RouterEntry, len(roles))
for i, r := range roles {
msgs[i] = RouterEntry{Role: r, Content: r + itoa(i)}
}
m.routerMessages["s1"] = msgs
return m
}
// ctrl+↑/↓ jumps between USER messages: from no selection ↑ grabs the most recent user
// message, keeps stepping to older ones (clamping), and ↓ steps back down, dropping to
// tail-follow (-1) once past the last user message.
func TestTranscriptNavUser(t *testing.T) {
// indices: 0 1 2 3 4
m := transcriptModel("user", "router", "user", "router", "user")
if m.transcriptSel != -1 {
t.Fatalf("initial sel = %d, want -1", m.transcriptSel)
}
m.transcriptNavUser(-1) // up from bottom → newest user (idx 4)
if m.transcriptSel != 4 {
t.Fatalf("up#1 = %d, want 4", m.transcriptSel)
}
m.transcriptNavUser(-1) // → user idx 2
if m.transcriptSel != 2 {
t.Fatalf("up#2 = %d, want 2", m.transcriptSel)
}
m.transcriptNavUser(-1) // → user idx 0
if m.transcriptSel != 0 {
t.Fatalf("up#3 = %d, want 0", m.transcriptSel)
}
m.transcriptNavUser(-1) // clamp at oldest
if m.transcriptSel != 0 {
t.Fatalf("up#4 (clamp) = %d, want 0", m.transcriptSel)
}
m.transcriptNavUser(1) // → user idx 2
if m.transcriptSel != 2 {
t.Fatalf("down#1 = %d, want 2", m.transcriptSel)
}
m.transcriptNavUser(1) // → user idx 4
m.transcriptNavUser(1) // past last user → tail-follow
if m.transcriptSel != -1 {
t.Fatalf("down past last = %d, want -1 (tail-follow)", m.transcriptSel)
}
}
// `y` copies the selected message, or the latest user/router turn when nothing is selected.
func TestCopyText(t *testing.T) {
m := transcriptModel("user", "router", "tool")
// no selection → most recent user/router turn (the "router" at idx 1, since "tool"
// is skipped).
if got := m.copyText(); got != "router1" {
t.Fatalf("copyText (no sel) = %q, want %q", got, "router1")
}
m.transcriptSel = 0
if got := m.copyText(); got != "user0" {
t.Fatalf("copyText (sel idx 0) = %q, want %q", got, "user0")
}
}
File diff suppressed because it is too large Load Diff
+446 -174
View File
@@ -2,10 +2,13 @@ package app
import (
"fmt"
"image/color"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
@@ -27,8 +30,63 @@ const (
// and full-width chrome.
func (m Model) narrow() bool { return m.width < narrowWidth }
// View renders the whole frame purely from the model (immediate-mode).
func (m Model) View() string {
// cursorMarker is a private-use rune the composer drops into the caret cell so
// View() can locate it in the fully-composited frame and place a real terminal
// cursor there (see splitCursor). It measures one display column — same as the
// "▏" glyph it stands in for — so the layout is identical whether it resolves to
// a live cursor or the static glyph.
const cursorMarker = ""
// View renders the whole frame purely from the model (immediate-mode). In
// bubbletea v2 the View carries terminal state, so renderRaw() builds the string
// and View() resolves the caret to a real blinking cursor + sets the window title.
func (m Model) View() tea.View {
content, cur := splitCursor(m.renderRaw(), " ")
return tea.View{Content: content, AltScreen: true, Cursor: cur, WindowTitle: m.windowTitle()}
}
// render returns the frame as a plain string with the caret drawn as the static
// "▏" glyph — for the preview tool and golden tests, which have no live cursor.
func (m Model) render() string {
content, _ := splitCursor(m.renderRaw(), "▏")
return content
}
// splitCursor finds the caret marker in a composited frame, replaces it with repl
// (a space for the live cursor cell, "▏" for static), and returns the cursor's
// screen position — nil when no marker is present (e.g. vim normal mode), which
// tells bubbletea to hide the cursor.
func splitCursor(raw, repl string) (string, *tea.Cursor) {
idx := strings.Index(raw, cursorMarker)
if idx < 0 {
return raw, nil
}
pre := raw[:idx]
y := strings.Count(pre, "\n")
lineStart := strings.LastIndex(pre, "\n") + 1 // 0 when the marker is on the first line
x := ansi.StringWidth(raw[lineStart:idx])
clean := strings.Replace(raw, cursorMarker, repl, 1)
cur := tea.NewCursor(x, y)
cur.Shape = tea.CursorBar
return clean, cur
}
// windowTitle names the terminal window/tab after the active session, falling
// back to the model name and then the bare program name.
func (m Model) windowTitle() string {
if m.displayState() == StateInSession {
if s := m.session(m.selectedID); s != nil && s.Name != "" {
return "correx — " + s.Name
}
}
if m.currentModel != "" {
return "correx — " + m.currentModel
}
return "correx"
}
// renderRaw builds the frame string (caret rendered as cursorMarker in insert mode).
func (m Model) renderRaw() string {
if m.quitting {
return ""
}
@@ -36,11 +94,15 @@ func (m Model) View() string {
return m.theme.Screen.Render("correx — terminal too small")
}
bottom := m.renderInput()
bottomH := inputH
if m.displayState() == StateApproval {
ds := m.displayState()
bottom, bottomH := m.renderInput(), m.inputBarHeight()
switch {
case ds == StateApproval:
bottomH = m.approvalBandHeight()
bottom = m.renderApprovalBand(bottomH)
case ds == StateIdle:
// Launcher: the input is centered inside the main area, so there's no bottom bar.
bottom, bottomH = "", 0
}
mainH := m.height - statusH - footerH - bottomH
@@ -48,12 +110,14 @@ func (m Model) View() string {
mainH = 3
}
base := lipgloss.JoinVertical(lipgloss.Left,
m.renderStatus(),
m.renderMain(mainH),
bottom,
m.renderFooter(),
)
sections := []string{m.renderStatus(), m.renderMain(mainH)}
if bottomH > 0 {
sections = append(sections, bottom)
}
sections = append(sections, m.renderFooter())
base := lipgloss.JoinVertical(lipgloss.Left, sections...)
// Stash the base so center() can composite a modal over a dimmed copy of it.
m.lastBase = base
if m.displayState() == StateClarification {
return m.center(m.clarificationModal())
@@ -72,7 +136,7 @@ func (m Model) View() string {
func (m Model) renderStatus() string {
t := m.theme
bg := t.P.BgPanel
span := func(s string, fg lipgloss.Color) string {
span := func(s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s)
}
nrw := m.narrow()
@@ -93,29 +157,31 @@ 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 != "" {
if s.CurrentStage != "" && m.sbShow("stage") {
parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2))
}
if s.Status != "" {
if s.Status != "" && m.sbShow("status") {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
}
if s.WorkspaceRoot != "" && !nrw {
if s.WorkspaceRoot != "" && !nrw && m.sbShow("workspace") {
parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
}
}
model := m.currentModel
if model == "" {
model = "no model"
}
if nrw {
parts = append(parts, span(model, t.P.Fg))
} else {
loc := "local"
if m.providerType == "REMOTE" {
loc = "remote"
if m.sbShow("model") {
model := m.currentModel
if model == "" {
model = "no model"
}
if nrw {
parts = append(parts, span(model, t.P.Fg))
} else {
loc := "local"
if m.providerType == "REMOTE" {
loc = "remote"
}
parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
}
parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint))
}
left := strings.Join(parts, sep)
@@ -123,7 +189,7 @@ func (m Model) renderStatus() string {
// Last-event clock (§2): always visible in-session, so a silent agent is never
// mistaken for a healthy one. Updates every frame tick; turns warn-colored when an
// active session has gone quiet past the stale threshold.
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle && m.sbShow("clock") {
gap := nowMillis() - s.LastEventAt
clockFg := t.P.Faint
if s.Active && gap > staleEventThresholdMs {
@@ -135,7 +201,7 @@ func (m Model) renderStatus() string {
}
right = append(right, span(label, clockFg))
}
if g := m.gaugeText(); g != "" && !nrw {
if g := m.gaugeText(); g != "" && !nrw && m.sbShow("gauge") {
right = append(right, span(g, t.P.Faint))
}
if s := m.session(m.selectedID); s != nil && s.Active {
@@ -221,6 +287,16 @@ func (m Model) renderFooter() string {
switch {
case m.editMode == ModeInsert:
hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")}
if m.inputMode != ModeFilter {
hints = append(hints, hint("^J", "newline"))
}
// The `/` command menu only triggers on an empty chat line (mid-line `/` is literal).
if m.inputMode == ModeRouter && m.inputBuffer == "" {
hints = append(hints, hint("/", "cmds"))
}
if m.inputMode == ModeRouter {
hints = append(hints, hint("@", "files"))
}
case m.displayState() == StateApproval:
// Approval actions live in the band itself; the footer offers navigation only.
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}
@@ -232,15 +308,20 @@ func (m Model) renderFooter() string {
hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")}
case m.displayState() == StateIdle:
if nrw {
hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("p", "cmds"), hint("q", "quit")}
hints = []string{hint("i", "type"), hint("Tab", "wf"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
} 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", "type"), hint("Tab", "workflow"), hint("R", "resume"), hint("?", "keys"), hint("p", "cmds"), hint("q", "quit")}
}
default: // StateInSession
if nrw {
hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("H", "health"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
hints = []string{hint("i", "msg"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
} else {
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("H", "health"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
// Trimmed to fit ~100 cols: tools/stats/model are all reachable via `p cmds`,
// so the footer keeps the high-traffic keys + the new transcript nav/copy.
hints = []string{hint("i", "message"), hint("^↑↓", "msgs"), hint("y", "copy"), hint("e", "events"), hint("d", "panel"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
}
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
hints = append(hints, lipgloss.NewStyle().Foreground(t.P.OK).Background(bg).Bold(true).Render("✓ copied"))
}
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
hints = append(hints, hint("a", "approval (pending)"))
@@ -262,39 +343,215 @@ func (m Model) renderFooter() string {
// --- main split ---
func (m Model) renderMain(h int) string {
if m.displayState() == StateIdle {
return m.renderLauncher(m.width, h)
}
if m.narrow() {
return m.renderMainNarrow(h)
}
leftW := m.width * 63 / 100
rightW := m.width - leftW
var leftTitle, rightTitle string
var leftBody, rightBody []string
leftActive, rightActive := false, false
// In-session / approval. The right panel cycles (d): events, changes (token usage +
// changed files), or off (output takes the full width).
if m.rightPanel == 2 {
return m.theme.box("output", m.routerRows(m.width-4, h-2), m.width, h, true)
}
left := m.theme.box("output", m.routerRows(leftW-4, h-2), leftW, h, true)
var right string
if m.rightPanel == 1 {
right = m.theme.box("changes", m.changesRows(rightW-4, h), rightW, h, false)
} else {
right = m.theme.box("events", m.eventRows(rightW-4, h), rightW, h, false)
}
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
}
switch m.displayState() {
case StateIdle:
leftTitle = "sessions"
if m.wfVisible {
leftTitle = "workflows"
leftBody = m.workflowRows(leftW - 4)
} else {
leftBody = m.sessionRows(leftW - 4)
// changesRows is the in-session "changes" panel: a token-usage line plus a git-status-style
// list of files the session has written (summed +/- from each write's diff). ^x opens the
// full diff. Drives the right panel when rightPanel == 1.
func (m Model) changesRows(w, h int) []string {
t := m.theme
turns, toks := 0, 0
for _, e := range m.routerMessages[m.selectedID] {
if e.Role == "router" && e.Metrics != nil {
turns++
toks += e.Metrics.TotalTokens
}
leftActive = true
rightTitle = "welcome"
rightBody = m.welcomeRows(rightW - 4)
case StateInSession, StateApproval:
leftTitle = "output"
leftBody = m.routerRows(leftW-4, h-2)
leftActive = true
rightTitle = "events"
rightBody = m.eventRows(rightW-4, h)
}
rows := []string{
t.span("tokens ", t.P.Faint) + t.span(itoa(toks), t.P.FgStrong) +
t.span(" · turns ", t.P.Faint) + t.span(itoa(turns), t.P.Dim),
"",
}
left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive)
right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive)
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
type chg struct{ add, del int }
var order []string
seen := map[string]*chg{}
for _, e := range m.routerMessages[m.selectedID] {
if e.Role != "tool" || !isUnifiedDiff(e.Content) {
continue
}
p, a, d := diffSummary(e.Content)
if seen[p] == nil {
seen[p] = &chg{}
order = append(order, p)
}
seen[p].add += a
seen[p].del += d
}
if len(order) == 0 {
return append(rows, t.span("no file changes yet", t.P.Faint))
}
rows = append(rows, t.span("changes (^x for full diff)", t.P.Faint))
for _, p := range order {
c := seen[p]
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("✎ ")
delta := lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("+"+itoa(c.add)) +
t.span(" ", t.P.Bg) + lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render(""+itoa(c.del))
rows = append(rows, icon+t.span(truncate(p, w-16), t.P.Fg)+" "+delta)
}
return rows
}
// renderLauncher is the idle screen: a compact, opencode-style input centered in the main
// area, with a hideable right rail of status + quick keys. The session list isn't shown —
// it's a keypress away (R). The input's lower-right shows <workflow> · <model>, Tab-cycled.
func (m Model) renderLauncher(w, h int) string {
t := m.theme
railW := 26
if m.railHidden || m.narrow() || w < 66 {
railW = 0
}
leftW := w - railW
inW := leftW * 82 / 100
if inW > 84 {
inW = 84
}
if inW < 24 {
inW = 24
}
left := lipgloss.Place(leftW, h, lipgloss.Center, lipgloss.Center, m.launcherInput(inW),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
if railW == 0 {
return left
}
rail := lipgloss.Place(railW, h, lipgloss.Left, lipgloss.Top, m.launcherRail(railW),
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.P.Bg)))
return lipgloss.JoinHorizontal(lipgloss.Top, left, rail)
}
// maxInputLines caps how many lines a growing (multi-line) input box shows; beyond it the
// box scrolls to keep the cursor's tail in view.
const maxInputLines = 6
// editorLines renders the input buffer as styled display lines with the caret at the cursor,
// so multi-line input (Ctrl+J / Alt+Enter) shows across rows. Tabs are flattened to a space.
func (m Model) editorLines() []string {
t := m.theme
caret := ""
// Only the focused composer (no modal over it) owns the live cursor; an open
// overlay keeps editMode==Insert but draws its own filter caret instead.
if m.editMode == ModeInsert && m.overlay == OverlayNone {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
}
buf := m.inputBuffer
cur := m.inputCursor
if cur > len(buf) {
cur = len(buf)
}
if cur < 0 {
cur = 0
}
style := func(s string) string { return t.span(strings.ReplaceAll(s, "\t", " "), t.P.FgStrong) }
before := strings.Split(buf[:cur], "\n")
after := strings.Split(buf[cur:], "\n")
var lines []string
for _, ln := range before[:len(before)-1] {
lines = append(lines, style(ln))
}
lines = append(lines, style(before[len(before)-1])+caret+style(after[0]))
for _, ln := range after[1:] {
lines = append(lines, style(ln))
}
if len(lines) > maxInputLines {
lines = lines[len(lines)-maxInputLines:]
}
return lines
}
// launcherInput renders the compact (growing) input box plus a status sub-line
// (<workflow> · <model> left, action hints right).
func (m Model) launcherInput(w int) string {
t := m.theme
insert := m.editMode == ModeInsert
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(" ")
indent := t.span(" ", t.P.Bg)
var content []string
if m.inputBuffer == "" && !insert {
content = []string{prompt + t.span("Ask anything…", t.P.Faint)}
} else {
for i, ln := range m.editorLines() {
if i == 0 {
content = append(content, prompt+ln)
} else {
content = append(content, indent+ln)
}
}
}
box := t.box("", content, w, len(content)+2, insert)
wf := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(m.launcherWfName())
leftSub := " " + wf + t.span(" · ", t.P.Faint) + t.span(m.currentModel, t.P.Faint)
hints := t.span("Tab wf · ⌥↵/^J newline · enter ↵ ", t.P.Faint)
return box + "\n" + m.justify(leftSub, hints, w, t.P.Bg)
}
// launcherRail is the idle status + quick-keys panel on the right.
func (m Model) launcherRail(w int) string {
t := m.theme
var rows []string
if m.connected {
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● connected"))
} else {
rows = append(rows, lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("● offline"))
}
count := t.span(plural(len(m.sessions), "session"), t.P.Dim)
if a := m.activeSessionCount(); a > 0 {
count += t.span(" · "+itoa(a)+" active", t.P.Faint)
}
key := func(k, d string) string {
return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(padRaw(k, 4)) + t.span(d, t.P.Dim)
}
rows = append(rows, count, "",
key("i", "type"),
key("Tab", "workflow"),
key("R", "resume"),
key("?", "keys"),
key("p", "commands"),
)
return t.box("correx", rows, w, len(rows)+2, false)
}
// launcherWfName is the active launch target: "chat" (default) or a workflow id, Tab-cycled.
func (m Model) launcherWfName() string {
if m.launcherWf <= 0 || m.launcherWf > len(m.workflows) {
return "chat"
}
return m.workflows[m.launcherWf-1].ID
}
// activeSessionCount is the number of sessions not in a terminal (completed/failed) state.
func (m Model) activeSessionCount() int {
n := 0
for _, s := range m.sessions {
u := strings.ToUpper(s.Status)
if !strings.Contains(u, "COMPLETE") && !strings.Contains(u, "FAIL") {
n++
}
}
return n
}
// renderMainNarrow gives the main area a single full-width column when the terminal
@@ -303,15 +560,8 @@ func (m Model) renderMain(h int) string {
// — the strip is too valuable to drop, the constraint is width not height).
func (m Model) renderMainNarrow(h int) string {
w := m.width
if m.displayState() == StateIdle {
title := "sessions"
body := m.sessionRows(w - 4)
if m.wfVisible {
title, body = "workflows", m.workflowRows(w-4)
}
return m.theme.box(title, body, w, h, true)
}
// in-session / approval: stack output over events.
// Idle is handled by renderLauncher (which drops the rail when narrow); this only
// runs for in-session / approval: stack output over events.
outH := h * 55 / 100
if outH < 3 {
outH = 3
@@ -351,17 +601,17 @@ func (m Model) renderInput() string {
insert := m.editMode == ModeInsert
caret := t.span(" ", t.P.Bg)
if insert && m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏")
if insert && m.overlay == OverlayNone {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render(cursorMarker)
}
var line string
var content []string
switch {
case m.inputBuffer == "" && !insert:
line = t.span(placeholder, t.P.Faint)
content = []string{t.span(placeholder, t.P.Faint)}
case m.inputBuffer == "":
line = caret + t.span(placeholder, t.P.Faint)
content = []string{caret + t.span(placeholder, t.P.Faint)}
default:
line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret
content = m.editorLines()
}
// status sub-line: <session/none> · <uuid> · <mode>
@@ -387,115 +637,104 @@ func (m Model) renderInput() string {
sub += t.span(" · ", t.P.Faint) +
t.span(mode, t.P.Dim)
body := []string{line, sub}
return t.box("", body, m.width, inputH, insert)
body := append(content, sub)
return t.box("", body, m.width, len(body)+2, insert)
}
// inputBarHeight is the bottom input bar's current height (it grows with multi-line input):
// content lines (capped at maxInputLines) + the status sub-line + 2 borders.
func (m Model) inputBarHeight() int {
n := 1
if m.inputBuffer != "" {
n = len(m.editorLines())
}
return n + 1 + 2
}
// --- body row builders ---
func (m Model) sessionRows(w int) []string {
t := m.theme
list := m.filteredSessions()
if len(list) == 0 {
return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)}
// shortDateTime formats a millis-since-epoch instant as a compact local timestamp for the
// session list: "Jan 02 15:04" within the current year, "Jan 02 2006" for older years.
// Empty for a zero/absent time (renders as no date rather than a bogus epoch).
func shortDateTime(ms int64) string {
if ms <= 0 {
return ""
}
rows := make([]string, 0, len(list))
for _, s := range list {
sel := s.ID == m.selectedID && !m.wfVisible
short := s.ID
if len(short) > 6 {
short = short[:6]
}
nameFg := t.P.Fg
bar := t.span(" ", t.P.Bg)
if sel {
bar = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▌ ")
nameFg = t.P.FgStrong
}
idCell := t.span("["+short+"] ", t.P.Faint)
statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true).
Render(padRaw(statusLabel(s.Status), 9))
nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + s.Name)
stage := ""
if s.CurrentStage != "" {
stage = t.span(" ["+s.CurrentStage+"]", t.P.Dim)
}
rows = append(rows, bar+idCell+statusCell+nameCell+stage)
ts := time.UnixMilli(ms)
if ts.Year() != time.Now().Year() {
return ts.Format("Jan 02 2006")
}
return rows
}
func (m Model) workflowRows(w int) []string {
t := m.theme
if len(m.workflows) == 0 {
return []string{t.span("no workflows advertised", t.P.Faint)}
}
rows := make([]string, 0, len(m.workflows))
for i, wf := range m.workflows {
marker := t.span(" ", t.P.Bg)
fg := t.P.Fg
if i == m.wfIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▸ ")
fg = t.P.FgStrong
}
rows = append(rows, marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(wf.ID)+t.span(" "+wf.Description, t.P.Faint))
}
return rows
}
func (m Model) welcomeRows(w int) []string {
t := m.theme
title := lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render("Corre") +
lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("x") +
lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render(" Agent Harness")
var status string
if m.connected {
status = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("Connected") +
t.span(" · ", t.P.Faint) + t.span(plural(len(m.sessions), "session"), t.P.Dim)
} else {
status = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("Disconnected")
}
rows := []string{title, status, "",
t.span("Select a session from the left", t.P.Dim),
t.span("or type a name to start a new one.", t.P.Dim), ""}
if n := len(m.sessions); n > 0 {
rows = append(rows, t.span("recent:", t.P.Faint))
start := 0
if n > 5 {
start = n - 5
}
for _, s := range m.sessions[start:] {
mark := statusGlyph(t, s.Status)
rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(formatTime(s.LastEventAt), t.P.Faint)+" "+mark)
}
}
return rows
return ts.Format("Jan 02 15:04")
}
func (m Model) routerRows(w, h int) []string {
t := m.theme
msgs := m.routerMessages[m.selectedID]
if len(msgs) == 0 {
if len(m.routerMessages[m.selectedID]) == 0 {
return []string{t.span("awaiting router response…", t.P.Faint)}
}
rows, msgStart := m.buildTranscriptRows(w)
// With a selection, scroll so the selected message is visible (top-aligned, clamped to the
// end); otherwise honour the free-scroll offset (0 = tail-follow the newest output).
if m.transcriptSel >= 0 && m.transcriptSel < len(msgStart) && len(rows) > h {
start := msgStart[m.transcriptSel]
if start > len(rows)-h {
start = len(rows) - h
}
if start < 0 {
start = 0
}
return rows[start : start+h]
}
if len(rows) > h {
off := m.outputScroll
if max := len(rows) - h; off > max {
off = max
}
if off < 0 {
off = 0
}
end := len(rows) - off
return rows[end-h : end]
}
return rows
}
// buildTranscriptRows renders the selected session's transcript to display rows (and records each
// message's first row index for scroll-to-selection). Split out so the scroll handler can measure
// the full height against the same content the renderer windows.
func (m Model) buildTranscriptRows(w int) ([]string, []int) {
t := m.theme
msgs := m.routerMessages[m.selectedID]
var rows []string
for _, e := range msgs {
msgStart := make([]int, len(msgs)) // first row index of each message, for scroll-to-selection
for mi, e := range msgs {
msgStart[mi] = len(rows)
switch e.Role {
case "user":
if mi == m.transcriptSel {
// Selected message: an inverted tag + a highlighted background bar so it's
// unmistakable which one ctrl+↑/↓ landed on (and what `y` will copy).
tag := lipgloss.NewStyle().Foreground(t.P.BgDeep).Background(t.P.Accent).Bold(true).Render(" ")
rows = append(rows, tag+" "+lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Sel).Render(e.Content))
break
}
tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("")
rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong))
case "router":
for _, ln := range wrap(e.Content, w) {
rows = append(rows, t.span(ln, t.P.Fg))
// The router turn is model output: render it as markdown (bold,
// lists, headings, code) wrapped to the panel width. glamour applies
// its own inline foreground colors; we keep the opaque panel by
// fixing each line's background. On any failure renderMarkdown hands
// back the plain content, which still flows through this path fine.
rendered := renderMarkdown(e.Content, w)
for _, ln := range strings.Split(rendered, "\n") {
rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln))
}
if s := metricsSuffix(e.Metrics); s != "" {
rows = append(rows, t.span(s, t.P.Faint))
}
case "tool":
rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim))
rows = append(rows, t.span(toolRowSummary(e.Content), t.P.Dim))
case "narration_llm":
diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆")
lines := wrap(e.Content, w-2)
@@ -511,24 +750,23 @@ func (m Model) routerRows(w, h int) []string {
}
case "narration", "stage":
rows = append(rows, t.span(e.Content, t.P.Dim))
case "action":
// Inline "external feedback" row: a side-effecting action (tool call, write/edit,
// approval, grant) surfaced in the conversation flow. Muted via actionsHidden.
if m.actionsHidden {
break
}
icon := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(e.Icon)
rows = append(rows, " "+icon+" "+t.span(e.Content, t.P.Dim))
}
}
// keep last h rows
if len(rows) > h {
rows = rows[len(rows)-h:]
}
return rows
return rows, msgStart
}
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)
if m.connected {
header += lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live")
} else {
header += t.span("○ idle", t.P.Faint)
}
t.span(" ", t.P.Bg) + m.eventStreamBadge()
s := m.session(m.selectedID)
if s == nil || len(s.Events) == 0 {
@@ -550,6 +788,30 @@ func (m Model) eventRows(w, h int) []string {
return append([]string{header, ""}, evRows...)
}
// eventStreamBadge reflects whether the *selected session's* stream is still live, not
// just whether the socket is connected: a completed / failed / paused session emits no
// more events, so the old connection-only "● live" went stale the moment a session ended.
func (m Model) eventStreamBadge() string {
t := m.theme
if !m.connected {
return t.span("○ idle", t.P.Faint)
}
if s := m.session(m.selectedID); s != nil {
badge := func(fg color.Color, text string) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(text)
}
switch u := strings.ToUpper(s.Status); {
case strings.Contains(u, "FAIL"):
return badge(t.P.Bad, "✗ failed")
case strings.Contains(u, "COMPLETE"):
return badge(t.P.OK, "✓ done")
case strings.Contains(u, "PAUSE"):
return badge(t.P.Warn, "‖ paused")
}
}
return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live")
}
// --- width helpers ---
// shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar.
@@ -561,7 +823,7 @@ func shortPath(p string) string {
}
// fill left-justifies a styled line and pads with bg to width w.
func (m Model) fill(s string, w int, bg lipgloss.Color) string {
func (m Model) fill(s string, w int, bg color.Color) string {
return " " + padTo(s, w-1, bg)
}
@@ -569,7 +831,7 @@ func (m Model) fill(s string, w int, bg lipgloss.Color) string {
// so the line never overflows w. The left segment is shrunk first (the right holds
// the high-signal status — connection clock / active spinner); the right is clamped
// only as a last resort. ANSI-aware so truncation can't slice through escape codes.
func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
func (m Model) justify(left, right string, w int, bg color.Color) string {
avail := w - 2 // leading + trailing space
lw := lipgloss.Width(left)
rw := lipgloss.Width(right)
@@ -595,7 +857,7 @@ func (m Model) justify(left, right string, w int, bg lipgloss.Color) string {
// padTo pads (or truncates) a possibly-styled string to visible width w, filling
// with the given background so the panel stays opaque.
func padTo(s string, w int, bg lipgloss.Color) string {
func padTo(s string, w int, bg color.Color) string {
vw := lipgloss.Width(s)
if vw == w {
return s
@@ -622,7 +884,7 @@ func padRaw(s string, w int) string {
return s + strings.Repeat(" ", w-n)
}
func statusColor(t Theme, status string) lipgloss.Color {
func statusColor(t Theme, status string) color.Color {
u := strings.ToUpper(status)
switch {
case strings.Contains(u, "FAIL"):
@@ -659,7 +921,7 @@ func statusGlyph(t Theme, status string) string {
}
// span renders text with foreground fg over the panel background.
func (t Theme) span(s string, fg lipgloss.Color) string {
func (t Theme) span(s string, fg color.Color) string {
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s)
}
@@ -739,7 +1001,17 @@ func itoa(n int) string {
return string(b[i:])
}
func itoaLen(s string) string { return itoa(len(s)) }
// toolRowSummary collapses a tool-output transcript entry into a one-line pointer. A
// write/edit entry holds a unified diff, so summarise it by what ^x actually shows — the
// diff's row count — instead of the raw diff byte length (which read as a meaningless
// "N chars": it counted +/-/@@/header bytes, not anything the operator cares about, and
// len() is bytes not characters). Non-diff output falls back to a true character count.
func toolRowSummary(content string) string {
if isUnifiedDiff(content) {
return "· diff (" + itoa(previewRowCount(content)) + " rows) — ^x to view"
}
return "· tool output (" + itoa(len([]rune(content))) + " chars) — ^x to view"
}
// metricsSuffix formats the faint latency+token annotation for a ROUTER turn.
func metricsSuffix(mtr *TurnMetrics) string {
+37 -1
View File
@@ -56,6 +56,8 @@ const (
TypeSessionStats = "session.stats"
TypeIdeaList = "idea.list"
TypeHealthChecks = "health.checks"
TypeFileList = "file.list"
TypeGrantList = "grant.list"
)
// ServerMessage is a flat decode of every server->client variant. Field names
@@ -154,6 +156,23 @@ type ServerMessage struct {
// idea.list — the cross-session idea board
Ideas []IdeaDto `json:"ideas"`
// file.list — the session workspace's file paths (for the @ file-ref picker)
Paths []string `json:"paths"`
// grant.list — the active cross-session (PROJECT/GLOBAL) standing grants
Grants []GrantDto `json:"grants"`
}
// GrantDto is one standing cross-session grant. Mirrors the Kotlin GrantDto.
type GrantDto struct {
GrantID string `json:"grantId"`
Scope string `json:"scope"` // "PROJECT" | "GLOBAL"
ToolName string `json:"toolName"`
ProjectID string `json:"projectId"`
Tiers []string `json:"tiers"`
Reason string `json:"reason"`
ExpiresAtMs *int64 `json:"expiresAtMs"`
}
// IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto.
@@ -410,6 +429,12 @@ func ListArtifacts(sessionID string) []byte {
return encode("ListArtifacts", map[string]any{"sessionId": sessionID})
}
// ListFiles asks the server for the session workspace's file paths (replied to with file.list),
// used to populate the @ file-reference picker.
func ListFiles(sessionID string) []byte {
return encode("ListFiles", map[string]any{"sessionId": sessionID})
}
// GetSessionStats asks the server for a session's derived metrics (replied to with session.stats).
func GetSessionStats(sessionID string) []byte {
return encode("GetSessionStats", map[string]any{"sessionId": sessionID})
@@ -471,7 +496,8 @@ func ClearModelPin() []byte {
}
// CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate.
// scope is "SESSION" or "STAGE"; permittedTiers are tier names like "T3".
// scope is "SESSION", "PROJECT", or "GLOBAL"; permittedTiers are tier names like "T3".
// For PROJECT/GLOBAL the server derives the projectId from the session's workspace.
func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte {
return encode("CreateGrant", map[string]any{
"sessionId": sessionID,
@@ -484,6 +510,16 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN
})
}
// RevokeGrant revokes a standing (PROJECT/GLOBAL) grant by id; replies with a fresh grant.list.
func RevokeGrant(grantID string) []byte {
return encode("RevokeGrant", map[string]any{"grantId": grantID})
}
// ListGrants requests the active cross-session grants (replied to with a grant.list).
func ListGrants() []byte {
return encode("ListGrants", map[string]any{})
}
// Hello is the first frame sent on every (re)connect. It carries the client's
// working directory so the server can bind a workspace before any session starts.
func Hello(workingDir string) []byte {
+22 -9
View File
@@ -28,23 +28,36 @@ type Status struct {
// Client is a reconnecting WebSocket client. Construct with New, then Run in a
// goroutine. Incoming and Conn are drained by the UI.
type Client struct {
url string
send chan []byte
in chan protocol.ServerMessage
conn chan Status
url string
httpBase string
send chan []byte
in chan protocol.ServerMessage
conn chan Status
}
// New builds a client targeting ws://host:port/stream.
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{
url: u.String(),
send: make(chan []byte, 64),
in: make(chan protocol.ServerMessage, 256),
conn: make(chan Status, 16),
url: u.String(),
httpBase: (&url.URL{Scheme: "http", Host: hp}).String(),
send: make(chan []byte, 64),
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 {
h := host
if h == "" {