Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 120 additions & 88 deletions README.md

Large diffs are not rendered by default.

Binary file modified assets/screenshots/approval.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions assets/screenshots/manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions internal/codex/approval_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,13 @@ func commandApprovalOptions(raw []json.RawMessage) ([8]ApprovalOption, string) {
if len(raw) > 16 {
names = append(names, "…")
}
// Present the one-command grant first when it was actually advertised.
for i, option := range out {
if option.Kind == "accept" {
copy(out[1:i+1], out[:i])
out[0] = option
break
}
}
return out, strings.Join(names, ", ")
}
1 change: 1 addition & 0 deletions internal/codex/approval_options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func TestAdvertisedApprovalChoices(t *testing.T) {
kinds []string
}{
{`["accept","cancel"]`, []string{"accept", "cancel"}},
{`["acceptForSession","decline","accept","cancel"]`, []string{"accept", "acceptForSession", "decline", "cancel"}},
{`["cancel"]`, []string{"cancel"}},
{`["acceptForSession","decline"]`, []string{"acceptForSession", "decline"}},
{`null`, []string{"accept", "cancel"}},
Expand Down
50 changes: 43 additions & 7 deletions internal/confirmation/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,61 @@
// second click never authorises an action or bypasses backend validation.
package confirmation

import "fmt"

type Mode string

const (
Balanced Mode = "balanced"
None Mode = "none"
Require Mode = "require"
)

func Parse(value string) (Mode, error) {
switch Mode(value) {
case Balanced, None, Require:
return Mode(value), nil
default:
return "", fmt.Errorf("--confirmation must be balanced, none or require")
}
}

type Action uint8

const (
Guarded Action = iota // Unknown actions fail closed by default.
Reversible
Guarded Action = iota // Unknown actions remain guarded by default.
Narrow
QuotaReset
)

type Policy struct {
NoConfirm bool
}
type Policy struct{ Mode Mode }

func (p Policy) Required(action Action) bool {
return action == QuotaReset || !p.NoConfirm && action != Reversible
if action == QuotaReset {
return true
}
switch p.Mode {
case None:
return false
case Require:
return true
default:
return action != Narrow
}
}

func Command(reversible bool) Action {
if reversible {
return Reversible
return Narrow
}
return Guarded
}

func Approval(kind string) Action {
switch kind {
case "accept", "decline", "cancel":
return Narrow
default:
return Guarded
}
}
38 changes: 24 additions & 14 deletions internal/confirmation/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,31 @@ package confirmation
import "testing"

func TestPolicy(t *testing.T) {
for _, tc := range []struct {
noConfirm bool
action Action
want bool
}{
{false, Guarded, true}, {false, Reversible, false},
{false, QuotaReset, true}, {false, Action(99), true},
{true, Guarded, false}, {true, Reversible, false},
{true, QuotaReset, true}, {true, Action(99), false},
} {
if (Policy{NoConfirm: tc.noConfirm}).Required(tc.action) != tc.want {
t.Fatal("wrong confirmation policy", tc)
for _, mode := range []Mode{"", Balanced, None, Require} {
for _, action := range []Action{Guarded, Narrow, QuotaReset, Action(99)} {
want := action == QuotaReset || mode != None && (mode == Require || action != Narrow)
if (Policy{Mode: mode}).Required(action) != want {
t.Fatal("wrong confirmation policy", mode, action)
}
}
}
if Command(false) != Guarded || Command(true) != Reversible {
t.Fatal("unknown command must remain guarded")
if Command(false) != Guarded || Command(true) != Narrow {
t.Fatal("unknown commands must remain guarded")
}
for _, kind := range []string{"accept", "decline", "cancel", "acceptForSession", "acceptWithExecpolicyAmendment", "unknown"} {
narrow := kind == "accept" || kind == "decline" || kind == "cancel"
if (Approval(kind) == Narrow) != narrow {
t.Fatal("incorrect approval scope", kind)
}
}
}

func TestParseMode(t *testing.T) {
for _, value := range []string{"balanced", "none", "require", "", "no", "true", "never"} {
mode, err := Parse(value)
valid := value == "balanced" || value == "none" || value == "require"
if (err == nil) != valid || valid && string(mode) != value {
t.Fatal(value, mode, err)
}
}
}
188 changes: 188 additions & 0 deletions internal/ui/action_review.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package ui

import (
"crypto/sha256"
"encoding/json"
"strings"
"time"

tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/merefield/codexometer/internal/codex"
"github.com/merefield/codexometer/internal/confirmation"
"github.com/merefield/codexometer/internal/i18n"
)

// A strict review holds no runnable closure. Confirmation re-enters the normal
// action path, which rechecks the target, exact payload and live capability.
type monitorActionReview struct {
action, target, destination, title, text string
signature [32]byte
until time.Time
scroll int
approved bool
}

func (m *Model) reviewMonitorAction(action, destination, title, text string, payload any) bool {
if !m.confirmations.Required(confirmation.Narrow) {
return false
}
encoded, err := json.Marshal(payload)
if err != nil {
return true
}
signature := sha256.Sum256(encoded)
target := m.monitorContextTarget()
if r := m.actionReview; r != nil && r.approved && r.action == action && r.target == target && r.destination == destination && r.signature == signature && time.Now().Before(r.until) {
m.actionReview = nil
return false
}
m.actionReview = &monitorActionReview{action: action, target: target, destination: destination, title: title, text: text, signature: signature, until: time.Now().Add(30 * time.Second)}
return true
}

// Controls and the document share one layout, including translated wrapping.
func layoutActionReview(width, height int) (buttons []monitorNavigationButton, rows, gap int) {
labels := []string{i18n.Text("[ C CONFIRM ]"), i18n.Text("[ ESC CANCEL ]")}
x, y := 2, 0
for i, label := range labels {
buttonWidth := lipgloss.Width(label)
if buttonWidth > width-4 {
return nil, max(height-2, 1), 0
}
if x+buttonWidth > width-2 {
x, y = 2, y+1
}
buttons = append(buttons, monitorNavigationButton{label: label, action: []string{"confirm", "cancel"}[i], enabled: true, rect: monitorRect{x: x, y: y, width: buttonWidth, height: 1}})
x += buttonWidth + 2
}
rows, gap, top := monitorContextBodyLayout(height, y+1)
if rows < 1 {
return nil, max(height-2, 1), 0
}
for i := range buttons {
buttons[i].rect.y += top
}
return buttons, rows, gap
}

func (m Model) actionReviewButtons() []monitorNavigationButton {
g := m.monitorDashboardLayout()
buttons, _, _ := layoutActionReview(g.contentWidth, g.meterHeight)
return buttons
}

func (m Model) renderActionReview(width, height int, colors palette) string {
r := m.actionReview
buttons, rows, gap := layoutActionReview(width, height)
lines := sessionWebLiteralTextLines(codex.SanitizeApprovalText(r.destination+"\n\n"+r.text), max(width-4, 1))
start := min(r.scroll, max(len(lines)-rows, 0))
body := append([]string(nil), lines[start:min(start+rows, len(lines))]...)
for len(body) < rows+gap {
body = append(body, "")
}
if len(buttons) > 0 {
top := buttons[0].rect.y
controls := make([]string, buttons[len(buttons)-1].rect.y-top+1)
for _, button := range buttons {
row := button.rect.y - top
controls[row] += strings.Repeat(" ", max(button.rect.x-2-lipgloss.Width(controls[row]), 0)) + colors.label().Render(button.label)
}
body = append(body, controls...)
}
return frameSizedWithActions(width, max(height-2, 1), r.title, "", "", strings.Join(body, "\n"), colors.primary, colors)
}

func (m Model) updateActionReview(msg tea.Msg) (Model, tea.Cmd, bool) {
if m.actionReview == nil {
return m, nil, false
}
if m.meterView != viewMonitor || m.actionReview.target != m.monitorContextTarget() || !time.Now().Before(m.actionReview.until) {
m.actionReview = nil
switch msg.(type) {
case tea.KeyPressMsg, tea.MouseClickMsg, tea.MouseWheelMsg, tea.PasteMsg:
return m, nil, true
}
return m, nil, false
}
// Copy before changing an immutable Model snapshot's review state.
review := *m.actionReview
m.actionReview = &review
action := ""
switch event := msg.(type) {
case tea.KeyPressMsg:
if event.IsRepeat {
return m, nil, true
}
switch event.String() {
case "c":
action = "confirm"
case "esc":
action = "cancel"
case "up", "pgup":
review.scroll = max(review.scroll-3, 0)
case "down", "pgdown":
review.scroll++
}
case tea.MouseClickMsg:
if event.Button != tea.MouseLeft {
return m, nil, true
}
g := m.monitorDashboardLayout()
action = monitorNavigationButtonsHit(m.actionReviewButtons(), event.X-2, event.Y-g.meterY)
case tea.MouseWheelMsg:
if event.Button == tea.MouseWheelUp {
review.scroll = max(review.scroll-3, 0)
} else {
review.scroll += 3
}
case tea.MouseMotionMsg, tea.PasteMsg:
return m, nil, true
default:
return m, nil, false // Telemetry and action responses keep flowing.
}
if action == "cancel" {
m.actionReview = nil
return m, nil, true
}
if action != "confirm" || len(m.actionReviewButtons()) == 0 {
return m, nil, true
}
if m.err != nil || m.monitorError != "" {
m.actionReview = nil
return m, nil, true
}
review.approved = true
var next Model
var cmd tea.Cmd
switch review.action {
case "reply":
next, cmd, _ = m.submitMonitorPrompt()
case "steer", "queue", "interrupt":
next, cmd, _ = m.submitMonitorTurn(review.action)
case "queue-edit":
next, cmd, _ = m.changeQueueItem()
case "profile-skip":
next, cmd, _ = m.profileAction("profile-skip:"+review.destination, false)
case "trigger-delete":
cmd = m.triggerAction("ctrl+d")
next = m
default:
m.actionReview = nil
return m, nil, true
}
if next.actionReview != nil && next.actionReview.approved {
next.actionReview = nil
}
return next, cmd, true
}

// Ordinary sends and edits are narrow actions. Permission grants, scheduling,
// queue deletion and other guarded workflows retain their own review screens.
func (m *Model) reviewPromptAction(action, destination string, payload any, text string) bool {
title := i18n.Text("REPLY")
if action != "reply" {
title += " // " + strings.ToUpper(action)
}
return m.reviewMonitorAction(action, destination, title, text, payload)
}
Loading
Loading