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
19 changes: 10 additions & 9 deletions cmd/autonomy_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ package cmd
import (
"strings"

"github.com/GrayCodeAI/rho/internal/engine/safety"

"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
lipgloss "charm.land/lipgloss/v2"
"github.com/GrayCodeAI/rho/internal/engine"
"github.com/mattn/go-runewidth"
)

// autonomyPickerEntry is one selectable row in the picker.
type autonomyPickerEntry struct {
Level engine.AutonomyLevel
Level safety.AutonomyLevel
Name string
Description string
}
Expand All @@ -22,12 +23,12 @@ type autonomyPickerEntry struct {
// containerAutonomyTiers (the Ctrl+L cycle), which deliberately excludes
// Supervised so repeated key-presses can't land you in max-friction mode by
// accident — the picker is a deliberate selection, so Supervised is fine here.
var allAutonomyTiers = []engine.AutonomyLevel{
engine.AutonomySupervised,
engine.AutonomyBasic,
engine.AutonomySemi,
engine.AutonomyFull,
engine.AutonomyYOLO,
var allAutonomyTiers = []safety.AutonomyLevel{
safety.AutonomySupervised,
safety.AutonomyBasic,
safety.AutonomySemi,
safety.AutonomyFull,
safety.AutonomyYOLO,
}

// AutonomyPicker is a Ctrl+L-adjacent quick-select overlay for choosing a
Expand Down Expand Up @@ -68,7 +69,7 @@ func NewAutonomyPicker(width int) *AutonomyPicker {
}

// Open opens the picker, pre-selecting the currently active tier.
func (ap *AutonomyPicker) Open(current engine.AutonomyLevel) {
func (ap *AutonomyPicker) Open(current safety.AutonomyLevel) {
ap.open = true
ap.input.SetValue("")
ap.input.Focus()
Expand Down
25 changes: 13 additions & 12 deletions cmd/autonomy_picker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package cmd
import (
"testing"

"github.com/GrayCodeAI/rho/internal/engine/safety"

tea "charm.land/bubbletea/v2"
"github.com/GrayCodeAI/rho/internal/engine"
"github.com/mattn/go-runewidth"
)

Expand Down Expand Up @@ -44,9 +45,9 @@ func TestAutonomyPicker_HasAllFiveTiers(t *testing.T) {
if len(ap.entries) != 5 {
t.Fatalf("expected 5 tiers, got %d", len(ap.entries))
}
wantOrder := []engine.AutonomyLevel{
engine.AutonomySupervised, engine.AutonomyBasic, engine.AutonomySemi,
engine.AutonomyFull, engine.AutonomyYOLO,
wantOrder := []safety.AutonomyLevel{
safety.AutonomySupervised, safety.AutonomyBasic, safety.AutonomySemi,
safety.AutonomyFull, safety.AutonomyYOLO,
}
for i, level := range wantOrder {
if ap.entries[i].Level != level {
Expand All @@ -57,33 +58,33 @@ func TestAutonomyPicker_HasAllFiveTiers(t *testing.T) {

func TestAutonomyPicker_OpenPreselectsCurrentTier(t *testing.T) {
ap := NewAutonomyPicker(80)
ap.Open(engine.AutonomyFull)
ap.Open(safety.AutonomyFull)
if !ap.IsOpen() {
t.Fatal("expected picker to be open")
}
sel := ap.Selected()
if sel == nil || sel.Level != engine.AutonomyFull {
if sel == nil || sel.Level != safety.AutonomyFull {
t.Fatalf("expected preselected tier AutonomyFull, got %+v", sel)
}
}

func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) {
ap := NewAutonomyPicker(80)
ap.Open(engine.AutonomySupervised)
ap.Open(safety.AutonomySupervised)

chosen, handled := ap.Update(tea.KeyPressMsg{Code: tea.KeyDown})
if !handled || chosen != nil {
t.Fatalf("KeyDown should navigate, not select: chosen=%v handled=%v", chosen, handled)
}
if ap.Selected().Level != engine.AutonomyBasic {
if ap.Selected().Level != safety.AutonomyBasic {
t.Fatalf("expected selection to move to AutonomyBasic, got %v", ap.Selected().Level)
}

chosen, handled = ap.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if !handled {
t.Fatal("expected Enter to be handled")
}
if chosen == nil || chosen.Level != engine.AutonomyBasic {
if chosen == nil || chosen.Level != safety.AutonomyBasic {
t.Fatalf("expected chosen AutonomyBasic, got %v", chosen)
}
if ap.IsOpen() {
Expand All @@ -93,7 +94,7 @@ func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) {

func TestAutonomyPicker_EscClosesWithoutSelecting(t *testing.T) {
ap := NewAutonomyPicker(80)
ap.Open(engine.AutonomyBasic)
ap.Open(safety.AutonomyBasic)

chosen, handled := ap.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
if !handled || chosen != nil {
Expand All @@ -106,12 +107,12 @@ func TestAutonomyPicker_EscClosesWithoutSelecting(t *testing.T) {

func TestAutonomyPicker_FilterByName(t *testing.T) {
ap := NewAutonomyPicker(80)
ap.Open(engine.AutonomyBasic)
ap.Open(safety.AutonomyBasic)

for _, r := range "scout" {
ap.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
}
if len(ap.filtered) != 1 || ap.filtered[0].Level != engine.AutonomyBasic {
if len(ap.filtered) != 1 || ap.filtered[0].Level != safety.AutonomyBasic {
t.Fatalf("expected filter 'scout' to match only Basic tier, got %+v", ap.filtered)
}
}
73 changes: 37 additions & 36 deletions cmd/autonomy_tiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@ import (
"fmt"
"image/color"

"github.com/GrayCodeAI/rho/internal/engine/safety"

lipgloss "charm.land/lipgloss/v2"
"github.com/GrayCodeAI/rho/internal/engine"
)

// Five container autonomy tiers (Scout → Builder → Operator → Autonomous → Always Ask).
// Supervised ("Always Ask") is included in the Ctrl+L cycle but requires a
// deliberate double-press to land on (see chat_update.go ctrl+l handling) so
// repeated key-presses can't accidentally drop the user into max-friction mode.
var containerAutonomyTiers = []engine.AutonomyLevel{
engine.AutonomyBasic,
engine.AutonomySemi,
engine.AutonomyFull,
engine.AutonomyYOLO,
engine.AutonomySupervised,
var containerAutonomyTiers = []safety.AutonomyLevel{
safety.AutonomyBasic,
safety.AutonomySemi,
safety.AutonomyFull,
safety.AutonomyYOLO,
safety.AutonomySupervised,
}

var containerAutonomyTierNames = []string{
Expand All @@ -29,14 +30,14 @@ var containerAutonomyTierNames = []string{
}

// DefaultContainerAutonomy is the tier applied when the Docker container becomes ready.
const DefaultContainerAutonomy = engine.AutonomySemi
const DefaultContainerAutonomy = safety.AutonomySemi

// yoloConfirmToken is the exact string a user must type (case-insensitive) to
// confirm entry into YOLO ("Autonomous") unattended mode via the picker.
const yoloConfirmToken = "continue"

func autonomyTierName(level engine.AutonomyLevel) string {
if level == engine.AutonomySupervised {
func autonomyTierName(level safety.AutonomyLevel) string {
if level == safety.AutonomySupervised {
return "Always Ask"
}
for i, l := range containerAutonomyTiers {
Expand All @@ -47,7 +48,7 @@ func autonomyTierName(level engine.AutonomyLevel) string {
return "Builder"
}

func autonomyTierIndex(level engine.AutonomyLevel) int {
func autonomyTierIndex(level safety.AutonomyLevel) int {
for i, l := range containerAutonomyTiers {
if l == level {
return i
Expand All @@ -60,86 +61,86 @@ func autonomyTierIndex(level engine.AutonomyLevel) int {
// Supervised ("Always Ask") — repeated Ctrl+L wraps YOLO → Basic. Use
// nextAutonomyTierIncludingSupervised when the user explicitly confirms they
// want the cautious tier.
func nextAutonomyTier(level engine.AutonomyLevel) engine.AutonomyLevel {
func nextAutonomyTier(level safety.AutonomyLevel) safety.AutonomyLevel {
idx := autonomyTierIndex(level)
for {
idx = (idx + 1) % len(containerAutonomyTiers)
if containerAutonomyTiers[idx] != engine.AutonomySupervised {
if containerAutonomyTiers[idx] != safety.AutonomySupervised {
return containerAutonomyTiers[idx]
}
}
}

// nextAutonomyTierIncludingSupervised returns the next tier with Supervised
// included in the cycle (used after the user confirms via double-press).
func nextAutonomyTierIncludingSupervised(level engine.AutonomyLevel) engine.AutonomyLevel {
func nextAutonomyTierIncludingSupervised(level safety.AutonomyLevel) safety.AutonomyLevel {
return containerAutonomyTiers[(autonomyTierIndex(level)+1)%len(containerAutonomyTiers)]
}

// isSupervisedPending reports whether the next regular cycle step would land
// on Supervised (i.e. the current tier is YOLO). The UI uses this to prompt
// for confirmation.
func isSupervisedPending(level engine.AutonomyLevel) bool {
return level == engine.AutonomyYOLO
func isSupervisedPending(level safety.AutonomyLevel) bool {
return level == safety.AutonomyYOLO
}

// autonomyTierDescription is short copy shown when the user changes tier (ctrl+L).
func autonomyTierDescription(level engine.AutonomyLevel) string {
func autonomyTierDescription(level safety.AutonomyLevel) string {
switch level {
case engine.AutonomySupervised:
case safety.AutonomySupervised:
return "Prompts for permission on every tool call"
case engine.AutonomyBasic:
case safety.AutonomyBasic:
return "Explore only — edits and commands ask first"
case engine.AutonomySemi:
case safety.AutonomySemi:
return "File changes auto-approve — commands ask first"
case engine.AutonomyFull:
case safety.AutonomyFull:
return "Commands auto-run — risky actions ask first"
case engine.AutonomyYOLO:
case safety.AutonomyYOLO:
return "Minimal prompts — only the highest-risk actions stop"
default:
return "File changes auto-approve — commands ask first"
}
}

func autonomyTierColor(level engine.AutonomyLevel) color.Color {
func autonomyTierColor(level safety.AutonomyLevel) color.Color {
switch level {
case engine.AutonomySupervised:
case safety.AutonomySupervised:
return lipgloss.Color("#9E9E9E") // matches textMuted's dark value
case engine.AutonomyBasic:
case safety.AutonomyBasic:
return tierInspect
case engine.AutonomySemi:
case safety.AutonomySemi:
return tierEdit
case engine.AutonomyFull:
case safety.AutonomyFull:
return tierRun
case engine.AutonomyYOLO:
case safety.AutonomyYOLO:
return tierTrust
default:
return tierEdit
}
}

func autonomyTierStyle(level engine.AutonomyLevel) lipgloss.Style {
func autonomyTierStyle(level safety.AutonomyLevel) lipgloss.Style {
return lipgloss.NewStyle().Foreground(autonomyTierColor(level)).Bold(true).Inline(true)
}

func renderAutonomyTierLabel(level engine.AutonomyLevel) string {
func renderAutonomyTierLabel(level safety.AutonomyLevel) string {
return autonomyTierStyle(level).Render(autonomyTierName(level))
}

func formatAutonomyTierMessage(level engine.AutonomyLevel) string {
func formatAutonomyTierMessage(level safety.AutonomyLevel) string {
return fmt.Sprintf("Autonomy %s — %s", renderAutonomyTierLabel(level), autonomyTierDescription(level))
}

func autonomyFromSettings(n int) engine.AutonomyLevel {
func autonomyFromSettings(n int) safety.AutonomyLevel {
switch n {
case 1:
return engine.AutonomyBasic
return safety.AutonomyBasic
case 2:
return engine.AutonomySemi
return safety.AutonomySemi
case 3:
return engine.AutonomyFull
return safety.AutonomyFull
case 4:
return engine.AutonomyYOLO
return safety.AutonomyYOLO
default:
return 0
}
Expand Down
14 changes: 7 additions & 7 deletions cmd/autonomy_tiers_copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ import (
"strings"
"testing"

"github.com/GrayCodeAI/rho/internal/engine"
"github.com/GrayCodeAI/rho/internal/engine/safety"
)

func TestAutonomyTierDescriptions_PlainLanguage(t *testing.T) {
cases := []struct {
level engine.AutonomyLevel
level safety.AutonomyLevel
need []string
}{
{engine.AutonomyBasic, []string{"Explore only", "commands ask"}},
{engine.AutonomySemi, []string{"File changes auto-approve", "commands ask"}},
{engine.AutonomyFull, []string{"Commands auto-run", "risky"}},
{engine.AutonomyYOLO, []string{"Minimal prompts", "highest-risk"}},
{safety.AutonomyBasic, []string{"Explore only", "commands ask"}},
{safety.AutonomySemi, []string{"File changes auto-approve", "commands ask"}},
{safety.AutonomyFull, []string{"Commands auto-run", "risky"}},
{safety.AutonomyYOLO, []string{"Minimal prompts", "highest-risk"}},
}
for _, tc := range cases {
desc := autonomyTierDescription(tc.level)
Expand All @@ -28,7 +28,7 @@ func TestAutonomyTierDescriptions_PlainLanguage(t *testing.T) {
}

func TestFormatAutonomyTierMessage_NoArrowJargon(t *testing.T) {
msg := formatAutonomyTierMessage(engine.AutonomyFull)
msg := formatAutonomyTierMessage(safety.AutonomyFull)
if strings.Contains(msg, "→") {
t.Fatalf("expected no arrow jargon, got %q", msg)
}
Expand Down
26 changes: 13 additions & 13 deletions cmd/autonomy_tiers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,35 @@ import (
"fmt"
"testing"

"github.com/GrayCodeAI/rho/internal/engine"
"github.com/GrayCodeAI/rho/internal/engine/safety"
)

func TestAutonomyTierNames(t *testing.T) {
if got := autonomyTierName(engine.AutonomyBasic); got != "Scout" {
if got := autonomyTierName(safety.AutonomyBasic); got != "Scout" {
t.Fatalf("Basic = %q, want Scout", got)
}
if got := autonomyTierName(engine.AutonomySemi); got != "Builder" {
if got := autonomyTierName(safety.AutonomySemi); got != "Builder" {
t.Fatalf("Semi = %q, want Builder", got)
}
if got := autonomyTierName(engine.AutonomyFull); got != "Operator" {
if got := autonomyTierName(safety.AutonomyFull); got != "Operator" {
t.Fatalf("Full = %q, want Operator", got)
}
if got := autonomyTierName(engine.AutonomyYOLO); got != "Autonomous" {
if got := autonomyTierName(safety.AutonomyYOLO); got != "Autonomous" {
t.Fatalf("YOLO = %q, want Autonomous", got)
}
}

func TestNextAutonomyTier(t *testing.T) {
if nextAutonomyTier(engine.AutonomyYOLO) != engine.AutonomyBasic {
if nextAutonomyTier(safety.AutonomyYOLO) != safety.AutonomyBasic {
t.Fatal("expected Autonomous -> Scout wrap")
}
if nextAutonomyTier(engine.AutonomySemi) != engine.AutonomyFull {
if nextAutonomyTier(safety.AutonomySemi) != safety.AutonomyFull {
t.Fatal("expected Builder -> Operator")
}
}

func TestAutonomyFromSettings(t *testing.T) {
if autonomyFromSettings(2) != engine.AutonomySemi {
if autonomyFromSettings(2) != safety.AutonomySemi {
t.Fatal("settings autonomy 2 should map to Builder/Semi")
}
if autonomyFromSettings(0) != 0 {
Expand All @@ -41,11 +41,11 @@ func TestAutonomyFromSettings(t *testing.T) {
}

func TestAutonomyTierColorsDistinct(t *testing.T) {
levels := []engine.AutonomyLevel{
engine.AutonomyBasic,
engine.AutonomySemi,
engine.AutonomyFull,
engine.AutonomyYOLO,
levels := []safety.AutonomyLevel{
safety.AutonomyBasic,
safety.AutonomySemi,
safety.AutonomyFull,
safety.AutonomyYOLO,
}
seen := make(map[string]bool)
for _, l := range levels {
Expand Down
Loading
Loading