diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index eb5fa09c..9c88fea6 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -84,6 +84,25 @@ func PreviewFrame(kind string, w, h int) string { } } + case "approval-shell": + 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.CurrentStage = "execute_script" + s.Events = sampleEvents() + s.Pending = &Approval{ + RequestID: "req-2", SessionID: "04a546aa", Tier: "T2", Risk: "MEDIUM", + ToolName: "shell", + Preview: `{"argv":["bash","scripts/healthcheck.sh"]}`, + Rationale: []string{ + "[INTERPRETER_EXECUTION] Tool 'shell' invokes interpreter 'bash'", + }, + } + } + case "diff": m.connected = true m.currentModel = "llama-cpp:default" diff --git a/apps/tui-go/internal/app/diff.go b/apps/tui-go/internal/app/diff.go index 2e0ae136..f8f5f707 100644 --- a/apps/tui-go/internal/app/diff.go +++ b/apps/tui-go/internal/app/diff.go @@ -78,6 +78,48 @@ func parseUnifiedDiff(diff string) []diffRow { return rows } +// isUnifiedDiff reports whether s looks like a unified diff (file/hunk headers) +// rather than arbitrary preview text such as a shell argv JSON. Non-diff previews +// must not go through the two-column renderer (it would show identical columns). +func isUnifiedDiff(s string) bool { + return strings.Contains(s, "@@ -") || strings.HasPrefix(s, "--- ") || strings.Contains(s, "\n--- ") +} + +// previewPlainLines splits non-diff preview text into display lines. +func previewPlainLines(s string) []string { + return strings.Split(strings.TrimRight(s, "\n"), "\n") +} + +// previewRowCount is the number of content rows a preview occupies — diff rows +// for a real diff, plain lines otherwise. Drives band sizing and scroll bounds. +func previewRowCount(s string) int { + if isUnifiedDiff(s) { + return len(parseUnifiedDiff(s)) + } + return len(previewPlainLines(s)) +} + +// renderPreviewLines renders non-diff preview text as dim plain lines (single +// column), up to maxRows starting at off, each truncated to width. +func (m Model) renderPreviewLines(content string, width, maxRows, off int) []string { + lines := previewPlainLines(content) + if off < 0 { + off = 0 + } + if off > len(lines) { + off = len(lines) + } + end := off + maxRows + if end > len(lines) { + end = len(lines) + } + out := make([]string, 0, end-off) + for _, ln := range lines[off:end] { + out = append(out, m.theme.span(truncate(ln, width), m.theme.P.Dim)) + } + return out +} + // hunkStarts reads the starting old/new line numbers from an @@ -a,b +c,d @@ marker. func hunkStarts(h string) (int, int) { old, newer := 1, 1 diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 49608692..8d1aefc8 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -210,25 +210,21 @@ func NewModel(client *ws.Client) Model { } } -// displayState derives the active screen, matching the Kotlin extension. +// displayState derives the active screen. A session must be *entered* +// (sessionEntered) before its in-session or approval surfaces show — otherwise +// merely moving the list cursor onto a session with a pending gate would yank +// you into the approval, and `l` back-to-list couldn't escape it. func (m Model) displayState() DisplayState { - if m.selectedID == "" { + if m.selectedID == "" || !m.sessionEntered { return StateIdle } - hasApproval := false + if m.approvalDismissed { + return StateInSession + } if s := m.session(m.selectedID); s != nil && s.Pending != nil { - hasApproval = true - } - switch { - case m.approvalDismissed: - return StateInSession - case hasApproval: return StateApproval - case m.sessionEntered: - return StateInSession - default: - return StateIdle } + return StateInSession } func (m Model) session(id string) *Session { diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index a13e7405..383546fe 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -58,7 +58,7 @@ func mbg(t Theme, s string, fg lipgloss.Color) string { func (m Model) approvalBandHeight() int { rows, rat := 0, 0 if s := m.session(m.selectedID); s != nil && s.Pending != nil { - rows = len(parseUnifiedDiff(s.Pending.Preview)) + rows = previewRowCount(s.Pending.Preview) if n := len(s.Pending.Rationale); n > 0 { rat = n + 1 // rationale lines + trailing blank } @@ -114,8 +114,6 @@ func (m Model) renderApprovalBand(h int) string { if diffH < 1 { diffH = 1 } - rows := parseUnifiedDiff(a.Preview) - body := make([]string, 0, innerH) body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "") for _, r := range a.Rationale { @@ -125,8 +123,14 @@ func (m Model) renderApprovalBand(h int) string { if len(a.Rationale) > 0 { body = append(body, "") } + var content []string + if isUnifiedDiff(a.Preview) { + content = m.renderSplitDiff(parseUnifiedDiff(a.Preview), textW, diffH, 0) + } else { + content = m.renderPreviewLines(a.Preview, textW, diffH, 0) + } diffStart := len(body) - body = append(body, m.renderSplitDiff(rows, textW, diffH, 0)...) + body = append(body, content...) for len(body) < diffStart+diffH { body = append(body, "") } @@ -150,8 +154,11 @@ func (m Model) approvalActions() string { return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render(k) + t.span(" "+l, t.P.Faint) } - parts := []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), - hint("r", "reject"), hint("^x", "fullscreen"), hint("esc", "later")} + 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 && isUnifiedDiff(s.Pending.Preview) { + parts = append(parts, hint("^x", "fullscreen")) + } + parts = append(parts, hint("esc", "later")) return strings.Join(parts, t.span(" ", t.P.Faint)) } @@ -167,8 +174,7 @@ func (m Model) diffBodyHeight() int { // diffMaxScroll is the largest scroll offset that still fills the body with // diff rows, keeping the last page anchored to the bottom of the modal. func (m Model) diffMaxScroll() int { - rows := parseUnifiedDiff(m.currentDiff()) - max := len(rows) - m.diffBodyHeight() + max := previewRowCount(m.currentDiff()) - m.diffBodyHeight() if max < 0 { max = 0 } @@ -180,7 +186,7 @@ func (m Model) diffModal() string { w := m.modalWidth() bodyH := m.diffBodyHeight() diff := m.currentDiff() - rows := parseUnifiedDiff(diff) + isDiff := isUnifiedDiff(diff) off := m.diffScrollOffset if off > m.diffMaxScroll() { @@ -191,12 +197,22 @@ func (m Model) diffModal() string { } var b strings.Builder - b.WriteString(m.titleLine("diff")) + title := "preview" + if isDiff { + title = "diff" + } + b.WriteString(m.titleLine(title)) if tgt := diffTarget(diff); tgt != "" { b.WriteString(mbg(t, " "+tgt, t.P.Dim)) } - b.WriteString(mbg(t, " ("+itoa(len(rows))+" rows)", t.P.Faint) + "\n\n") - for _, ln := range m.renderSplitDiff(rows, w-6, bodyH, off) { + b.WriteString(mbg(t, " ("+itoa(previewRowCount(diff))+" rows)", t.P.Faint) + "\n\n") + var lines []string + if isDiff { + lines = m.renderSplitDiff(parseUnifiedDiff(diff), w-6, bodyH, off) + } else { + lines = m.renderPreviewLines(diff, w-6, bodyH, off) + } + for _, ln := range lines { b.WriteString(ln + "\n") } b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}})) diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 6d2dd45e..2c73be15 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -139,7 +139,8 @@ func (m Model) renderFooter() string { case m.editMode == ModeInsert: hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} case m.displayState() == StateApproval: - hints = []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject"), hint("x", "diff"), hint("esc", "later")} + // Approval actions live in the band itself; the footer offers navigation only. + hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")} case m.displayState() == StateIdle: hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")} default: // StateInSession