From da3f6c84a3adf41e4e4a65bc566f127cf0da7446 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 1 Jun 2026 23:23:29 +0400 Subject: [PATCH] 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 --- apps/tui-go/internal/app/debug.go | 47 ++++++++++++++++++++++++++ apps/tui-go/internal/app/model.go | 5 +-- apps/tui-go/internal/app/overlays.go | 2 +- apps/tui-go/internal/app/server.go | 9 ++++- apps/tui-go/internal/app/update.go | 49 ++++++++++++++++++++-------- apps/tui-go/internal/app/view.go | 13 ++++++-- apps/tui-go/main.go | 10 ++++++ 7 files changed, 114 insertions(+), 21 deletions(-) create mode 100644 apps/tui-go/internal/app/debug.go diff --git a/apps/tui-go/internal/app/debug.go b/apps/tui-go/internal/app/debug.go new file mode 100644 index 00000000..46a34c6e --- /dev/null +++ b/apps/tui-go/internal/app/debug.go @@ -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 "?" + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index ac30e1c5..203d7e39 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -147,8 +147,9 @@ type Model struct { savedBuffer string // flow flags - sessionEntered bool - approvalDismissed bool + sessionEntered bool + approvalDismissed bool + pendingWorkflowFocus bool // router transcript routerMessages map[string][]RouterEntry diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index bfc24b95..7acad342 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -88,7 +88,7 @@ func (m Model) renderApproval(base string) string { 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(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()) return m.center(modal) diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 5b607181..163f89a2 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -49,6 +49,7 @@ func inferCategory(t string) string { // applyServer mutates the model for a single (non-buffered) server message. func (m *Model) applyServer(msg protocol.ServerMessage) { + debugLog("SRV type=%s session=%s reason=%s", msg.Type, msg.SessionID, msg.Reason) switch msg.Type { case protocol.TypeSessionStarted: m.onSessionStarted(msg) @@ -244,7 +245,13 @@ func (m *Model) onSessionStarted(msg protocol.ServerMessage) { if hadOptimistic || m.selectedID == "" { 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) { diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 224c92f3..a3475a26 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -93,6 +93,9 @@ func (m *Model) applyServerPhased(msg protocol.ServerMessage) { // --- key handling --- 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. if k.Type == tea.KeyCtrlC { m.quitting = true @@ -120,8 +123,28 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: return m.normalEnter() case tea.KeyEsc: + if ds == StateApproval { + m.approvalDismissed = true + return m, nil + } m.filter = "" 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 { return m, nil @@ -167,27 +190,20 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } else { m.cycleChatMode() } - case "x": - if m.currentDiff() != "" { - m.overlay = OverlayDiff - m.diffScrollOffset = 0 - } case "c": if m.selectedID != "" { m.client.Send(protocol.CancelSession(m.selectedID)) } case "a": - if ds == StateApproval { - return m.decide("APPROVE") + if ds == StateInSession { + if s := m.session(m.selectedID); s != nil && s.Pending != nil { + m.approvalDismissed = false + } } case "A": if ds == StateApproval { return m.autoApprove() } - case "r": - if ds == StateApproval { - return m.decide("REJECT") - } case "q": m.quitting = true return m, tea.Quit @@ -277,6 +293,7 @@ func (m Model) normalEnter() (tea.Model, tea.Cmd) { m.client.Send(protocol.StartSession(wf.ID)) m.wfVisible = false m.wfIndex = -1 + m.pendingWorkflowFocus = true return m, nil } 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"): m.diffScrollOffset++ - case runeIs(k, "x"): + case k.Type == tea.KeyCtrlX: m.overlay = OverlayNone } case OverlayEventInspector: @@ -595,7 +612,9 @@ func (m *Model) navUp() { m.wfNav(-1) return } - m.listNav(-1) + if m.displayState() == StateIdle { + m.listNav(-1) + } } func (m *Model) navDown() { @@ -607,7 +626,9 @@ func (m *Model) navDown() { m.wfNav(1) return } - m.listNav(1) + if m.displayState() == StateIdle { + m.listNav(1) + } } func (m *Model) wfNav(dir int) { diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 6818a07e..c9f23241 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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")} 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")} + if s := m.session(m.selectedID); s != nil && s.Pending != nil { + hints = append(hints, hint("a", "approval (pending)")) + } } left := strings.Join(hints, gap) @@ -213,7 +216,7 @@ func (m Model) renderInput() string { line = t.span(m.inputBuffer, t.P.FgStrong) + caret } - // status sub-line: · + // status sub-line: · · name := "(no session)" if s := m.session(m.selectedID); s != nil { name = s.Name @@ -225,8 +228,12 @@ func (m Model) renderInput() string { if m.inputMode == ModeFilter { mode = "filter" } - sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name) + - t.span(" · ", t.P.Faint) + + sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name) + 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) body := []string{line, sub} diff --git a/apps/tui-go/main.go b/apps/tui-go/main.go index 19411999..9d40068b 100644 --- a/apps/tui-go/main.go +++ b/apps/tui-go/main.go @@ -18,6 +18,16 @@ func main() { port := flag.Int("port", 8080, "server port") 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) ctx, cancel := context.WithCancel(context.Background()) defer cancel()