d247b19608
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).
Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).
Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
257 lines
9.1 KiB
Go
257 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|