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
30 changes: 29 additions & 1 deletion cmd/autonomy_tiers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,24 @@ import (
"github.com/GrayCodeAI/hawk/internal/engine"
)

// Four container autonomy tiers (Scout → Builder → Operator → Autonomous).
// 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 containerAutonomyTierNames = []string{
"Scout",
"Builder",
"Operator",
"Autonomous",
"Always Ask",
}

// DefaultContainerAutonomy is the tier applied when the Docker container becomes ready.
Expand All @@ -48,10 +53,33 @@ func autonomyTierIndex(level engine.AutonomyLevel) int {
return 1 // default Builder
}

// nextAutonomyTier returns the next tier in the Ctrl+L cycle. It skips
// 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 {
idx := autonomyTierIndex(level)
for {
idx = (idx + 1) % len(containerAutonomyTiers)
if containerAutonomyTiers[idx] != engine.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 {
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
}

// autonomyTierDescription is short copy shown when the user changes tier (ctrl+L).
func autonomyTierDescription(level engine.AutonomyLevel) string {
switch level {
Expand Down
46 changes: 39 additions & 7 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/GrayCodeAI/hawk/internal/intelligence/memory"
"github.com/GrayCodeAI/hawk/internal/intelligence/repomap"
"github.com/GrayCodeAI/hawk/internal/plugin"
"github.com/GrayCodeAI/hawk/internal/sandbox"
"github.com/GrayCodeAI/hawk/internal/session"
"github.com/GrayCodeAI/hawk/internal/startup"
hawkstorage "github.com/GrayCodeAI/hawk/internal/storage"
Expand Down Expand Up @@ -316,10 +317,10 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
quickSnapshot := welcomeStatusSnapshot{}
m.welcomeSetupState = quickSnapshot.setup
m.welcomeAgentsOK = quickSnapshot.agentsOK
m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), false, initWidth, initHeight, nil, quickSnapshot, false, "")
m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), 0, initWidth, initHeight, nil, quickSnapshot, false, "")
m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache})
// First-session control-plane tip (skip when resuming history).
if saved == nil {
// First-session control-plane tip (skip when resuming history or when quiet env var is set).
if saved == nil && os.Getenv("HAWK_QUIET_START") == "" && os.Getenv("HAWK_SUPPRESS_HINTS") == "" && os.Getenv("HAWK_QUIET") == "" {
m.messages = append(m.messages, displayMsg{role: "system", content: controlPlaneOnboardingHint(sess)})
}
startup.EndPhase("newChatModel:welcome")
Expand Down Expand Up @@ -348,6 +349,27 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
}
})

// Wire credential gate: the tool calls this to prompt the user for access
// to a host credential. On approval, the symlink inside the container is
// flipped to the staging copy.
SetCredentialGate(func(req tool.CredentialRequest) tool.CredentialResponse {
resp := make(chan tool.CredentialResponse, 1)
ref.Send(credentialAskMsg{req: req, response: resp})
select {
case r := <-resp:
if r.Approved && req.ContainerID != "" {
// Flip the symlink inside the container to grant access.
if desc := sandbox.FindCredential(req.Credential); desc != nil {
_ = tool.FlipCredentialSymlink(req.ContainerID, req.Credential,
sandbox.StagingPath(req.Credential), desc.ContainerPath)
}
}
return r
case <-time.After(5 * time.Minute):
return tool.CredentialResponse{Approved: false, Reason: "timed out"}
}
})

if saved != nil {
for _, sm := range saved.Messages {
if sm.Role == "user" || sm.Role == "assistant" {
Expand Down Expand Up @@ -387,7 +409,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
startup.MarkPhase("newChatModel:ui-cache-warm")
hawkconfig.RefreshConfigCredSnapshot(context.Background())
welcomeSnapshot := loadWelcomeStatusSnapshot()
model.refreshStatusBarLeft(true)
_, _ = model.refreshStatusBarLeft(true)
connStatusVal := ""
connStatusKey := ""
if model.session != nil {
Expand Down Expand Up @@ -502,8 +524,18 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco
// refreshInputPlaceholder updates the input placeholder based on the current
// container lifecycle. Hawk never executes agent tools directly on the host.
func (m *chatModel) refreshInputPlaceholder() {
base := "Ask Hawk to inspect, edit, or run something..."
m.input.Placeholder = base + " · Docker isolated · ? for help"
work := "act"
if m.session != nil {
work = string(m.session.WorkMode())
}
switch work {
case "plan":
m.input.Placeholder = "Design architecture or draft plan... · / commands · ? help"
case "review":
m.input.Placeholder = "Audit diffs, security, or PRs... · / commands · ? help"
default:
m.input.Placeholder = "Build, refactor, or run commands... · / commands · ? help"
}
}

// stopContainer releases the session's Docker sandbox on every CLI exit path.
Expand All @@ -520,7 +552,7 @@ func (m *chatModel) stopContainer() {
}

func (m chatModel) Init() tea.Cmd {
cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd()}
cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd(), eyeBlinkTickCmd()}
if gw, _ := m.sessionGatewayModel(); strings.TrimSpace(gw) != "" {
cmds = append(cmds, fetchModelsAsync(gw))
if isXiaomiMimoProvider(gw) {
Expand Down
8 changes: 8 additions & 0 deletions cmd/chat_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,14 @@ func applySlashSuggestion(input string) string {
}

func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) {
trimmed := strings.TrimSpace(text)
lower := strings.ToLower(trimmed)
if lower == "?" || lower == "? help" || lower == "?help" || lower == "help" {
text = "/help"
} else if strings.HasPrefix(lower, "? ") {
text = "/help " + strings.TrimPrefix(trimmed, "? ")
}

parts := strings.Fields(text)
if len(parts) == 0 {
return m, nil
Expand Down
13 changes: 13 additions & 0 deletions cmd/chat_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,16 @@ func TestDiagnosticSummaries(t *testing.T) {
t.Fatalf("unexpected tools summary: %s", tools)
}
}

func TestQuestionMarkAndHelpAliases(t *testing.T) {
sess := engine.NewSession("openai", "gpt-4o", "base", tool.NewRegistry())
m := &chatModel{session: sess, registry: tool.NewRegistry(), sessionID: "test"}
for _, input := range []string{"?", "? help", "?help", "help", "? commit"} {
m.messages = nil
model, _ := m.handleCommand(input)
cm := model.(*chatModel)
if len(cm.messages) == 0 {
t.Fatalf("expected message output for alias %q, got 0", input)
}
}
}
2 changes: 1 addition & 1 deletion cmd/chat_journey_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func TestChatJourney_ConfigPermissionsAndCoreCommands(t *testing.T) {

result, _ = m.handleCommand("/autonomy rules")
m = requireChatModel(t, result)
if got := lastSystemMessage(m.messages); !strings.Contains(got, "Bash(git:*)") {
if got := lastSystemMessage(m.messages); !strings.Contains(got, "Bash") || !strings.Contains(got, "git") {
t.Fatalf("permission rules summary missing allow rule: %q", got)
}

Expand Down
37 changes: 34 additions & 3 deletions cmd/chat_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,15 @@ type (
streamErrMsg struct{ err error }
spinnerVerbTickMsg struct{}
promptKeepAliveMsg struct{}
usageUpdateMsg struct{ usage *engine.StreamUsage }
compactStartMsg struct{}
compactMsg struct {
eyeBlinkTickMsg struct{}
eyeFrameNextMsg struct{ frame int }
statusLeftPRsMsg struct {
branch string
nums []string
}
usageUpdateMsg struct{ usage *engine.StreamUsage }
compactStartMsg struct{}
compactMsg struct {
strategy string
tokensBefore, tokensAfter int
}
Expand Down Expand Up @@ -135,6 +141,11 @@ type (
response chan string
}
askUserPromptTimeoutMsg struct{ seq int }
credentialAskMsg struct {
req tool.CredentialRequest
response chan tool.CredentialResponse
}
credentialPromptTimeoutMsg struct{ seq int }
)

type displayMsg struct {
Expand Down Expand Up @@ -188,10 +199,14 @@ type chatModel struct {
permTimeoutAt time.Time // deadline for the active permission prompt (zero = none)
askReq *askUserMsg // pending ask_user prompt
askReqSeq int
credentialReq *credentialAskMsg // pending credential prompt
credentialReqSeq int
credentialTimeoutAt time.Time
width int
height int
quitting bool
blinkClosed bool
eyeFrame int
slashSel int
hudOpen bool // Agent Status HUD overlay (Ctrl+A)
hudData HUDData // latest HUD snapshot
Expand Down Expand Up @@ -235,6 +250,8 @@ type chatModel struct {
displayInTok float64
displayOutTok float64
lastCtrlC time.Time
supervisedPending bool // Ctrl+L guard: waiting for confirmation to land on Supervised
supervisedPendingAt time.Time // when the pending confirmation was set
history []string
historyIdx int
historyDraft string // unsent text before navigating history
Expand Down Expand Up @@ -278,6 +295,8 @@ type chatModel struct {
statusLeftVal string
statusLeftBranch string
statusLeftAt time.Time // last branch lookup; refreshed on a short TTL
statusLeftPRs []string // open PR numbers ("#184") for the current branch
statusLeftPRAt time.Time // last PR lookup; refreshed on a longer TTL

// Incremental viewport cache (see chat_viewport_render.go).
vpStableContent string
Expand Down Expand Up @@ -498,10 +517,22 @@ func promptKeepAliveCmd() tea.Cmd {
return tea.Tick(15*time.Second, func(time.Time) tea.Msg { return promptKeepAliveMsg{} })
}

func eyeBlinkTickCmd() tea.Cmd {
return tea.Tick(4*time.Second, func(time.Time) tea.Msg { return eyeBlinkTickMsg{} })
}

func eyeFrameNextCmd(frame int, d time.Duration) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg { return eyeFrameNextMsg{frame: frame} })
}

func permissionPromptTimeoutCmd(seq int) tea.Cmd {
return tea.Tick(5*time.Minute, func(time.Time) tea.Msg { return permissionPromptTimeoutMsg{seq: seq} })
}

func askUserPromptTimeoutCmd(seq int) tea.Cmd {
return tea.Tick(5*time.Minute, func(time.Time) tea.Msg { return askUserPromptTimeoutMsg{seq: seq} })
}

func credentialPromptTimeoutCmd(seq int) tea.Cmd {
return tea.Tick(5*time.Minute, func(time.Time) tea.Msg { return credentialPromptTimeoutMsg{seq: seq} })
}
4 changes: 2 additions & 2 deletions cmd/chat_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,14 +228,14 @@ func TestStartupWarmMsg_RefreshesFooterCache(t *testing.T) {
func TestBuildWelcomeMessage_IncludesDockerWhenEnabled(t *testing.T) {
running := true
msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 80, 24, &running)
if !strings.Contains(msg, "CONTAINER · DOCKER · ISOLATED") {
if !strings.Contains(msg, "Container") {
t.Fatalf("expected container execution badge in welcome, got:\n%s", msg)
}
}

func TestBuildWelcomeMessage_OmitsDockerWhenDisabled(t *testing.T) {
msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 80, 24, nil)
if !strings.Contains(msg, "CONTAINER · STARTING") || strings.Contains(msg, "HOST") {
if !strings.Contains(msg, "Container Starting") || strings.Contains(msg, "HOST") {
t.Fatalf("expected mandatory container startup badge, got:\n%s", msg)
}
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/chat_subcommand_branch_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ func (c *branchAgentSubcommand) Handle(m *chatModel, args []string, text string)
m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()})
return m, nil
}
m.refreshStatusBarLeft(true)
_, prCmd := m.refreshStatusBarLeft(true)
m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf(
"%s Checked out `%s` — agent edits stay off %s.\nTip: `/commit` when ready.",
icons.CheckBold(), name, info.Branch,
)})
return m, nil
return m, prCmd
}

func init() {
Expand Down
2 changes: 1 addition & 1 deletion cmd/chat_subcommand_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func (c *startSubcommand) Handle(m *chatModel, args []string, text string) (tea.
b.WriteString(fmt.Sprintf("5. **Git** — could not create agent branch: %v\n", err))
} else {
b.WriteString(fmt.Sprintf("5. **Git** — created and checked out `%s`\n", name))
m.refreshStatusBarLeft(true)
_, _ = m.refreshStatusBarLeft(true)
}
} else if advice := engine.GitSafetyAdvice(gi); advice != "" {
b.WriteString(fmt.Sprintf("5. **Git** — %s\n", advice))
Expand Down
13 changes: 11 additions & 2 deletions cmd/chat_subcommand_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,19 @@ func buildStatusInfo(m *chatModel) string {
if m.modeManager != nil {
shell = m.modeManager.Current().String()
}
containerInfo := "Host"
if m.containerReady {
containerInfo = "Docker Sandbox (bridge net, SSH agent, non-root UID)"
} else if m.containerErr != nil {
containerInfo = fmt.Sprintf("Docker Required (error: %v)", m.containerErr)
} else if m.containerEnabled {
containerInfo = "Docker Sandbox (starting)"
}

info := fmt.Sprintf(
"Session: %s\nModel: %s/%s\nShell mode: %s\nWork mode: %s\nIsolation: %s\nAuto-commit: %s\nFolder trust: %s\nSpec stage: %s\nMessages: %d\nTools: %d visible / %d registered\nGit: %s\n%s",
"Session: %s\nModel: %s/%s\nShell mode: %s\nWork mode: %s\nIsolation: %s\nContainer: %s\nAuto-commit: %s\nFolder trust: %s\nSpec stage: %s\nMessages: %d\nTools: %d visible / %d registered\nGit: %s\n%s",
m.sessionID, m.session.Provider(), m.session.Model(),
shell, work, iso, ac, tr.String(),
shell, work, iso, containerInfo, ac, tr.String(),
specStageLabel(m.session), m.session.MessageCount(),
visible, toolCount,
engine.GitSafetyAdvice(git),
Expand Down
10 changes: 10 additions & 0 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ func essentialTools() []tool.Tool {
tool.MultiEditTool{},
tool.BrowserTool{},
tool.ScreenshotTool{},
tool.RequestCredentialTool{Gateway: func() tool.CredentialGateFn {
// The actual gateway is wired at session start via SetCredentialGate.
// This returns nil until then; the tool checks for nil and errors.
if fn := credentialGate.Load(); fn != nil {
if gateFn, ok := fn.(tool.CredentialGateFn); ok {
return gateFn
}
}
return nil
}},
}
}

Expand Down
Loading
Loading