feat(tui,kernel): deterministic command-approval parse layer (§5.1)
Layer 1 of tui-requirements §5: a no-LLM analyzer that shell-lexes a
proposed command, classifies binaries against a known-command table, and
mechanically flags the constructs that widen blast radius — sudo/doas,
recursive delete, pipe-to-shell (skipping wrappers so `| sudo sh` counts),
redirects outside the workspace, network binaries, base64/eval, and
unparseable input (itself a red flag). Pure + replay-stable: same preview
+ workspaceRoot → same analysis, no environment reads.
The approval band renders command approvals as a card — worst-first flag
chips over the reconstructed command line, each token colored by severity
(T0–T4 ramp). ^x opens a scrollable fullscreen view so a long command is
never truncated. Diff and plain-preview paths are unchanged.
Server: computeToolPreview now renders the shell tool's argv as a full,
shell-quoted command line instead of the 200-char JSON-args fallback,
honouring §3's "full untruncated command". The TUI parser accepts both
the rendered line and the older `{"argv":[...]}` shape for replay.
Layer 2 (model annotation) deliberately deferred per the spec.
This commit is contained in:
@@ -78,7 +78,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 = previewRowCount(s.Pending.Preview)
|
||||
rows = m.approvalContentRows(s.Pending)
|
||||
if n := len(s.Pending.Rationale); n > 0 {
|
||||
rat = n + 1 // rationale lines + trailing blank
|
||||
}
|
||||
@@ -152,9 +152,15 @@ func (m Model) renderApprovalBand(h int) string {
|
||||
body = append(body, "")
|
||||
}
|
||||
var content []string
|
||||
if isUnifiedDiff(a.Preview) {
|
||||
switch {
|
||||
case isCommandApproval(a):
|
||||
content = m.commandCardLines(a, textW)
|
||||
if len(content) > diffH {
|
||||
content = content[:diffH]
|
||||
}
|
||||
case isUnifiedDiff(a.Preview):
|
||||
content = m.renderSplitDiff(parseUnifiedDiff(a.Preview), textW, diffH, 0)
|
||||
} else {
|
||||
default:
|
||||
content = m.renderPreviewLines(a.Preview, textW, diffH, 0)
|
||||
}
|
||||
diffStart := len(body)
|
||||
@@ -209,13 +215,28 @@ func (m Model) approvalActions() string {
|
||||
}, gap)
|
||||
}
|
||||
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) {
|
||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil &&
|
||||
(isUnifiedDiff(s.Pending.Preview) || isCommandApproval(s.Pending)) {
|
||||
parts = append(parts, hint("^x", "fullscreen"))
|
||||
}
|
||||
parts = append(parts, hint("esc", "later"))
|
||||
return strings.Join(parts, gap)
|
||||
}
|
||||
|
||||
// approvalContentRows is the number of body rows the pending approval's content
|
||||
// occupies — command-card lines for a command, diff/preview rows otherwise. Drives
|
||||
// band sizing so the height matches what renderApprovalBand will draw.
|
||||
func (m Model) approvalContentRows(a *Approval) int {
|
||||
if isCommandApproval(a) {
|
||||
w := m.width - 4
|
||||
if w < 8 {
|
||||
w = 8
|
||||
}
|
||||
return len(m.commandCardLines(a, w))
|
||||
}
|
||||
return previewRowCount(a.Preview)
|
||||
}
|
||||
|
||||
// diffBodyHeight is the number of diff lines visible in the diff modal body.
|
||||
func (m Model) diffBodyHeight() int {
|
||||
h := m.height * 70 / 100
|
||||
@@ -228,7 +249,11 @@ 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 {
|
||||
max := previewRowCount(m.currentDiff()) - m.diffBodyHeight()
|
||||
rows := previewRowCount(m.currentDiff())
|
||||
if cmd := m.pendingCommand(); cmd != nil {
|
||||
rows = len(m.commandCardLines(cmd, m.modalWidth()-6))
|
||||
}
|
||||
max := rows - m.diffBodyHeight()
|
||||
if max < 0 {
|
||||
max = 0
|
||||
}
|
||||
@@ -236,6 +261,9 @@ func (m Model) diffMaxScroll() int {
|
||||
}
|
||||
|
||||
func (m Model) diffModal() string {
|
||||
if cmd := m.pendingCommand(); cmd != nil {
|
||||
return m.commandModal(cmd)
|
||||
}
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
bodyH := m.diffBodyHeight()
|
||||
@@ -275,6 +303,38 @@ func (m Model) diffModal() string {
|
||||
return m.center(modal)
|
||||
}
|
||||
|
||||
// commandModal is the ^x fullscreen view of a command approval: the same
|
||||
// deterministic card as the band, scrollable, so a long command is fully readable
|
||||
// without ever being truncated.
|
||||
func (m Model) commandModal(a *Approval) string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
bodyH := m.diffBodyHeight()
|
||||
lines := m.commandCardLines(a, w-6)
|
||||
|
||||
off := m.diffScrollOffset
|
||||
if mx := m.diffMaxScroll(); off > mx {
|
||||
off = mx
|
||||
}
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("command"))
|
||||
b.WriteString(mbg(t, " "+a.ToolName, t.P.Dim))
|
||||
b.WriteString(mbg(t, " ("+itoa(len(lines))+" rows)", t.P.Faint) + "\n\n")
|
||||
end := off + bodyH
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
for i := off; i < end; i++ {
|
||||
b.WriteString(lines[i] + "\n")
|
||||
}
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}}))
|
||||
return m.center(t.Overlay.Width(w).Render(b.String()))
|
||||
}
|
||||
|
||||
func (m Model) eventInspectorModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
Reference in New Issue
Block a user