feat(tui): QA fixes + approval Ctrl keys, workflow focus, session UUID

- gate session-list nav on idle so in-session arrows/jk don't move the list (5a)
- approval dismiss on esc + in-session 'a' reopens a pending approval (5b)
- auto-focus a newly started workflow session (was running invisibly)
- approval actions on Ctrl chords (^a/^r/^x) matching the modal; bare/Alt letters no longer approve; diff closes on ^x/esc
- steer hint in the approval modal; session UUID in the input bar
- env-gated debug logging (CORREX_TUI_LOG) with k.Alt in the key trace
This commit is contained in:
2026-06-01 23:23:29 +04:00
parent 94f7ad0ee9
commit da3f6c84a3
7 changed files with 114 additions and 21 deletions
+47
View File
@@ -0,0 +1,47 @@
package app
import "log"
// debugLog is a no-op unless EnableDebugLog is called from main (when the
// CORREX_TUI_LOG env var is set). Tracing must never write to stderr while the
// alt-screen is active, so it is routed through the standard logger which main
// points at a file via tea.LogToFile.
var debugLog = func(string, ...any) {}
// EnableDebugLog switches tracing on, sending it through the standard logger.
func EnableDebugLog() { debugLog = log.Printf }
func (d DisplayState) String() string {
switch d {
case StateIdle:
return "Idle"
case StateInSession:
return "InSession"
case StateApproval:
return "Approval"
default:
return "?"
}
}
func (i InputMode) String() string {
switch i {
case ModeRouter:
return "Router"
case ModeFilter:
return "Filter"
default:
return "?"
}
}
func (e EditMode) String() string {
switch e {
case ModeNormal:
return "Normal"
case ModeInsert:
return "Insert"
default:
return "?"
}
}
+3 -2
View File
@@ -147,8 +147,9 @@ type Model struct {
savedBuffer string savedBuffer string
// flow flags // flow flags
sessionEntered bool sessionEntered bool
approvalDismissed bool approvalDismissed bool
pendingWorkflowFocus bool
// router transcript // router transcript
routerMessages map[string][]RouterEntry routerMessages map[string][]RouterEntry
+1 -1
View File
@@ -88,7 +88,7 @@ func (m Model) renderApproval(base string) string {
b.WriteString(mbg(t, m.steerBuffer, t.P.FgStrong)) b.WriteString(mbg(t, m.steerBuffer, t.P.FgStrong))
} }
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + "\n\n") b.WriteString(lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + "\n\n")
b.WriteString(modalHints(t, [][2]string{{"^a", "approve"}, {"^r", "reject"}, {"enter", "approve"}, {"^x", "diff"}, {"esc", "later"}})) b.WriteString(modalHints(t, [][2]string{{"^a", "approve"}, {"^r", "reject"}, {"s", "steer"}, {"enter", "approve"}, {"^x", "diff"}, {"esc", "later"}}))
modal := t.Overlay.Width(w).Render(b.String()) modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal) return m.center(modal)
+8 -1
View File
@@ -49,6 +49,7 @@ func inferCategory(t string) string {
// applyServer mutates the model for a single (non-buffered) server message. // applyServer mutates the model for a single (non-buffered) server message.
func (m *Model) applyServer(msg protocol.ServerMessage) { func (m *Model) applyServer(msg protocol.ServerMessage) {
debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason)
switch msg.Type { switch msg.Type {
case protocol.TypeSessionStarted: case protocol.TypeSessionStarted:
m.onSessionStarted(msg) m.onSessionStarted(msg)
@@ -244,7 +245,13 @@ func (m *Model) onSessionStarted(msg protocol.ServerMessage) {
if hadOptimistic || m.selectedID == "" { if hadOptimistic || m.selectedID == "" {
m.selectedID = msg.SessionID m.selectedID = msg.SessionID
} }
m.sessionEntered = true if m.pendingWorkflowFocus {
m.selectedID = msg.SessionID
m.sessionEntered = true
m.pendingWorkflowFocus = false
} else {
m.sessionEntered = true
}
} }
func (m *Model) onApprovalRequired(msg protocol.ServerMessage) { func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
+35 -14
View File
@@ -93,6 +93,9 @@ func (m *Model) applyServerPhased(msg protocol.ServerMessage) {
// --- key handling --- // --- key handling ---
func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s",
k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay,
m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID)
// Ctrl+C is a universal hard-quit safety; everything else is bare-key modal. // Ctrl+C is a universal hard-quit safety; everything else is bare-key modal.
if k.Type == tea.KeyCtrlC { if k.Type == tea.KeyCtrlC {
m.quitting = true m.quitting = true
@@ -120,8 +123,28 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case tea.KeyEnter: case tea.KeyEnter:
return m.normalEnter() return m.normalEnter()
case tea.KeyEsc: case tea.KeyEsc:
if ds == StateApproval {
m.approvalDismissed = true
return m, nil
}
m.filter = "" m.filter = ""
return m, nil return m, nil
case tea.KeyCtrlA:
if ds == StateApproval {
return m.decide("APPROVE")
}
return m, nil
case tea.KeyCtrlR:
if ds == StateApproval {
return m.decide("REJECT")
}
return m, nil
case tea.KeyCtrlX:
if m.currentDiff() != "" {
m.overlay = OverlayDiff
m.diffScrollOffset = 0
}
return m, nil
} }
if k.Type != tea.KeyRunes { if k.Type != tea.KeyRunes {
return m, nil return m, nil
@@ -167,27 +190,20 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
} else { } else {
m.cycleChatMode() m.cycleChatMode()
} }
case "x":
if m.currentDiff() != "" {
m.overlay = OverlayDiff
m.diffScrollOffset = 0
}
case "c": case "c":
if m.selectedID != "" { if m.selectedID != "" {
m.client.Send(protocol.CancelSession(m.selectedID)) m.client.Send(protocol.CancelSession(m.selectedID))
} }
case "a": case "a":
if ds == StateApproval { if ds == StateInSession {
return m.decide("APPROVE") if s := m.session(m.selectedID); s != nil && s.Pending != nil {
m.approvalDismissed = false
}
} }
case "A": case "A":
if ds == StateApproval { if ds == StateApproval {
return m.autoApprove() return m.autoApprove()
} }
case "r":
if ds == StateApproval {
return m.decide("REJECT")
}
case "q": case "q":
m.quitting = true m.quitting = true
return m, tea.Quit return m, tea.Quit
@@ -277,6 +293,7 @@ func (m Model) normalEnter() (tea.Model, tea.Cmd) {
m.client.Send(protocol.StartSession(wf.ID)) m.client.Send(protocol.StartSession(wf.ID))
m.wfVisible = false m.wfVisible = false
m.wfIndex = -1 m.wfIndex = -1
m.pendingWorkflowFocus = true
return m, nil return m, nil
} }
if m.selectedID != "" { if m.selectedID != "" {
@@ -316,7 +333,7 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case k.Type == tea.KeyDown || runeIs(k, "j"): case k.Type == tea.KeyDown || runeIs(k, "j"):
m.diffScrollOffset++ m.diffScrollOffset++
case runeIs(k, "x"): case k.Type == tea.KeyCtrlX:
m.overlay = OverlayNone m.overlay = OverlayNone
} }
case OverlayEventInspector: case OverlayEventInspector:
@@ -595,7 +612,9 @@ func (m *Model) navUp() {
m.wfNav(-1) m.wfNav(-1)
return return
} }
m.listNav(-1) if m.displayState() == StateIdle {
m.listNav(-1)
}
} }
func (m *Model) navDown() { func (m *Model) navDown() {
@@ -607,7 +626,9 @@ func (m *Model) navDown() {
m.wfNav(1) m.wfNav(1)
return return
} }
m.listNav(1) if m.displayState() == StateIdle {
m.listNav(1)
}
} }
func (m *Model) wfNav(dir int) { func (m *Model) wfNav(dir int) {
+10 -3
View File
@@ -140,6 +140,9 @@ func (m Model) renderFooter() string {
hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")} hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")}
default: // StateInSession default: // StateInSession
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
hints = append(hints, hint("a", "approval (pending)"))
}
} }
left := strings.Join(hints, gap) left := strings.Join(hints, gap)
@@ -213,7 +216,7 @@ func (m Model) renderInput() string {
line = t.span(m.inputBuffer, t.P.FgStrong) + caret line = t.span(m.inputBuffer, t.P.FgStrong) + caret
} }
// status sub-line: <session/none> · <mode> // status sub-line: <session/none> · <uuid> · <mode>
name := "(no session)" name := "(no session)"
if s := m.session(m.selectedID); s != nil { if s := m.session(m.selectedID); s != nil {
name = s.Name name = s.Name
@@ -225,8 +228,12 @@ func (m Model) renderInput() string {
if m.inputMode == ModeFilter { if m.inputMode == ModeFilter {
mode = "filter" mode = "filter"
} }
sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name) + sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name)
t.span(" · ", t.P.Faint) + if m.selectedID != "" {
sub += t.span(" · ", t.P.Faint) +
t.span(m.selectedID, t.P.Faint)
}
sub += t.span(" · ", t.P.Faint) +
t.span(mode, t.P.Dim) t.span(mode, t.P.Dim)
body := []string{line, sub} body := []string{line, sub}
+10
View File
@@ -18,6 +18,16 @@ func main() {
port := flag.Int("port", 8080, "server port") port := flag.Int("port", 8080, "server port")
flag.Parse() flag.Parse()
if path := os.Getenv("CORREX_TUI_LOG"); path != "" {
f, err := tea.LogToFile(path, "tui")
if err != nil {
fmt.Fprintln(os.Stderr, "correx tui: cannot open log file:", err)
os.Exit(1)
}
defer f.Close()
app.EnableDebugLog()
}
client := ws.New(*host, *port) client := ws.New(*host, *port)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()