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:
@@ -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 "?"
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,7 @@ type Model struct {
|
||||
// flow flags
|
||||
sessionEntered bool
|
||||
approvalDismissed bool
|
||||
pendingWorkflowFocus bool
|
||||
|
||||
// router transcript
|
||||
routerMessages map[string][]RouterEntry
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
if m.pendingWorkflowFocus {
|
||||
m.selectedID = msg.SessionID
|
||||
m.sessionEntered = true
|
||||
m.pendingWorkflowFocus = false
|
||||
} else {
|
||||
m.sessionEntered = true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
||||
|
||||
@@ -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,8 +612,10 @@ func (m *Model) navUp() {
|
||||
m.wfNav(-1)
|
||||
return
|
||||
}
|
||||
if m.displayState() == StateIdle {
|
||||
m.listNav(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) navDown() {
|
||||
if m.displayState() == StateInSession && m.inputMode == ModeRouter {
|
||||
@@ -607,8 +626,10 @@ func (m *Model) navDown() {
|
||||
m.wfNav(1)
|
||||
return
|
||||
}
|
||||
if m.displayState() == StateIdle {
|
||||
m.listNav(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) wfNav(dir int) {
|
||||
n := len(m.workflows)
|
||||
|
||||
@@ -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: <session/none> · <mode>
|
||||
// status sub-line: <session/none> · <uuid> · <mode>
|
||||
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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user