fix(tui): approval nav lockups, shell non-diff preview, hint dedup
Three QA-found issues in the approval gate: - displayState gated the in-session/approval surfaces on hasApproval before sessionEntered, so moving the list cursor onto a session with a pending gate auto-opened it, and `l` back-to-list couldn't escape (the gate re-popped). Require sessionEntered first. - The band ran every preview through the two-column diff renderer, so a shell tool's argv JSON rendered as an identical-column "diff". Detect real unified diffs (isUnifiedDiff); render other previews as plain text, and drop the ^x fullscreen hint when there's nothing to expand. - The footer duplicated the band's approve/reject/steer/diff keys. It now shows navigation (l back / e events / q quit) instead.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}}))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user