diff --git a/AGENTS.md b/AGENTS.md index f4a3a34d..6bd4415f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,16 @@ refactor: extract context packing logic test: add coverage for guardian ``` +### Commit Signing + +- Signed commits are required in this repo. +- Git is configured for SSH signing with `commit.gpgsign=true` and the user's + SSH signing key. +- In sandboxed agent sessions, `git commit` may fail even when the key is + unlocked because the sandbox cannot access `SSH_AUTH_SOCK`. +- When that happens, run `git commit` outside the sandbox or with an + unsandboxed/escalated execution path so git can talk to the host SSH agent. + ### Code Style - `gofmt` and `go vet` are mandatory (enforced by CI) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e479e19..2d811ef8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,6 +81,16 @@ only the human author. Hooks are auto-installed by `make setup` via make hooks ``` +## Commit signing + +Signed commits are required in this repo. + +- Git is configured for SSH commit signing. +- In sandboxed agent sessions, `git commit` can fail even when the signing key + is unlocked because the sandbox cannot access the host `SSH_AUTH_SOCK`. +- When signing is required, run `git commit` outside the sandbox or through an + unsandboxed/escalated execution path. + ## Pull request checklist Before requesting review: diff --git a/README.md b/README.md index a0b6d99c..bebfa5f8 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,37 @@ hawk skills install go-review # Install from GitHub hawk skills audit # Security scan installed skills ``` -### Permission System +### Permission Center + +hawk now exposes one visible permission command center in chat: + +```text +/permissions +/permissions tier +/permissions sandbox +/permissions mode +/permissions allow +/permissions deny +/permissions rules +/permissions reset +/permissions save [project|global] +``` + +The model is: -hawk asks before running dangerous tools. Auto-mode learns from your decisions, with emergency killswitch support. +- `Tier` controls autonomy: + - `Scout` + - `Builder` + - `Operator` + - `Autonomous` +- `Sandbox` controls the execution boundary: + - `strict` + - `workspace` + - `off` +- `Rules` control explicit allow/deny exceptions. + +For normal chat usage, `/permissions` is the main control surface. Older +permission chat commands have been removed in favor of this single flow. ### MCP & LSP Support @@ -170,11 +198,25 @@ hawk -c # Continue latest session hawk --provider openai --model gpt-4o # Override provider ``` +### Permission Examples + +```bash +# Inside the TUI +/permissions +/permissions tier builder +/permissions sandbox workspace +/permissions mode plan +/permissions allow Bash(git:*) +/permissions deny Bash(rm -rf *) +/permissions save project +``` + ### Non-Interactive Mode ```bash hawk -p "explain this repo" # Print response, exit hawk -p "fix tests" --allowed-tools "Bash(go test:*) Edit Read" +hawk -p "review this repo" --permission-mode plan --sandbox workspace hawk exec "refactor auth module" # Full engine, non-interactive hawk exec --auto full "add error handling" # Full autonomy hawk exec --worktree "add rate limiting" # Isolated branch diff --git a/cmd/autocomplete.go b/cmd/autocomplete.go index d47ee938..0d97cb1e 100644 --- a/cmd/autocomplete.go +++ b/cmd/autocomplete.go @@ -420,13 +420,13 @@ func (ac *Autocompleter) completeFlags(prefix string) []Suggestion { {"--max-budget-usd", "Maximum API spend"}, {"--system-prompt", "System prompt to use"}, {"--output-format", "Output format"}, - {"--sandbox", "Sandbox mode"}, + {"--sandbox", "Permission sandbox"}, {"--auto-commit", "Auto-commit changes"}, {"--watch", "Watch for file changes"}, {"--vibe", "Vibe coding mode"}, {"--power", "Power level 1-10"}, {"--timeout", "Time budget"}, - {"--permission-mode", "Permission mode"}, + {"--permission-mode", "Advanced permission mode"}, {"--session-id", "Session ID"}, } diff --git a/cmd/autonomy_tiers.go b/cmd/autonomy_tiers.go index e5194948..e27a8032 100644 --- a/cmd/autonomy_tiers.go +++ b/cmd/autonomy_tiers.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -// Four container autonomy tiers (Inspect → Edit → Run → Trust). +// Four container autonomy tiers (Scout → Builder → Operator → Autonomous). var containerAutonomyTiers = []engine.AutonomyLevel{ engine.AutonomyBasic, engine.AutonomySemi, @@ -17,10 +17,10 @@ var containerAutonomyTiers = []engine.AutonomyLevel{ } var containerAutonomyTierNames = []string{ - "Inspect", - "Edit", - "Run", - "Trust", + "Scout", + "Builder", + "Operator", + "Autonomous", } // DefaultContainerAutonomy is the tier applied when the sandbox becomes ready. @@ -32,7 +32,7 @@ func autonomyTierName(level engine.AutonomyLevel) string { return containerAutonomyTierNames[i] } } - return "Inspect" + return "Builder" } func autonomyTierIndex(level engine.AutonomyLevel) int { @@ -41,7 +41,7 @@ func autonomyTierIndex(level engine.AutonomyLevel) int { return i } } - return 1 // default Edit + return 1 // default Builder } func nextAutonomyTier(level engine.AutonomyLevel) engine.AutonomyLevel { @@ -52,15 +52,15 @@ func nextAutonomyTier(level engine.AutonomyLevel) engine.AutonomyLevel { func autonomyTierDescription(level engine.AutonomyLevel) string { switch level { case engine.AutonomyBasic: - return "Look only — edits & shell ask first" + return "Explore only — edits and commands ask first" case engine.AutonomySemi: - return "Auto edits — shell asks first" + return "File changes auto-approve — commands ask first" case engine.AutonomyFull: - return "Auto shell — risky ops ask first" + return "Commands auto-run — risky actions ask first" case engine.AutonomyYOLO: - return "Few asks — trust this session" + return "Minimal prompts — only the highest-risk actions stop" default: - return "Auto edits — shell asks first" + return "File changes auto-approve — commands ask first" } } @@ -98,13 +98,13 @@ func formatSandboxReadyAutonomyMessage(level engine.AutonomyLevel) string { func autonomyLevelForTierName(name string) engine.AutonomyLevel { switch strings.TrimSpace(name) { - case "Inspect": + case "Scout": return engine.AutonomyBasic - case "Edit": + case "Builder": return engine.AutonomySemi - case "Run": + case "Operator": return engine.AutonomyFull - case "Trust": + case "Autonomous": return engine.AutonomyYOLO default: return DefaultContainerAutonomy diff --git a/cmd/autonomy_tiers_copy_test.go b/cmd/autonomy_tiers_copy_test.go index 4bbe026a..045f63a5 100644 --- a/cmd/autonomy_tiers_copy_test.go +++ b/cmd/autonomy_tiers_copy_test.go @@ -12,10 +12,10 @@ func TestAutonomyTierDescriptions_PlainLanguage(t *testing.T) { level engine.AutonomyLevel need []string }{ - {engine.AutonomyBasic, []string{"Look only", "shell ask"}}, - {engine.AutonomySemi, []string{"Auto edits", "shell asks"}}, - {engine.AutonomyFull, []string{"Auto shell", "risky"}}, - {engine.AutonomyYOLO, []string{"Few asks", "trust"}}, + {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"}}, } for _, tc := range cases { desc := autonomyTierDescription(tc.level) @@ -35,7 +35,7 @@ func TestFormatAutonomyTierMessage_NoArrowJargon(t *testing.T) { if len(msg) > 120 { t.Fatalf("message too long (%d chars): %q", len(msg), msg) } - if !strings.Contains(msg, "Run") { + if !strings.Contains(msg, "Operator") { t.Fatalf("expected tier name in message, got %q", msg) } } diff --git a/cmd/autonomy_tiers_test.go b/cmd/autonomy_tiers_test.go index 218c96d2..251bac76 100644 --- a/cmd/autonomy_tiers_test.go +++ b/cmd/autonomy_tiers_test.go @@ -8,32 +8,32 @@ import ( ) func TestAutonomyTierNames(t *testing.T) { - if got := autonomyTierName(engine.AutonomyBasic); got != "Inspect" { - t.Fatalf("Basic = %q, want Inspect", got) + if got := autonomyTierName(engine.AutonomyBasic); got != "Scout" { + t.Fatalf("Basic = %q, want Scout", got) } - if got := autonomyTierName(engine.AutonomySemi); got != "Edit" { - t.Fatalf("Semi = %q, want Edit", got) + if got := autonomyTierName(engine.AutonomySemi); got != "Builder" { + t.Fatalf("Semi = %q, want Builder", got) } - if got := autonomyTierName(engine.AutonomyFull); got != "Run" { - t.Fatalf("Full = %q, want Run", got) + if got := autonomyTierName(engine.AutonomyFull); got != "Operator" { + t.Fatalf("Full = %q, want Operator", got) } - if got := autonomyTierName(engine.AutonomyYOLO); got != "Trust" { - t.Fatalf("YOLO = %q, want Trust", got) + if got := autonomyTierName(engine.AutonomyYOLO); got != "Autonomous" { + t.Fatalf("YOLO = %q, want Autonomous", got) } } func TestNextAutonomyTier(t *testing.T) { if nextAutonomyTier(engine.AutonomyYOLO) != engine.AutonomyBasic { - t.Fatal("expected Trust → Inspect wrap") + t.Fatal("expected Autonomous -> Scout wrap") } if nextAutonomyTier(engine.AutonomySemi) != engine.AutonomyFull { - t.Fatal("expected Edit → Run") + t.Fatal("expected Builder -> Operator") } } func TestAutonomyFromSettings(t *testing.T) { if autonomyFromSettings(2) != engine.AutonomySemi { - t.Fatal("settings autonomy 2 should map to Edit/Semi") + t.Fatal("settings autonomy 2 should map to Builder/Semi") } if autonomyFromSettings(0) != 0 { t.Fatal("settings 0 should leave unset") diff --git a/cmd/cascade_diag_test.go b/cmd/cascade_diag_test.go new file mode 100644 index 00000000..55aaee42 --- /dev/null +++ b/cmd/cascade_diag_test.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "testing" + + hawkbranch "github.com/GrayCodeAI/hawk/internal/engine/branching" + "github.com/GrayCodeAI/hawk/internal/provider/routing" +) + +func TestCascadeSelectsForOpenCodeGoHi(t *testing.T) { + roles := routing.DefaultRoles("opencodego/minimax-m2.5") + t.Logf("cheapest=%s commit=%s", routing.CheapestForProvider("opencodego", "minimax-m2.5"), roles.Commit) + cr := hawkbranch.NewCascadeRouter("opencodego/minimax-m2.5", roles) + cr.Enabled = true + got := cr.SelectModel("Hi", "opencodego/minimax-m2.5", "") + t.Logf("selected=%s", got) +} diff --git a/cmd/chat.go b/cmd/chat.go index 37acfdad..b4c0ee86 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -320,7 +320,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting m.connStatusKey = m.connStatusFingerprint() } m.phase = initialUIPhase(m.hasChatMessages(), promptFlag != "") - m = m.withSyncedLayout() + m = m.syncViewportMouseWheel().withSyncedLayout() m.containerEnabled = shouldUseContainer() bindChatSession(sess, sid, m.containerEnabled) if m.containerEnabled { @@ -448,12 +448,23 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting ref.Send(permissionAskMsg{req: req}) } - // Wire ask_user tool + // High-risk action gate (network, destructive bash) — additive layer on top + // of the permission engine; falls back to AskUserFn for confirmation. + sess.Approval = &engine.ApprovalGate{ + Enabled: true, + MaxAutoApprove: engine.AutonomySemi, + } + + // Wire ask_user tool (5-minute timeout matches permission prompts). sess.AskUserFn = func(question string) (string, error) { resp := make(chan string, 1) ref.Send(askUserMsg{question: question, response: resp}) - answer := <-resp - return answer, nil + select { + case answer := <-resp: + return answer, nil + case <-time.After(5 * time.Minute): + return "", fmt.Errorf("question timed out") + } } if saved != nil { @@ -536,7 +547,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting } func (m chatModel) Init() tea.Cmd { - cmds := []tea.Cmd{m.spinner.Tick, blinkTickCmd(), spinnerVerbTickCmd()} + cmds := []tea.Cmd{initTerminalMouseCmd(), m.spinner.Tick, blinkTickCmd(), spinnerVerbTickCmd()} if gw, _ := m.sessionGatewayModel(); strings.TrimSpace(gw) != "" { cmds = append(cmds, fetchModelsAsync(gw)) } @@ -557,7 +568,24 @@ func (m chatModel) Init() tea.Cmd { func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd + if m.uiFocus == focusPrompt && !m.configOpen && !m.useConfigInput { + mm := m + mm.sanitizeInput() + m = mm + } + switch msg := msg.(type) { + case tea.MouseMsg: + if mouseTrackingEnabled() { + cmds = append(cmds, m.applyMouseScroll(msg)) + } + m.sanitizeInput() + m = m.syncViewportMouseWheel().withSyncedLayout() + if m.viewDirty || m.syncInputLayout() { + m.updateViewportContent() + } + return m, tea.Batch(cmds...) + case autoOpenConfigMsg: if !m.openConfigOnStart || m.configOpen { return m, nil @@ -565,6 +593,14 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.openConfigOnStart = false return m.openConfigPanel() case tea.KeyMsg: + if isMouseSequenceLeak(msg) { + if handled, cmd := m.tryScrollFromMouseLeak(msg); handled { + m.sanitizeInput() + return m, cmd + } + m.sanitizeInput() + return m, nil + } if next, cmd, handled := m.handleWelcomeGateKey(msg); handled { return next, cmd } @@ -626,17 +662,6 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - // Container failed — block all input except quit - if m.containerEnabled && m.containerErr != nil { - if msg.String() == "ctrl+c" || msg.String() == "q" { - if m.watcherStop != nil { - m.watcherStop() - } - m.quitting = true - return m, tea.Quit - } - return m, nil - } // Permission prompt active — handle y/n if m.permReq != nil { switch msg.String() { @@ -670,10 +695,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateViewportContent() return m, nil } - // Let textarea handle other keys - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd + return m, m.updateInput(msg) } if m.waiting { if msg.Type == tea.KeyCtrlC { @@ -681,6 +703,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cancel != nil { m.cancel() m.cancel = nil + m.streamCancelled = true m.messages = append(m.messages, displayMsg{role: "system", content: "⏹ Cancelled."}) if m.partial.Len() > 0 { m.messages = append(m.messages, displayMsg{role: "assistant", content: m.partial.String()}) @@ -703,6 +726,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cancel != nil { m.cancel() m.cancel = nil + m.streamCancelled = true m.messages = append(m.messages, displayMsg{role: "system", content: "⏹ Cancelled."}) if m.partial.Len() > 0 { m.messages = append(m.messages, displayMsg{role: "assistant", content: m.partial.String()}) @@ -727,10 +751,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - // Allow typing in input while streaming - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - return m, cmd + return m, m.updateInput(msg) } if m.configOpen { switch msg.Type { @@ -974,10 +995,26 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.partial.WriteString(string(msg)) m.markPartialDirty() + if m.viewDirty { + m.updateViewportContent() + } return m, nil case thinkingMsg: - m.messages = append(m.messages, displayMsg{role: "thinking", content: string(msg)}) + chunk := string(msg) + if n := len(m.messages); n > 0 && m.messages[n-1].role == "thinking" { + m.messages[n-1].content += chunk + } else { + m.messages = append(m.messages, displayMsg{role: "thinking", content: chunk}) + } + m.viewDirty = true + m.updateViewportContent() + return m, nil + + case streamRetryMsg: + m.partial.Reset() + m.messages = stripCurrentTurnThinking(m.messages) + m.messages = append(m.messages, displayMsg{role: "system", content: "↻ " + msg.content}) m.viewDirty = true return m, nil @@ -1004,6 +1041,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case permissionAskMsg: m.permReq = &msg.req m.messages = append(m.messages, displayMsg{role: "permission", content: msg.req.Summary}) + m.viewDirty = true return m, nil case askUserMsg: @@ -1064,6 +1102,14 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case streamDoneMsg: + if m.streamCancelled { + m.streamCancelled = false + m.waiting = false + m.cancel = nil + m.toolStartTime = time.Time{} + m.viewDirty = true + return m, nil + } if m.compacting { m.compacting = false m.brailleSpinner.SetLabel(m.spinnerVerb) @@ -1079,6 +1125,13 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Generate ghost text suggestion from AI response m.ghostText.Suggest(content) m.partial.Reset() + } else if turnHadThinkingOnly(m.messages) { + // Model sent reasoning tokens but no answer — common with reasoning + // models when the provider drops the post-reasoning content. + m.messages = append(m.messages, displayMsg{ + role: "error", + content: friendlyError(fmt.Errorf("error_only_reasoning: model produced reasoning but no answer")), + }) } m.waiting = false m.cancel = nil @@ -1183,7 +1236,18 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.invalidateConnStatus() } if msg.err != nil { - m.input.Blur() + // Fall back to host mode so chat still works (container is optional). + m.containerEnabled = false + m.containerReady = false + if m.session != nil { + m.session.ContainerRequired = false + m.session.ContainerExecutor = nil + } + m.messages = append(m.messages, displayMsg{ + role: "system", + content: "Container unavailable — running on host. " + msg.err.Error(), + }) + m.input.Focus() } m.rebuildWelcomeCache(m.blinkClosed) m.viewDirty = true @@ -1213,34 +1277,17 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } - var cmd tea.Cmd - m.input, cmd = m.input.Update(msg) - cmds = append(cmds, cmd) + if shouldForwardToInput(msg) { + cmds = append(cmds, m.updateInput(msg)) + } else { + m.sanitizeInput() + } } if m.uiFocus == focusPrompt && !m.input.Focused() { cmds = append(cmds, m.input.Focus()) } - // Update viewport for scroll events (mouse wheel, page up/down) - var vpCmd tea.Cmd - m.viewport, vpCmd = m.viewport.Update(msg) - cmds = append(cmds, vpCmd) - - // If user scrolled away from bottom, disable auto-scroll. - // Re-enable when they scroll back to bottom. - if m.viewport.AtBottom() { - m.autoScroll = true - if m.uiFocus == focusPrompt { - m.streamFollow = true - } - } else { - m.autoScroll = false - if m.uiFocus == focusScrollback { - m.streamFollow = false - } - } - - m = m.withSyncedLayout() + m = m.syncViewportMouseWheel().withSyncedLayout() // Update viewport content when messages change or input layout shifts (slash menu / multiline). if m.viewDirty || m.syncInputLayout() { m.updateViewportContent() @@ -1306,7 +1353,11 @@ func runChat() error { m.waiting = true } - p := tea.NewProgram(m, tea.WithAltScreen()) + programOpts := []tea.ProgramOption{tea.WithAltScreen()} + if mouseTrackingEnabled() { + programOpts = append(programOpts, tea.WithMouseCellMotion()) + } + p := tea.NewProgram(m, programOpts...) // Suppress library log output (e.g. eyrie retry warnings) from corrupting the TUI. log.SetOutput(io.Discard) ref.Set(p) @@ -1321,41 +1372,12 @@ func runChat() error { p.Send(streamErrMsg{err: err}) return } - for ev := range ch { - switch ev.Type { - case "content": - p.Send(streamChunkMsg(ev.Content)) - case "thinking": - p.Send(thinkingMsg(ev.Content)) - case "tool_use": - p.Send(toolUseMsg{name: ev.ToolName, id: ev.ToolID}) - case "tool_result": - p.Send(toolResultMsg{name: ev.ToolName, content: ev.Content}) - case "compact_start": - p.Send(compactStartMsg{}) - case "compact": - p.Send(compactMsg{ - strategy: ev.Content, - tokensBefore: ev.TokensBefore, - tokensAfter: ev.TokensAfter, - }) - case "usage": - if ev.Usage != nil { - p.Send(usageUpdateMsg{usage: ev.Usage}) - } - case "error": - p.Send(streamErrMsg{err: fmt.Errorf("%s", ev.Content)}) - return - case "done": - p.Send(streamDoneMsg{}) - return - } - } - p.Send(streamDoneMsg{}) + pumpStreamEvents(ref, ch) }() } finalModel, err := p.Run() + writeTerminalMouse(disableMouseCSI) if err != nil { return err } diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index dc7f6be5..1e2184cc 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -35,13 +35,13 @@ var allSlashCommands = []string{ "/copy", "/cost", "/cron", "/ctx", "/diff", "/doctor", "/drop", "/effort", "/env", "/exit", "/explain", "/export", "/fast", "/feedback", "/files", "/focus", "/follow", "/fork", "/glm", "/help", "/history", "/home", "/hooks", "/init", "/integrity", "/keybindings", "/learn", "/lint", "/loop", "/mcp", "/memory", "/metrics", "/model", "/new", - "/hunt", "/insights", "/mode", "/output-style", "/party", "/permissions", "/pin", "/plan", "/plugin", "/plugins", + "/hunt", "/insights", "/mode", "/output-style", "/party", "/permissions", "/pin", "/plugin", "/plugins", "/power", "/pr-comments", "/provider-status", "/quit", "/recipe", "/recover", "/reflect", "/refresh-model-catalog", "/release-notes", "/image", "/reload-plugins", "/remote-env", "/rename", "/render", "/research", "/resume", "/retry", "/review", "/rewind", - "/run", "/btw", "/brainstorm", "/checkpoint", "/dream", "/away", "/investigate", "/sandbox", "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/soul", "/spec", "/stale", "/stats", + "/run", "/btw", "/brainstorm", "/checkpoint", "/dream", "/away", "/investigate", "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/soul", "/spec", "/stale", "/stats", "/status", "/statusline", "/summary", "/tag", "/taste", "/tasks", "/test", "/theme", "/think", "/think-back", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/ultrareview", "/undo", "/upgrade", "/usage", - "/version", "/vibe", "/vim", "/voice", "/welcome", "/ecosystem", "/path", "/yaad", "/yolo", + "/version", "/vibe", "/vim", "/voice", "/welcome", "/ecosystem", "/path", "/yaad", } func (m *chatModel) slashSuggestionsFor(input string) []string { @@ -140,9 +140,8 @@ var slashDescriptions = map[string]string{ "/metrics": "Show session metrics", "/model": "Switch or view current model", "/new": "Start a fresh session", - "/permissions": "Manage permission rules", + "/permissions": "Permission Center for tier, sandbox, mode, and rules", "/pin": "Pin last N messages to protect from compaction", - "/plan": "Enter plan mode (read-only)", "/parallel": "Run N agents in parallel on independent tasks", "/plugins": "List installed plugins", "/power": "Set power level (1-10)", @@ -154,7 +153,6 @@ var slashDescriptions = map[string]string{ "/review": "Code review for bugs and issues", "/rewind": "Undo last exchange", "/run": "Run command, add output to context", - "/sandbox": "Toggle approval mode (not Docker; use default container or --no-container)", "/search": "Search across sessions", "/snapshot": "Manage file snapshots: list, restore , diff ", "/stale": "Show stale rules that may need updating or removal", @@ -176,7 +174,6 @@ var slashDescriptions = map[string]string{ "/ecosystem": "Show eyrie, yaad, and tok integration status", "/path": "Developer path readiness (setup, security, sandbox)", "/yaad": "Show yaad memory (use /yaad search to search)", - "/yolo": "Toggle auto-approve mode", "/cron": "Show scheduled jobs", "/keybindings": "Show keyboard shortcuts", "/output-style": "Change output style", @@ -343,10 +340,12 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { /model — Show current model /models — List available models /output-style — Set output verbosity -/permissions allow — Always allow a tool or rule -/permissions deny — Always deny a tool or rule -/permissions mode — Set permission mode -/plan — Enter plan mode (read-only) +/permissions — Show tier, sandbox, mode, rules, and effective behavior +/permissions tier — Set the autonomy tier +/permissions mode — Set the advanced permission mode +/permissions allow — Add an allow rule +/permissions deny — Add a deny rule +/permissions save — Persist the current permission policy /plugins — List installed plugins /pr-comments — Ask hawk to handle PR comments /release-notes — Draft release notes @@ -354,7 +353,6 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { /resume — Resume session /review — Ask hawk to review changes /rewind — Undo last exchange -/sandbox — Toggle approval mode (Docker isolation: default container; --no-container for host) /security-review — Ask hawk to review security risks /share — Share session /learn — LLM-powered skill advisor (deep, update) @@ -525,6 +523,7 @@ func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { return m, nil } m.messages = nil + m.invalidateViewportCache() m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Switched to branch %s", targetID)}) for _, msg := range m.session.RawMessages() { m.messages = append(m.messages, displayMsg{role: msg.Role, content: msg.Content}) @@ -863,35 +862,6 @@ Generate the recap:`, summary.String()) return m, nil } return m.startPromptCommand("/design", buildDesignPrompt(topic)) - case "/permissions": - if len(parts) >= 2 { - switch parts[1] { - case "allow": - spec := permissionCommandArg(text, "allow") - if spec != "" { - m.session.Permissions.AllowSpec(spec) - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Always allowing: %s", spec)}) - return m, nil - } - case "deny": - spec := permissionCommandArg(text, "deny") - if spec != "" { - m.session.Permissions.DenySpec(spec) - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Always denying: %s", spec)}) - return m, nil - } - case "mode": - mode := permissionCommandArg(text, "mode") - if err := m.session.SetPermissionMode(mode); err != nil { - m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) - } else { - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Permission mode: %s", m.session.Mode)}) - } - return m, nil - } - } - m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /permissions allow , /permissions deny , /permissions mode \nExamples: /permissions allow Bash(git:*), /permissions deny Write(*.env), /permissions mode plan"}) - return m, nil case "/status": toolCount := 0 if m.registry != nil { @@ -900,7 +870,7 @@ Generate the recap:`, summary.String()) info := fmt.Sprintf("Session: %s\nModel: %s/%s\nMode: %s\nPermission mode: %s\nMessages: %d\nTools: %d\n%s", m.sessionID, m.session.Provider(), m.session.Model(), m.modeManager.Current().String(), - m.session.Mode, m.session.MessageCount(), toolCount, m.session.Cost.Summary()) + permissionModeLabel(m.session), m.session.MessageCount(), toolCount, m.session.Cost.Summary()) if len(addDirs) > 0 { info += "\nAdditional dirs: " + strings.Join(addDirs, ", ") } @@ -998,14 +968,6 @@ Generate the recap:`, summary.String()) } prompt := BuildResearchPrompt(cfg) return m.startPromptCommand("/research", prompt) - case "/plan": - m.messages = append(m.messages, displayMsg{role: "system", content: "Plan mode: hawk will only read and discuss, no modifications."}) - _ = m.session.SetPermissionMode(string(engine.PermissionModePlan)) - m.session.AddUser("Enter plan mode. Only read files and discuss plans — do not write files or run commands that modify state until I say to proceed.") - m.waiting = true - m.partial.Reset() - m.startStream() - return m, nil case "/parallel": return m.handleParallelCommand(parts, text) case "/usage": @@ -1343,20 +1305,14 @@ Generate the recap:`, summary.String()) return m, nil case "/share": return m.handleSessionCommand(cmd, parts, text) + case "/permissions": + next, cmd := m.handlePermissionsCommand(parts) + return next, cmd case "/upgrade": return m.startPromptCommand("/upgrade", "Check for hawk updates and show the latest available version.") case "/keybindings": m.messages = append(m.messages, displayMsg{role: "system", content: "Keybindings:\n Enter — Submit\n Ctrl+C — Cancel/Exit\n Ctrl+L — Clear\n Up/Down — History\n Tab — Complete"}) return m, nil - case "/sandbox": - if string(m.session.Mode) == "acceptEdits" { - _ = m.session.SetPermissionMode("default") - m.messages = append(m.messages, displayMsg{role: "system", content: "Approval mode ON — all actions require confirmation. (Docker tool isolation is separate: default container mode, or --no-container on host.)"}) - } else { - _ = m.session.SetPermissionMode("acceptEdits") - m.messages = append(m.messages, displayMsg{role: "system", content: "Approval mode relaxed — file edits auto-approved; other actions still prompt. (Docker tool isolation unchanged.)"}) - } - return m, nil case "/output-style": if len(parts) < 2 { m.messages = append(m.messages, displayMsg{role: "system", content: "Usage: /output-style "}) @@ -1588,16 +1544,6 @@ Generate the recap:`, summary.String()) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Messages: %d\nEstimated tokens: ~%d", m.session.MessageCount(), m.session.MessageCount()*200)}) return m, nil - case "/yolo": - if string(m.session.Mode) == "bypassPermissions" { - _ = m.session.SetPermissionMode("default") - m.messages = append(m.messages, displayMsg{role: "system", content: "Yolo mode OFF — all actions require approval."}) - } else { - _ = m.session.SetPermissionMode("bypassPermissions") - m.messages = append(m.messages, displayMsg{role: "system", content: "⚠ Yolo mode ON — all tool calls auto-approved."}) - } - return m, nil - case "/new": return m.handleSessionCommand(cmd, parts, text) case "/btw": diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index 6a248377..503a5206 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -68,6 +68,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.loopCancel = nil } m.messages = []displayMsg{{role: "system", content: "Conversation cleared."}} + m.invalidateViewportCache() m.viewDirty = true m.autoScroll = false return m, nil @@ -111,6 +112,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string return m, nil } m.sessionID = s.ID + m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} var msgs []client.EyrieMessage for _, sm := range s.Messages { @@ -157,6 +159,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string return m, nil } m.sessionID = saved.ID + m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} var msgs []client.EyrieMessage for _, sm := range saved.Messages { @@ -378,6 +381,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string case "/new": m.saveSession() + m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} m.session.LoadMessages(nil) sid := genID() @@ -393,7 +397,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string case "/session": info := fmt.Sprintf("Session: %s\nModel: %s/%s\nPermission mode: %s\nMessages: %d\nTools: %d\n%s", m.sessionID, m.session.Provider(), m.session.Model(), - m.session.Mode, m.session.MessageCount(), len(m.registry.EyrieTools()), m.session.Cost.Summary()) + permissionModeLabel(m.session), m.session.MessageCount(), len(m.registry.EyrieTools()), m.session.Cost.Summary()) m.messages = append(m.messages, displayMsg{role: "system", content: info}) return m, nil diff --git a/cmd/chat_focus.go b/cmd/chat_focus.go index 5daedaf3..d7297629 100644 --- a/cmd/chat_focus.go +++ b/cmd/chat_focus.go @@ -35,6 +35,7 @@ func (m *chatModel) cycleUIFocus() (chatModel, tea.Cmd) { if m.inScrollbackFocus() { m.uiFocus = focusPrompt m.viewDirty = true + *m = m.syncViewportMouseWheel() return *m, m.input.Focus() } m.uiFocus = focusScrollback @@ -42,6 +43,7 @@ func (m *chatModel) cycleUIFocus() (chatModel, tea.Cmd) { m.streamFollow = false m.input.Blur() m.viewDirty = true + *m = m.syncViewportMouseWheel() return *m, nil } @@ -125,7 +127,7 @@ func (m chatModel) renderScrollbackFocusBar(width int) string { if !m.inScrollbackFocus() { return "" } - hint := "scroll · Tab prompt · Up/Dn · PgUp/PgDn" + hint := "wheel/Up/Dn scroll chat · Tab → prompt (Up/Dn history)" if width < len(hint)+4 { width = len(hint) + 4 } diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 717d4b56..90536237 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -77,6 +77,7 @@ var spinnerVerbs = []string{ type ( streamChunkMsg string streamDoneMsg struct{} + streamRetryMsg struct{ content string } streamErrMsg struct{ err error } blinkTickMsg struct{} spinnerVerbTickMsg struct{} @@ -148,6 +149,7 @@ type chatModel struct { messages []displayMsg partial *strings.Builder waiting bool + streamCancelled bool // user cancelled; suppress late streamDone side effects messageQueue []string // queued messages while agent is working permReq *engine.PermissionRequest // pending permission prompt askReq *askUserMsg // pending ask_user prompt @@ -219,7 +221,13 @@ type chatModel struct { connStatusVal string partialDirty bool // stream text changed since last viewport paint lastPartialRender time.Time - activeSkills map[string]plugin.SmartSkill // per-session activated skills + + // Incremental viewport cache (see chat_viewport_render.go). + vpStableContent string + vpRenderedMsgs int + vpRenderWidth int + vpLastMsgLen int + activeSkills map[string]plugin.SmartSkill // per-session activated skills // Container mode (hermetic execution in sandbox) containerEnabled bool diff --git a/cmd/chat_stream.go b/cmd/chat_stream.go index 3d495591..ce250edd 100644 --- a/cmd/chat_stream.go +++ b/cmd/chat_stream.go @@ -5,6 +5,8 @@ import ( "fmt" tea "github.com/charmbracelet/bubbletea" + + "github.com/GrayCodeAI/hawk/internal/engine" ) // Streaming and prompt command functions extracted from chat.go @@ -19,7 +21,56 @@ func (m *chatModel) startPromptCommand(display, prompt string) (tea.Model, tea.C return m, nil } +// dispatchStreamEvent maps one engine event to TUI messages. Returns true when +// the pump should stop (error or done). +func dispatchStreamEvent(ref *progRef, ev engine.StreamEvent) bool { + switch ev.Type { + case "content": + ref.Send(streamChunkMsg(ev.Content)) + case "thinking": + ref.Send(thinkingMsg(ev.Content)) + case "tool_use": + ref.Send(toolUseMsg{name: ev.ToolName, id: ev.ToolID}) + case "tool_result": + ref.Send(toolResultMsg{name: ev.ToolName, content: ev.Content}) + case "blast_radius": + ref.Send(blastRadiusMsg{message: ev.Content}) + case "compact_start": + ref.Send(compactStartMsg{}) + case "compact": + ref.Send(compactMsg{ + strategy: ev.Content, + tokensBefore: ev.TokensBefore, + tokensAfter: ev.TokensAfter, + }) + case "usage": + if ev.Usage != nil { + ref.Send(usageUpdateMsg{usage: ev.Usage}) + } + case "retry": + ref.Send(streamRetryMsg{content: ev.Content}) + case "error": + ref.Send(streamErrMsg{err: fmt.Errorf("%s", ev.Content)}) + return true + case "done": + ref.Send(streamDoneMsg{}) + return true + } + return false +} + +// pumpStreamEvents drains the engine channel into Bubble Tea messages. +func pumpStreamEvents(ref *progRef, ch <-chan engine.StreamEvent) { + for ev := range ch { + if dispatchStreamEvent(ref, ev) { + return + } + } + ref.Send(streamDoneMsg{}) +} + func (m *chatModel) startStream() { + m.streamCancelled = false m.syncSessionSelection() sess := m.session ref := m.ref @@ -31,38 +82,6 @@ func (m *chatModel) startStream() { ref.Send(streamErrMsg{err: err}) return } - for ev := range ch { - switch ev.Type { - case "content": - ref.Send(streamChunkMsg(ev.Content)) - case "thinking": - ref.Send(thinkingMsg(ev.Content)) - case "tool_use": - ref.Send(toolUseMsg{name: ev.ToolName, id: ev.ToolID}) - case "tool_result": - ref.Send(toolResultMsg{name: ev.ToolName, content: ev.Content}) - case "blast_radius": - ref.Send(blastRadiusMsg{message: ev.Content}) - case "compact_start": - ref.Send(compactStartMsg{}) - case "compact": - ref.Send(compactMsg{ - strategy: ev.Content, - tokensBefore: ev.TokensBefore, - tokensAfter: ev.TokensAfter, - }) - case "usage": - if ev.Usage != nil { - ref.Send(usageUpdateMsg{usage: ev.Usage}) - } - case "error": - ref.Send(streamErrMsg{err: fmt.Errorf("%s", ev.Content)}) - return - case "done": - ref.Send(streamDoneMsg{}) - return - } - } - ref.Send(streamDoneMsg{}) + pumpStreamEvents(ref, ch) }() } diff --git a/cmd/chat_submit.go b/cmd/chat_submit.go index 9f64a3f1..705ddcd9 100644 --- a/cmd/chat_submit.go +++ b/cmd/chat_submit.go @@ -19,9 +19,6 @@ import ( // submitUserMessage handles Enter on a non-empty prompt (slash commands, shell, or agent turn). func (m chatModel) submitUserMessage() (chatModel, tea.Cmd) { - if m.containerEnabled && m.containerErr != nil { - return m, nil - } if m.containerEnabled && !m.containerReady { m.messages = append(m.messages, displayMsg{role: "system", content: "Waiting for container — agent tools are disabled until the sandbox is ready."}) m.viewDirty = true diff --git a/cmd/chat_terminal_mouse.go b/cmd/chat_terminal_mouse.go new file mode 100644 index 00000000..a33019cd --- /dev/null +++ b/cmd/chat_terminal_mouse.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +// ANSI sequences to turn off xterm SGR/cell mouse tracking. Cursor's integrated +// terminal can leave these modes enabled after a prior TUI session, which causes +// scroll events to arrive as literal "[<65;99;16M" KeyRunes in the input. +const ( + disableMouseCSI = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" + enableMouseCSI = "\x1b[?1006h\x1b[?1002h" +) + +func writeTerminalMouse(mode string) { + _, _ = os.Stdout.WriteString(mode) +} + +func syncTerminalMouse() { + if mouseTrackingEnabled() { + writeTerminalMouse(enableMouseCSI) + } else { + writeTerminalMouse(disableMouseCSI) + } +} + +func initTerminalMouseCmd() tea.Cmd { + return func() tea.Msg { + syncTerminalMouse() + return nil + } +} diff --git a/cmd/chat_turn.go b/cmd/chat_turn.go new file mode 100644 index 00000000..048e991c --- /dev/null +++ b/cmd/chat_turn.go @@ -0,0 +1,50 @@ +package cmd + +// turnHadThinkingOnly reports whether the latest user turn ended with internal +// reasoning visible but no assistant reply or tool activity. This is the TUI +// symptom of eyrie's ResponseErrorOnlyReasoning health check. +func turnHadThinkingOnly(messages []displayMsg) bool { + if len(messages) == 0 { + return false + } + var sawThinking, sawAssistant, sawTool bool + for i := len(messages) - 1; i >= 0; i-- { + switch messages[i].role { + case "user": + return sawThinking && !sawAssistant && !sawTool + case "thinking": + sawThinking = true + case "assistant": + sawAssistant = true + case "tool_use", "tool_result": + sawTool = true + } + } + return false +} + +// stripCurrentTurnThinking removes thinking messages from the latest user turn. +// Used when the engine retries after a reasoning-only response. +func stripCurrentTurnThinking(messages []displayMsg) []displayMsg { + if len(messages) == 0 { + return messages + } + lastUser := -1 + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].role == "user" { + lastUser = i + break + } + } + if lastUser < 0 { + return messages + } + out := make([]displayMsg, 0, len(messages)) + for i, msg := range messages { + if i > lastUser && msg.role == "thinking" { + continue + } + out = append(out, msg) + } + return out +} diff --git a/cmd/chat_turn_test.go b/cmd/chat_turn_test.go new file mode 100644 index 00000000..acf5c184 --- /dev/null +++ b/cmd/chat_turn_test.go @@ -0,0 +1,61 @@ +package cmd + +import "testing" + +func TestTurnHadThinkingOnly(t *testing.T) { + cases := []struct { + name string + msgs []displayMsg + want bool + }{ + { + name: "thinking without answer", + msgs: []displayMsg{ + {role: "user", content: "hi"}, + {role: "thinking", content: "plan"}, + }, + want: true, + }, + { + name: "thinking with assistant", + msgs: []displayMsg{ + {role: "user", content: "hi"}, + {role: "thinking", content: "plan"}, + {role: "assistant", content: "hello"}, + }, + want: false, + }, + { + name: "tool turn", + msgs: []displayMsg{ + {role: "user", content: "hi"}, + {role: "thinking", content: "plan"}, + {role: "tool_use", content: "Read"}, + }, + want: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := turnHadThinkingOnly(tc.msgs); got != tc.want { + t.Fatalf("turnHadThinkingOnly() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestStripCurrentTurnThinking(t *testing.T) { + msgs := []displayMsg{ + {role: "user", content: "old"}, + {role: "assistant", content: "prior"}, + {role: "user", content: "hi"}, + {role: "thinking", content: "plan"}, + } + got := stripCurrentTurnThinking(msgs) + if len(got) != 3 { + t.Fatalf("len = %d, want 3", len(got)) + } + if got[2].role != "user" || got[2].content != "hi" { + t.Fatalf("last msg = %+v, want user hi", got[2]) + } +} diff --git a/cmd/chat_view.go b/cmd/chat_view.go index 492e6a77..cba5a686 100644 --- a/cmd/chat_view.go +++ b/cmd/chat_view.go @@ -254,128 +254,10 @@ func (m *chatModel) updateViewportContent() { return } - hawkC := "\033[38;2;255;94;14m" - rst := "\033[0m" - bgDark := "\033[48;2;30;30;40m" - - var chatContent strings.Builder - - for i, msg := range m.messages { - switch msg.role { - case "user": - if i > 0 { - chatContent.WriteString("\n") - } - wrapped := wrapText(msg.content, viewWidth-1, 3) - wrappedLines := strings.Split(wrapped, "\n") - for li, wl := range wrappedLines { - if li == 0 { - chatContent.WriteString(bgDark + hawkC + "█" + rst + bgDark + " " + wl) - } else { - chatContent.WriteString(bgDark + " " + wl) - } - // Pad to full width for consistent background - visW := 3 + visibleWidth(wl) - if pad := viewWidth - visW; pad > 0 { - chatContent.WriteString(strings.Repeat(" ", pad)) - } - chatContent.WriteString(rst) - if li < len(wrappedLines)-1 { - chatContent.WriteByte('\n') - } - } - case "assistant": - content := strings.TrimLeft(msg.content, "\n\r") - chatContent.WriteString(hawkC + iconAssistantPrefix + " " + rst + renderMarkdown(content, viewWidth-3)) - case "tool_use": - chatContent.WriteString(toolStyle.Render("⚡ " + msg.content)) - case "tool_result": - // Enhanced rendering for tool results with diff info - if strings.Contains(msg.content, "diff ") && strings.Contains(msg.content, " lines") { - // Split into main content and diff summary - parts := strings.SplitN(msg.content, "\ndiff ", 2) - mainContent := parts[0] - diffPart := "" - if len(parts) > 1 { - diffPart = "diff " + parts[1] - } - toolWrapped := wrapText(mainContent, viewWidth-6, 0) - chatContent.WriteString(toolDimStyle.Render(" " + strings.ReplaceAll(toolWrapped, "\n", "\n "))) - if diffPart != "" { - chatContent.WriteString("\n") - diffStyled := renderDiffSummary(diffPart, viewWidth-6) - chatContent.WriteString(" " + diffStyled) - } - } else if strings.Contains(msg.content, "Self-review found issues") { - // Highlight self-review rejections - chatContent.WriteString(errorStyle.Render(" ✗ " + msg.content)) - } else if strings.Contains(msg.content, "## Self-Reflection") { - // Render reflection with distinct styling - parts := strings.SplitN(msg.content, "## Self-Reflection", 2) - mainContent := parts[0] - reflectionPart := "" - if len(parts) > 1 { - reflectionPart = "## Self-Reflection" + parts[1] - } - toolWrapped := wrapText(mainContent, viewWidth-6, 0) - chatContent.WriteString(toolDimStyle.Render(" " + strings.ReplaceAll(toolWrapped, "\n", "\n "))) - if reflectionPart != "" { - chatContent.WriteString("\n") - reflStyled := renderReflectionBox(reflectionPart, viewWidth-6) - chatContent.WriteString(" " + reflStyled) - } - } else { - display := formatToolResultDisplay(msg.content) - toolWrapped := wrapText(display, viewWidth-6, 0) - chatContent.WriteString(toolDimStyle.Render(" " + strings.ReplaceAll(toolWrapped, "\n", "\n "))) - } - case "thinking": - thinkWrapped := wrapText(msg.content, viewWidth-4, 3) - chatContent.WriteString(dimStyle.Render("💭 " + thinkWrapped)) - case "welcome": - // Skip welcome in viewport — it's rendered statically in View() - case "system": - sysWrapped := wrapText(msg.content, viewWidth-2, 0) - chatContent.WriteString(dimStyle.Render(sysWrapped)) - case "setup_complete": - chatContent.WriteString(renderSetupCompleteMessage(msg.content)) - case "permission": - chatContent.WriteString(renderPermissionBox(msg.content, viewWidth)) - case "question": - qWrapped := wrapText(msg.content, viewWidth-2, 2) - chatContent.WriteString(toolStyle.Render(qWrapped)) - case "usage": - chatContent.WriteString(dimStyle.Render(" " + msg.content)) - case "error": - errWrapped := wrapText(msg.content, viewWidth-8, 7) - chatContent.WriteString(errorStyle.Render("error: " + errWrapped)) - } - // Tighter spacing between tool_use → tool_result pairs - if msg.role == "tool_use" && i+1 < len(m.messages) && m.messages[i+1].role == "tool_result" { - chatContent.WriteByte('\n') - } else if msg.role == "tool_result" && i+1 < len(m.messages) && m.messages[i+1].role == "tool_use" { - chatContent.WriteByte('\n') - } else if msg.role == "usage" { - chatContent.WriteByte('\n') - } else { - chatContent.WriteString("\n\n") - } - } - - if m.waiting && !m.manualCompacting { - partial := sanitizeIdentity(strings.TrimLeft(m.partial.String(), "\n\r")) - if partial != "" { - chatContent.WriteString(hawkC + iconAssistantPrefix + " " + rst + renderMarkdown(partial, viewWidth-3)) - chatContent.WriteString("\n\n") - } else { - chatContent.WriteString(m.renderWaitingSpinnerLine() + "\n\n") - } - } - atBottom := m.viewport.AtBottom() preserveScroll := !m.autoScroll && !atBottom prevYOffset := m.viewport.YOffset - contentStr := chatContent.String() + contentStr := m.assembleViewportContent(viewWidth) m.contentLines = strings.Count(contentStr, "\n") + 1 if m.contentLines < 1 { m.contentLines = 1 diff --git a/cmd/chat_viewport.go b/cmd/chat_viewport.go index d48aeb87..4c6bf0df 100644 --- a/cmd/chat_viewport.go +++ b/cmd/chat_viewport.go @@ -1,11 +1,85 @@ package cmd import ( + "os" + "regexp" + "strconv" "strings" tea "github.com/charmbracelet/bubbletea" ) +// mouseSGRLeakRE matches SGR mouse reports; "[" is optional (Cursor often drops it). +var mouseSGRLeakRE = regexp.MustCompile(`(?:\x1b)?\[?<[0-9;.+^$*-]+[Mm]`) + +// mouseSGRLeakPartialRE matches CSI mouse bytes split across KeyRunes events. +var mouseSGRLeakPartialRE = regexp.MustCompile(`^[\[<]? 0 { + top++ + } + return top +} + +// bottomBarTopY is the first terminal row of the fixed footer (input + stats). +func (m chatModel) bottomBarTopY() int { + if m.height <= 0 { + return 0 + } + return m.height - m.chatBottomBarLines() +} + +// mouseInChatPane reports whether a mouse event is over the chat viewport region. +func (m chatModel) mouseInChatPane(mouse tea.MouseMsg) bool { + if m.height <= 0 { + return true + } + top := m.chatPaneTopY() + bottom := m.bottomBarTopY() + if bottom <= top { + return mouse.Y >= top + } + return mouse.Y >= top && mouse.Y < bottom +} + +// syncViewportMouseWheel enables wheel scrolling only when mouse tracking is on. +func (m chatModel) syncViewportMouseWheel() chatModel { + m.viewport.MouseWheelEnabled = mouseTrackingEnabled() && !m.configOpen && !m.onWelcomeGate() + return m +} + +// shouldRouteMouseToViewport decides whether mouse events reach the chat viewport. +// Standard split-pane UX: wheel over chat scrolls history; wheel over input is ignored; +// arrows in prompt focus navigate input history (see routeKeyToViewport). +func (m chatModel) shouldRouteMouseToViewport(msg tea.Msg) bool { + if !mouseTrackingEnabled() { + return false + } + mouse, isMouse := msg.(tea.MouseMsg) + if !isMouse { + return true + } + if !tea.MouseEvent(mouse).IsWheel() { + return m.inScrollbackFocus() + } + if m.configOpen || m.onWelcomeGate() { + return false + } + if !m.viewportScrollable() { + return false + } + if m.inScrollbackFocus() { + return true + } + return m.mouseInChatPane(mouse) +} + // applyViewportScroll updates the chat viewport and syncs auto-scroll with scroll position. // The bool is true when the key was handled as a scroll action (even if cmd is nil). func (m *chatModel) applyViewportScroll(msg tea.KeyMsg) (bool, tea.Cmd) { @@ -63,3 +203,102 @@ func (m *chatModel) applyViewportScroll(msg tea.KeyMsg) (bool, tea.Cmd) { } return true, cmd } + +func wheelButtonFromSGR(code int) (tea.MouseButton, bool) { + switch code { + case 64: + return tea.MouseButtonWheelUp, true + case 65: + return tea.MouseButtonWheelDown, true + default: + return 0, false + } +} + +func mouseMsgFromSGRMatch(match []string) (tea.MouseMsg, bool) { + if len(match) < 5 { + return tea.MouseMsg{}, false + } + btnCode, err1 := strconv.Atoi(match[1]) + x, err2 := strconv.Atoi(match[2]) + y, err3 := strconv.Atoi(match[3]) + if err1 != nil || err2 != nil || err3 != nil { + return tea.MouseMsg{}, false + } + btn, ok := wheelButtonFromSGR(btnCode) + if !ok { + return tea.MouseMsg{}, false + } + return tea.MouseMsg{ + X: x, + Y: y, + Button: btn, + Action: tea.MouseActionPress, + }, true +} + +// tryScrollFromMouseLeak handles terminals (e.g. Cursor) that inject wheel events as +// literal "[<65;x;yM" / "<65;x;yM" KeyRunes instead of tea.MouseMsg. Routes by Y: +// chat scrolls, input/footer is ignored. +func (m *chatModel) tryScrollFromMouseLeak(msg tea.KeyMsg) (bool, tea.Cmd) { + if !mouseTrackingEnabled() { + return false, nil + } + matches := mouseSGRReportRE.FindAllStringSubmatch(string(msg.Runes), -1) + if len(matches) == 0 { + return false, nil + } + var cmd tea.Cmd + for _, match := range matches { + mouse, ok := mouseMsgFromSGRMatch(match) + if !ok { + continue + } + if m.shouldRouteMouseToViewport(mouse) { + cmd = m.applyMouseScroll(mouse) + } + } + return true, cmd +} + +// applyMouseScroll routes a mouse event to the chat viewport and syncs follow mode. +func (m *chatModel) applyMouseScroll(msg tea.MouseMsg) tea.Cmd { + if !m.shouldRouteMouseToViewport(msg) { + return nil + } + var vpCmd tea.Cmd + m.viewport, vpCmd = m.viewport.Update(msg) + if m.viewport.AtBottom() { + m.autoScroll = true + if m.uiFocus == focusPrompt { + m.streamFollow = true + } + } else { + m.autoScroll = false + if m.uiFocus == focusScrollback { + m.streamFollow = false + } + } + return vpCmd +} + +// sanitizeInput strips any SGR mouse garbage already present in the textarea. +func (m *chatModel) sanitizeInput() { + cleaned := stripMouseLeaks(m.input.Value()) + if cleaned != m.input.Value() { + m.input.SetValue(cleaned) + m.input.CursorEnd() + } +} + +// updateInput forwards a message to the textarea when it is safe (not mouse noise). +func (m *chatModel) updateInput(msg tea.Msg) tea.Cmd { + if !shouldForwardToInput(msg) { + m.sanitizeInput() + return nil + } + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.sanitizeInput() + return cmd +} diff --git a/cmd/chat_viewport_render.go b/cmd/chat_viewport_render.go new file mode 100644 index 00000000..94b233fc --- /dev/null +++ b/cmd/chat_viewport_render.go @@ -0,0 +1,193 @@ +package cmd + +import "strings" + +// Viewport render cache — avoids re-wrapping and re-rendering markdown for the +// entire scrollback on every 50ms stream tick. Stable prefix is cached; only +// new/changed messages and the live tail (partial + spinner) are rebuilt. + +func (m *chatModel) invalidateViewportCache() { + m.vpStableContent = "" + m.vpRenderedMsgs = 0 + m.vpRenderWidth = 0 + m.vpLastMsgLen = 0 +} + +func renderDisplayMessage(msg displayMsg, i int, messages []displayMsg, viewWidth int) string { + hawkC := "\033[38;2;255;94;14m" + rst := "\033[0m" + bgDark := "\033[48;2;30;30;40m" + + var b strings.Builder + + switch msg.role { + case "user": + if i > 0 { + b.WriteByte('\n') + } + wrapped := wrapText(msg.content, viewWidth-1, 3) + wrappedLines := strings.Split(wrapped, "\n") + for li, wl := range wrappedLines { + if li == 0 { + b.WriteString(bgDark + hawkC + "█" + rst + bgDark + " " + wl) + } else { + b.WriteString(bgDark + " " + wl) + } + visW := 3 + visibleWidth(wl) + if pad := viewWidth - visW; pad > 0 { + b.WriteString(strings.Repeat(" ", pad)) + } + b.WriteString(rst) + if li < len(wrappedLines)-1 { + b.WriteByte('\n') + } + } + case "assistant": + content := strings.TrimLeft(msg.content, "\n\r") + b.WriteString(hawkC + iconAssistantPrefix + " " + rst + renderMarkdown(content, viewWidth-3)) + case "tool_use": + b.WriteString(toolStyle.Render("⚡ " + msg.content)) + case "tool_result": + if strings.Contains(msg.content, "diff ") && strings.Contains(msg.content, " lines") { + parts := strings.SplitN(msg.content, "\ndiff ", 2) + mainContent := parts[0] + diffPart := "" + if len(parts) > 1 { + diffPart = "diff " + parts[1] + } + toolWrapped := wrapText(mainContent, viewWidth-6, 0) + b.WriteString(toolDimStyle.Render(" " + strings.ReplaceAll(toolWrapped, "\n", "\n "))) + if diffPart != "" { + b.WriteString("\n") + diffStyled := renderDiffSummary(diffPart, viewWidth-6) + b.WriteString(" " + diffStyled) + } + } else if strings.Contains(msg.content, "Self-review found issues") { + b.WriteString(errorStyle.Render(" ✗ " + msg.content)) + } else if strings.Contains(msg.content, "## Self-Reflection") { + parts := strings.SplitN(msg.content, "## Self-Reflection", 2) + mainContent := parts[0] + reflectionPart := "" + if len(parts) > 1 { + reflectionPart = "## Self-Reflection" + parts[1] + } + toolWrapped := wrapText(mainContent, viewWidth-6, 0) + b.WriteString(toolDimStyle.Render(" " + strings.ReplaceAll(toolWrapped, "\n", "\n "))) + if reflectionPart != "" { + b.WriteString("\n") + reflStyled := renderReflectionBox(reflectionPart, viewWidth-6) + b.WriteString(" " + reflStyled) + } + } else { + display := formatToolResultDisplay(msg.content) + toolWrapped := wrapText(display, viewWidth-6, 0) + b.WriteString(toolDimStyle.Render(" " + strings.ReplaceAll(toolWrapped, "\n", "\n "))) + } + case "thinking": + thinkWrapped := wrapText(msg.content, viewWidth-4, 3) + b.WriteString(dimStyle.Render("💭 " + thinkWrapped)) + case "welcome": + // rendered in fixed welcome pane, not scrollback + case "system": + sysWrapped := wrapText(msg.content, viewWidth-2, 0) + b.WriteString(dimStyle.Render(sysWrapped)) + case "setup_complete": + b.WriteString(renderSetupCompleteMessage(msg.content)) + case "permission": + b.WriteString(renderPermissionBox(msg.content, viewWidth)) + case "question": + qWrapped := wrapText(msg.content, viewWidth-2, 2) + b.WriteString(toolStyle.Render(qWrapped)) + case "usage": + b.WriteString(dimStyle.Render(" " + msg.content)) + case "error": + errWrapped := wrapText(msg.content, viewWidth-8, 7) + b.WriteString(errorStyle.Render("error: " + errWrapped)) + } + + switch msg.role { + case "tool_use": + if i+1 < len(messages) && messages[i+1].role == "tool_result" { + b.WriteByte('\n') + } else { + b.WriteString("\n\n") + } + case "tool_result": + if i+1 < len(messages) && messages[i+1].role == "tool_use" { + b.WriteByte('\n') + } else { + b.WriteString("\n\n") + } + case "usage": + b.WriteByte('\n') + case "welcome": + // no trailing space + default: + if msg.role != "" { + b.WriteString("\n\n") + } + } + return b.String() +} + +func renderMessagesRange(messages []displayMsg, start, end int, viewWidth int) string { + var b strings.Builder + for i := start; i < end && i < len(messages); i++ { + b.WriteString(renderDisplayMessage(messages[i], i, messages, viewWidth)) + } + return b.String() +} + +func (m chatModel) renderStreamTail(viewWidth int) string { + hawkC := "\033[38;2;255;94;14m" + rst := "\033[0m" + + partial := sanitizeIdentity(strings.TrimLeft(m.partial.String(), "\n\r")) + if partial != "" { + return hawkC + iconAssistantPrefix + " " + rst + renderMarkdown(partial, viewWidth-3) + "\n\n" + } + return m.renderWaitingSpinnerLine() + "\n\n" +} + +// assembleViewportContent builds scrollback using the render cache. Returns the +// full viewport string ready for SetContent. +func (m *chatModel) assembleViewportContent(viewWidth int) string { + fullRebuild := m.vpRenderWidth != viewWidth || + m.vpRenderedMsgs > len(m.messages) || + m.vpStableContent == "" + + if !fullRebuild && m.vpRenderedMsgs < len(m.messages) { + var b strings.Builder + b.WriteString(m.vpStableContent) + for i := m.vpRenderedMsgs; i < len(m.messages); i++ { + b.WriteString(renderDisplayMessage(m.messages[i], i, m.messages, viewWidth)) + } + m.vpStableContent = b.String() + m.vpRenderedMsgs = len(m.messages) + } else if !fullRebuild && m.vpRenderedMsgs == len(m.messages) && m.vpRenderedMsgs > 0 { + last := m.messages[m.vpRenderedMsgs-1] + if len(last.content) != m.vpLastMsgLen { + prefix := renderMessagesRange(m.messages, 0, m.vpRenderedMsgs-1, viewWidth) + tail := renderDisplayMessage(last, m.vpRenderedMsgs-1, m.messages, viewWidth) + m.vpStableContent = prefix + tail + } + } + + if fullRebuild { + m.vpStableContent = renderMessagesRange(m.messages, 0, len(m.messages), viewWidth) + m.vpRenderedMsgs = len(m.messages) + m.vpRenderWidth = viewWidth + } + + if m.vpRenderedMsgs > 0 { + m.vpLastMsgLen = len(m.messages[m.vpRenderedMsgs-1].content) + } else { + m.vpLastMsgLen = 0 + } + + content := m.vpStableContent + if m.waiting && !m.manualCompacting { + content += m.renderStreamTail(viewWidth) + } + return content +} diff --git a/cmd/chat_viewport_render_test.go b/cmd/chat_viewport_render_test.go new file mode 100644 index 00000000..ee7fb80a --- /dev/null +++ b/cmd/chat_viewport_render_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestAssembleViewportContent_IncrementalMatchesFullRebuild(t *testing.T) { + msgs := []displayMsg{ + {role: "user", content: "hello"}, + {role: "assistant", content: "hi **there**"}, + {role: "tool_use", content: "Read"}, + {role: "tool_result", content: "[Read] file contents"}, + } + + m := &chatModel{messages: msgs, width: 80} + got := m.assembleViewportContent(80) + + fresh := &chatModel{messages: msgs, width: 80} + want := fresh.assembleViewportContent(80) + if got != want { + t.Fatal("initial assemble should be deterministic") + } + + // Append a message — should match a full rebuild. + m.messages = append(m.messages, displayMsg{role: "user", content: "follow up"}) + incremental := m.assembleViewportContent(80) + + fresh = &chatModel{messages: m.messages, width: 80} + full := fresh.assembleViewportContent(80) + if incremental != full { + t.Fatal("incremental append should match full rebuild") + } + if m.vpRenderedMsgs != len(m.messages) { + t.Fatalf("expected all messages cached, got %d want %d", m.vpRenderedMsgs, len(m.messages)) + } +} + +func TestAssembleViewportContent_ThinkingInPlaceUpdate(t *testing.T) { + m := &chatModel{ + messages: []displayMsg{{role: "thinking", content: "part"}}, + width: 80, + } + first := m.assembleViewportContent(80) + + m.messages[0].content = "part two" + second := m.assembleViewportContent(80) + if first == second { + t.Fatal("thinking growth should change rendered output") + } + + fresh := &chatModel{messages: m.messages, width: 80} + full := fresh.assembleViewportContent(80) + if second != full { + t.Fatal("in-place thinking update should match full rebuild") + } +} + +func TestAssembleViewportContent_StreamTailNotCached(t *testing.T) { + m := &chatModel{ + messages: []displayMsg{{role: "user", content: "go"}}, + waiting: true, + partial: &strings.Builder{}, + width: 80, + } + m.partial.WriteString("streaming") + + stable := m.assembleViewportContent(80) + stablePrefix := m.vpStableContent + + m.partial.WriteString(" more") + withMore := m.assembleViewportContent(80) + + if m.vpStableContent != stablePrefix { + t.Fatal("stream tail changes should not mutate stable cache") + } + if withMore == stable { + t.Fatal("stream tail growth should change assembled output") + } + if !strings.Contains(withMore, "streaming more") { + t.Fatal("expected updated partial in output") + } +} + +func TestAssembleViewportContent_WidthChangeRebuilds(t *testing.T) { + m := &chatModel{ + messages: []displayMsg{{role: "user", content: "hello world"}}, + width: 80, + } + _ = m.assembleViewportContent(80) + if m.vpRenderWidth != 80 { + t.Fatalf("expected width 80 cached, got %d", m.vpRenderWidth) + } + + _ = m.assembleViewportContent(60) + if m.vpRenderWidth != 60 { + t.Fatalf("expected width 60 after resize, got %d", m.vpRenderWidth) + } +} diff --git a/cmd/chat_viewport_test.go b/cmd/chat_viewport_test.go index 888e4c07..aeb1094c 100644 --- a/cmd/chat_viewport_test.go +++ b/cmd/chat_viewport_test.go @@ -9,21 +9,173 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -func TestRouteKeyToViewport_ArrowsWhenScrollable(t *testing.T) { +func TestRouteKeyToViewport_ArrowsInPromptFocus(t *testing.T) { vp := viewport.New(80, 10) vp.SetContent(strings.Repeat("line\n", 40)) vp.SetYOffset(5) ta := textarea.New() - m := chatModel{viewport: vp, input: ta} + m := chatModel{viewport: vp, input: ta, uiFocus: focusPrompt} up := tea.KeyMsg{Type: tea.KeyUp} + if m.routeKeyToViewport(up) { + t.Fatal("up in prompt focus should use input history, not scroll chat") + } + m.uiFocus = focusScrollback if !m.routeKeyToViewport(up) { - t.Fatal("expected up to scroll when not at top") + t.Fatal("up in scrollback focus should scroll when not at top") } - vp.GotoTop() - m.viewport = vp - if m.routeKeyToViewport(up) { - t.Fatal("expected up at top to use input history, not scroll") +} + +func TestMouseInChatPane_Zones(t *testing.T) { + vp := viewport.New(80, 14) + m := chatModel{ + viewport: vp, + input: textarea.New(), + height: 24, + width: 80, + uiFocus: focusPrompt, + } + m = m.withSyncedLayout() + + top := m.chatPaneTopY() + bottom := m.bottomBarTopY() + if bottom <= top { + t.Fatalf("invalid zones top=%d bottom=%d", top, bottom) + } + + overChat := tea.MouseMsg{Y: top, Button: tea.MouseButtonWheelDown} + if !m.mouseInChatPane(overChat) { + t.Fatal("expected wheel row on chat pane") + } + overInput := tea.MouseMsg{Y: bottom, Button: tea.MouseButtonWheelDown} + if m.mouseInChatPane(overInput) { + t.Fatal("expected wheel row on input footer to be outside chat pane") + } +} + +func TestShouldRouteMouseToViewport_SplitPaneUX(t *testing.T) { + vp := viewport.New(80, 14) + vp.SetContent(strings.Repeat("line\n", 40)) + m := chatModel{ + viewport: vp, + input: textarea.New(), + height: 24, + width: 80, + uiFocus: focusPrompt, + } + m = m.withSyncedLayout() + + wheelChat := tea.MouseMsg{Y: m.chatPaneTopY(), Button: tea.MouseButtonWheelDown} + wheelInput := tea.MouseMsg{Y: m.bottomBarTopY(), Button: tea.MouseButtonWheelDown} + + if !m.shouldRouteMouseToViewport(wheelChat) { + t.Fatal("wheel over chat should scroll history in prompt focus") + } + if m.shouldRouteMouseToViewport(wheelInput) { + t.Fatal("wheel over input should not scroll chat in prompt focus") + } + + m.uiFocus = focusScrollback + if !m.shouldRouteMouseToViewport(wheelInput) { + t.Fatal("wheel should scroll in scrollback focus anywhere") + } +} + +func TestSyncViewportMouseWheel_EnabledByDefault(t *testing.T) { + t.Setenv("HAWK_MOUSE", "") + vp := viewport.New(80, 10) + m := chatModel{viewport: vp, uiFocus: focusPrompt, phase: phaseWork} + m = m.syncViewportMouseWheel() + if !m.viewport.MouseWheelEnabled { + t.Fatal("wheel should be enabled by default") + } +} + +func TestSyncViewportMouseWheel_DisabledWithOptOut(t *testing.T) { + t.Setenv("HAWK_MOUSE", "0") + vp := viewport.New(80, 10) + m := chatModel{viewport: vp, uiFocus: focusPrompt, phase: phaseWork} + m = m.syncViewportMouseWheel() + if m.viewport.MouseWheelEnabled { + t.Fatal("wheel should be disabled when HAWK_MOUSE=0") + } +} + +func TestTryScrollFromMouseLeak_SplitPaneByY(t *testing.T) { + vp := viewport.New(80, 14) + vp.SetContent(strings.Repeat("line\n", 40)) + m := chatModel{ + viewport: vp, + input: textarea.New(), + height: 24, + width: 80, + uiFocus: focusPrompt, + } + m = m.withSyncedLayout() + before := m.viewport.YOffset + + chatLeak := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[<65;99;5M")} + handled, _ := m.tryScrollFromMouseLeak(chatLeak) + if !handled { + t.Fatal("expected chat leak to be consumed") + } + if m.viewport.YOffset == before { + t.Fatal("wheel leak over chat should scroll viewport") + } + + m.viewport.SetYOffset(before) + inputLeak := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[<65;99;22M")} + handled, _ = m.tryScrollFromMouseLeak(inputLeak) + if !handled { + t.Fatal("expected input leak to be consumed") + } + if m.viewport.YOffset != before { + t.Fatal("wheel leak over input should not scroll viewport") + } +} + +func TestMouseSequenceLeak_Filtered(t *testing.T) { + leak := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[<65;49;18M")} + if !isMouseSequenceLeak(leak) { + t.Fatal("expected SGR mouse leak detection") + } + if shouldForwardToInput(leak) { + t.Fatal("leak must not forward to input") + } + got := stripMouseLeaks("hi[<64;86;20M[<65;49;18Mthere") + if got != "hithere" { + t.Fatalf("stripMouseLeaks = %q, want hithere", got) + } +} + +func TestMouseSequenceLeak_PartialFragments(t *testing.T) { + partials := []string{"[", "[<", "[<65", "65;99;16M"} + for _, s := range partials { + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + if !isMouseSequenceLeak(msg) { + t.Fatalf("expected partial leak %q to be filtered", s) + } + if shouldForwardToInput(msg) { + t.Fatalf("partial leak %q must not forward to input", s) + } + } + if stripMouseLeaks("still[<65;99;16M") != "still" { + t.Fatal("stripMouseLeaks should remove trailing leak") + } +} + +func TestMouseSequenceLeak_CursorConcatenated(t *testing.T) { + // Cursor integrated terminal often drops "[" on repeated wheel events. + leak := "[<65;84;24M[<64;84;24M<64;84;24M<64;84;24M<65;84;24M" + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(leak)} + if !isMouseSequenceLeak(msg) { + t.Fatal("expected concatenated Cursor leak detection") + } + if shouldForwardToInput(msg) { + t.Fatal("concatenated leak must not forward to input") + } + if got := stripMouseLeaks(leak); got != "" { + t.Fatalf("stripMouseLeaks = %q, want empty", got) } } diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 57ca30b9..2883a55b 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -79,6 +79,13 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. sepC := "\033[38;2;102;102;102m" // textDisabled — chip separators rst := "\033[0m" + // Status marks — green ✓ = present, dim ○ = none (not an error), + // red × = actual problem (e.g. Docker enabled but not running). Using a + // neutral mark for "none" avoids the alarming all-red look on a fresh repo. + markPresent := greenC + "✓" + rst + markNone := sepC + "○" + rst + markErr := redC + "×" + rst + totalW := width if totalW < 40 { totalW = 80 @@ -149,6 +156,26 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. b.WriteString(center(visW, combined) + "\n") } + if forGate { + if model, provider := effectiveModelAndProvider(settings); model != "" { + var plainParts, styledParts []string + if provider != "" { + plainParts = append(plainParts, provider) + styledParts = append(styledParts, mutedC+provider+rst) + } + short := normalizeModelDisplayName(model, model) + plainParts = append(plainParts, short) + styledParts = append(styledParts, bodyC+short+rst) + mode := permissionModeLabel(sess) + plainParts = append(plainParts, mode) + styledParts = append(styledParts, mutedC+mode+rst) + + sep := sepC + " · " + rst + plain := strings.Join(plainParts, " · ") + b.WriteString("\n" + center(len(plain), strings.Join(styledParts, sep)) + "\n") + } + } + if !forGate { verLine := fmt.Sprintf("v%s", DisplayVersion()) b.WriteString("\n" + center(len(verLine), dimC+verLine+rst) + "\n") @@ -164,45 +191,36 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. if !forGate && !needsSetup { tip := "TIP: /help commands · /model to switch" b.WriteString("\n" + center(len(tip), boldC+tip+rst) + "\n") - shortcutsPlain := "Tab scrollback · Up/Dn · PgUp/PgDn · /home · /ctx · ctrl+N · ctrl+L" + shortcutsPlain := "PgUp/Dn scroll chat · Up/Dn history · Tab scrollback · /home · /ctx · ctrl+N · ctrl+L" b.WriteString(center(runewidth.StringWidth(shortcutsPlain), dimC+shortcutsPlain+rst) + "\n") } skillsCount := 0 mcpCount := len(settings.MCPServers) + len(mcpServers) - - skillsOK := false - mcpOK := mcpCount > 0 agentsOK := hawkconfig.LoadAgentsMD() != "" - skillMark := redC + "×" + rst - mcpMark := greenC + "✓" + rst - if mcpCount == 0 { - mcpMark = redC + "×" + rst - } - hawkMark := greenC + "✓" + rst - if !agentsOK { - hawkMark = redC + "×" + rst - } - - gateMarkBox := func(ok bool) string { - if ok { - return greenC + "✓" + rst + mark := func(present bool) string { + if present { + return markPresent } - return redC + "×" + rst + return markNone } - gateChip := func(label string, count int, ok bool) string { - return gateMarkBox(ok) + " " + mutedC + label + rst + " " + mutedC + "(" + rst + bodyC + fmt.Sprintf("%d", count) + rst + mutedC + ")" + rst + skillMark := mark(skillsCount > 0) + mcpMark := mark(mcpCount > 0) + hawkMark := mark(agentsOK) + + gateChip := func(label string, count int) string { + return mark(count > 0) + " " + mutedC + label + rst + " " + mutedC + "(" + rst + bodyC + fmt.Sprintf("%d", count) + rst + mutedC + ")" + rst } gateChipPlain := func(label string, count int) string { return "x " + label + " (" + fmt.Sprintf("%d", count) + ")" } if forGate { chipSep := sepC + " · " + rst - agentsChip := gateMarkBox(agentsOK) + " " + mutedC + "AGENTS" + rst + agentsChip := mark(agentsOK) + " " + mutedC + "AGENTS" + rst parts := []string{ - gateChip("Skills", skillsCount, skillsOK), - gateChip("MCP", mcpCount, mcpOK), + gateChip("Skills", skillsCount), + gateChip("MCP", mcpCount), agentsChip, } plain := []string{ @@ -211,7 +229,11 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. "x AGENTS", } if dockerRunning != nil { - parts = append(parts, gateMarkBox(*dockerRunning)+" "+mutedC+"Docker"+rst) + dockerMark := markErr + if *dockerRunning { + dockerMark = markPresent + } + parts = append(parts, dockerMark+" "+mutedC+"Docker"+rst) plain = append(plain, "x Docker") } indicators := strings.Join(parts, chipSep) @@ -242,14 +264,6 @@ func actLine(saved *session.Session, sessionID string) string { return "" } -func permissionCommandArg(text, action string) string { - prefix := "/permissions " + action - if !strings.HasPrefix(text, prefix) { - return "" - } - return strings.TrimSpace(strings.TrimPrefix(text, prefix)) -} - func toolListSummary(registry *tool.Registry) string { if registry == nil { return "No tools enabled." diff --git a/cmd/command_palette.go b/cmd/command_palette.go index 82e1209c..a87d3e4d 100644 --- a/cmd/command_palette.go +++ b/cmd/command_palette.go @@ -74,7 +74,6 @@ func (cp *CommandPalette) buildEntries() []CommandPaletteEntry { // Workflow commands workflow := map[string]string{ - "/plan": "Enter planning mode", "/review": "Review recent changes", "/commit": "Create smart commit", "/test": "Run project tests", @@ -116,21 +115,18 @@ func (cp *CommandPalette) buildEntries() []CommandPaletteEntry { // Diagnostics diag := map[string]string{ - "/doctor": "Run health diagnostics", - "/path": "Check developer path readiness", - "/cost": "Show cost analysis", - "/rules": "Show permission rules", - "/sandbox": "Sandbox configuration", - "/eval": "Run evaluations", + "/doctor": "Run health diagnostics", + "/path": "Check developer path readiness", + "/cost": "Show cost analysis", + "/rules": "Show permission rules", + "/eval": "Run evaluations", } // Settings settings := map[string]string{ - "/acceptEdits": "Toggle auto-edit mode", - "/bypassPermissions": "Toggle full auto mode", - "/default": "Reset to default permissions", - "/vim": "Toggle vim mode", - "/theme": "Change color theme", + "/permissions": "Permission Center", + "/vim": "Toggle vim mode", + "/theme": "Change color theme", } addEntries := func(category string, cmds map[string]string) { diff --git a/cmd/completions.go b/cmd/completions.go index aa0fb639..9701859d 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -83,15 +83,15 @@ func (g *CompletionGenerator) populateSlashCommands() { "/export", "/fast", "/files", "/focus", "/fork", "/help", "/history", "/hooks", "/hunt", "/init", "/integrity", "/keybindings", "/learn", "/lint", "/loop", "/mcp", "/memory", "/metrics", "/model", "/new", "/output-style", - "/permissions", "/pin", "/plan", "/plugin", "/plugins", "/power", + "/permissions", "/pin", "/plugin", "/plugins", "/power", "/pr-comments", "/provider-status", "/quit", "/refresh-model-catalog", "/release-notes", "/reload-plugins", "/remote-env", "/rename", "/render", "/research", "/resume", "/retry", "/review", "/rewind", "/run", - "/sandbox", "/search", "/security-review", "/session", "/share", "/skills", + "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/stats", "/status", "/statusline", "/summary", "/tag", "/tasks", "/test", "/theme", "/think", "/think-back", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/undo", "/upgrade", "/usage", "/version", "/vibe", - "/vim", "/voice", "/welcome", "/yolo", + "/vim", "/voice", "/welcome", } } @@ -523,7 +523,7 @@ func normalizeFlagType(flagType string) string { func flagChoices(name string) []string { switch name { case "permission-mode": - return []string{"default", "acceptEdits", "bypassPermissions", "dontAsk", "plan"} + return []string{"default", "edits", "bypass", "dontask", "plan"} case "output-format": return []string{"text", "json", "stream-json"} case "input-format": diff --git a/cmd/completions_test.go b/cmd/completions_test.go index 96d5f125..8e19f897 100644 --- a/cmd/completions_test.go +++ b/cmd/completions_test.go @@ -76,7 +76,7 @@ func TestGenerateBashContainsPermissionMode(t *testing.T) { if !strings.Contains(bash, "--permission-mode") { t.Error("Bash completion should contain --permission-mode") } - modes := []string{"default", "acceptEdits", "bypassPermissions", "dontAsk", "plan"} + modes := []string{"default", "edits", "bypass", "dontask", "plan"} for _, m := range modes { if !strings.Contains(bash, m) { t.Errorf("Bash completion should contain permission mode %q", m) diff --git a/cmd/container_boot.go b/cmd/container_boot.go index 84972e3f..464d8dba 100644 --- a/cmd/container_boot.go +++ b/cmd/container_boot.go @@ -5,8 +5,7 @@ import ( "fmt" "os" "os/exec" - "path/filepath" - "runtime" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -22,53 +21,18 @@ type containerStatusMsg struct { sandbox *sandbox.ContainerSandbox } -// buildHawkImage builds the hawk container image from the bundled Dockerfile. -// It writes the Dockerfile to a temp dir and runs docker build. -func buildHawkImage(ctx context.Context, tag string) bool { - dockerfile := `FROM ubuntu:24.04 -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && apt-get install -y --no-install-recommends \ - git curl wget jq tree ripgrep fd-find make gcc g++ \ - python3 python3-pip python3-venv \ - nodejs npm \ - ca-certificates openssh-client unzip xz-utils \ - && rm -rf /var/lib/apt/lists/* \ - && ln -sf /usr/bin/fdfind /usr/bin/fd -# Install Go -RUN curl -fsSL https://go.dev/dl/go1.26.1.linux-$(dpkg --print-architecture).tar.gz | tar -C /usr/local -xz -ENV PATH="/usr/local/go/bin:${PATH}" -ENV GOPATH="/root/go" -ENV PATH="${GOPATH}/bin:${PATH}" -ENV TERM=xterm-256color LANG=C.UTF-8 -` - // Use platform-appropriate arch - platform := runtime.GOARCH - if platform == "arm64" { - platform = "linux/arm64" - } else { - platform = "linux/amd64" - } - - tmpDir, err := os.MkdirTemp("", "hawk-build-") - if err != nil { +// shouldUseContainer determines if hawk should run in container mode. +// Default: container-first when Docker is available. Opt out with --no-container +// or HAWK_NO_CONTAINER=1 (useful on low-memory hosts where docker pull/build +// can trigger jetsam kills). +func shouldUseContainer() bool { + if noContainer { return false } - defer func() { _ = os.RemoveAll(tmpDir) }() - - dfPath := filepath.Join(tmpDir, "Dockerfile") - if err := os.WriteFile(dfPath, []byte(dockerfile), 0o644); err != nil { + if v := strings.TrimSpace(os.Getenv("HAWK_NO_CONTAINER")); v == "1" || strings.EqualFold(v, "true") { return false } - - cmd := exec.CommandContext(ctx, "docker", "build", "--platform", platform, "-t", tag, "-f", dfPath, tmpDir) - return cmd.Run() == nil -} - -// shouldUseContainer determines if hawk should run in container mode. -// Default: ALWAYS Container-first, no fallback. -// User can opt out with --no-container for host mode. -func shouldUseContainer() bool { - return !noContainer + return true } // bootContainerCmd starts the container in the background and sends status @@ -84,23 +48,19 @@ func bootContainerCmd(projectDir string) tea.Cmd { } } - // Ensure image exists locally — pull or build as needed + // Only start when the image is already local. Pull/build during TUI + // startup can spike memory (jetsam "killed" on 8GB Macs) and block chat. image := cs.Image() - imgCtx, imgCancel := context.WithTimeout(context.Background(), 300*time.Second) + imgCtx, imgCancel := context.WithTimeout(context.Background(), 10*time.Second) defer imgCancel() checkCmd := exec.CommandContext(imgCtx, "docker", "image", "inspect", image) if checkCmd.Run() != nil { - // Image not available locally — try pull first - pullCmd := exec.CommandContext(imgCtx, "docker", "pull", image) - if pullCmd.Run() != nil { - // Pull failed — build from bundled Dockerfile - built := buildHawkImage(imgCtx, image) - if !built { - return containerStatusMsg{ - status: "image build failed", - err: fmt.Errorf("could not pull or build %s", image), - } - } + return containerStatusMsg{ + status: "image missing", + err: fmt.Errorf( + "container image %s is not local — run: docker pull %s\nOr restart with --no-container for host mode", + image, image, + ), } } diff --git a/cmd/errors.go b/cmd/errors.go index 5b855ecc..7cafb9e4 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -78,6 +78,15 @@ func friendlyError(err error) string { return "A tool execution timed out. The command may be taking too long.\n Try breaking the task into smaller steps." } + // ── Reasoning-only response (thinking tokens but no answer) ─────────── + if strings.Contains(low, "error_only_reasoning") || + strings.Contains(low, "reasoning tokens but no answer") || + strings.Contains(low, "reasoning but no answer") { + return "The model produced internal reasoning but no reply.\n" + + " This often happens with reasoning models on OpenCode Go / MiniMax when the provider drops the answer after thinking.\n" + + " Try /model to switch model, or pick a non-reasoning model for simple chat." + } + // ── Rate limiting (429) ─────────────────────────────────────────────── if strings.Contains(low, "429") || strings.Contains(low, "rate limit") || strings.Contains(low, "rate_limit") || strings.Contains(low, "too many requests") { base := "Rate limited by the API provider." diff --git a/cmd/manpage.go b/cmd/manpage.go index d63b170a..a90d100d 100644 --- a/cmd/manpage.go +++ b/cmd/manpage.go @@ -47,7 +47,8 @@ func GenerateManPage() string { {"--system-prompt TEXT", "Custom system prompt"}, {"--system-prompt-file FILE", "Read system prompt from file"}, {"--append-system-prompt TEXT", "Append text to system prompt"}, - {"--permission-mode MODE", "Permission mode: default, acceptEdits, bypassPermissions, plan"}, + {"--permission-mode MODE", "Advanced permission mode: default, edits, bypass, dontask, or plan"}, + {"--sandbox MODE", "Permission sandbox: strict, workspace, or off"}, {"--max-turns N", "Maximum agentic turns in non-interactive mode"}, {"--max-budget-usd AMOUNT", "Maximum estimated API spend in USD"}, {"--tools TOOLS", "Comma-separated tool list"}, diff --git a/cmd/ocg_live_test.go b/cmd/ocg_live_test.go new file mode 100644 index 00000000..245825a0 --- /dev/null +++ b/cmd/ocg_live_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "strings" + "testing" + "time" + + eyriecfg "github.com/GrayCodeAI/eyrie/config" + "github.com/GrayCodeAI/eyrie/credentials" + "github.com/GrayCodeAI/eyrie/setup" + "github.com/GrayCodeAI/hawk/internal/observability/logger" +) + +func TestLiveOpenCodeGoMiniMaxM3FullHawkPath(t *testing.T) { + if credentials.LookupSecret(context.Background(), "OPENCODEGO_API_KEY") == "" { + t.Skip("OPENCODEGO_API_KEY not configured") + } + settings, err := loadEffectiveSettings() + if err != nil { + t.Fatal(err) + } + systemPrompt, err := buildSystemPrompt() + if err != nil { + t.Fatal(err) + } + effectiveModel, effectiveProvider := effectiveModelAndProvider(settings) + registry, err := defaultRegistry(settings) + if err != nil { + t.Fatal(err) + } + t.Logf("provider=%s model=%s tools=%d system_len=%d", effectiveProvider, effectiveModel, len(registry.EyrieTools()), len(systemPrompt)) + + adapter := setup.ConfiguredDeploymentAdapters(eyriecfg.LoadProviderConfig(""))["opencodego"] + t.Logf("adapter_type=%T", adapter.Provider) + + sess := newHawkSession(settings, effectiveProvider, effectiveModel, systemPrompt, registry) + sess.SetLogger(logger.New(ioDiscard{}, logger.Info)) + if err := configureSession(sess, settings); err != nil { + t.Fatal(err) + } + sess.AddUser("Hi") + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + ch, err := sess.Stream(ctx) + if err != nil { + t.Fatal(err) + } + var content, thinking strings.Builder + for ev := range ch { + switch ev.Type { + case "content": + content.WriteString(ev.Content) + case "thinking": + thinking.WriteString(ev.Content) + t.Logf("thinking chunk len=%d", len(ev.Content)) + case "error": + t.Logf("error: %s", ev.Content) + case "done": + t.Log("done") + } + } + t.Logf("content_len=%d thinking_len=%d", content.Len(), thinking.Len()) + if content.Len() == 0 { + t.Fatalf("reasoning-only or empty: thinking_len=%d model=%s", thinking.Len(), effectiveModel) + } +} + +// ioDiscard is a minimal io.Writer for tests. +type ioDiscard struct{} + +func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go new file mode 100644 index 00000000..1da5bcf4 --- /dev/null +++ b/cmd/permissions_center.go @@ -0,0 +1,410 @@ +package cmd + +import ( + "fmt" + "strings" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/engine" + tea "github.com/charmbracelet/bubbletea" +) + +const defaultPermissionSandbox = "workspace" + +func normalizePermissionTier(raw string) (engine.AutonomyLevel, string, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "scout", "basic", "read": + return engine.AutonomyBasic, "Scout", true + case "builder", "semi", "edit": + return engine.AutonomySemi, "Builder", true + case "operator", "full", "run": + return engine.AutonomyFull, "Operator", true + case "autonomous", "yolo", "auto": + return engine.AutonomyYOLO, "Autonomous", true + default: + return 0, "", false + } +} + +func permissionTierSettingValue(level engine.AutonomyLevel) int { + switch level { + case engine.AutonomyBasic: + return 1 + case engine.AutonomySemi: + return 2 + case engine.AutonomyFull: + return 3 + case engine.AutonomyYOLO: + return 4 + default: + return 0 + } +} + +func effectivePermissionTier(sess *engine.Session) engine.AutonomyLevel { + if sess == nil || sess.Autonomy == 0 { + return DefaultContainerAutonomy + } + return sess.Autonomy +} + +func normalizePermissionSandbox(raw string) (string, string, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return defaultPermissionSandbox, "Workspace", true + case "strict": + return "strict", "Strict", true + case "workspace": + return "workspace", "Workspace", true + case "off": + return "off", "Off", true + default: + return "", "", false + } +} + +func effectivePermissionSandbox(settings hawkconfig.Settings) string { + if normalized, _, ok := normalizePermissionSandbox(sandboxFlag); ok && strings.TrimSpace(sandboxFlag) != "" { + return normalized + } + if normalized, _, ok := normalizePermissionSandbox(settings.Sandbox); ok { + return normalized + } + return defaultPermissionSandbox +} + +func permissionBehaviorSummary(level engine.AutonomyLevel) string { + switch level { + case engine.AutonomyBasic: + return "reads auto-approve; edits and commands ask first" + case engine.AutonomySemi: + return "reads and file changes auto-approve; commands ask first" + case engine.AutonomyFull: + return "reads, edits, and normal commands auto-run; risky actions ask first" + case engine.AutonomyYOLO: + return "minimal prompts; only highest-risk actions stop" + default: + return "reads and file changes auto-approve; commands ask first" + } +} + +func normalizePermissionMode(raw string) (engine.PermissionMode, string, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "default", "normal", "standard": + return engine.PermissionModeDefault, "Default", true + case "acceptedits", "edit", "edits": + return engine.PermissionModeAcceptEdits, "Accept Edits", true + case "bypasspermissions", "bypass", "full-auto", "fullauto": + return engine.PermissionModeBypassPermissions, "Bypass Permissions", true + case "dontask", "deny", "blocked": + return engine.PermissionModeDontAsk, "Don't Ask", true + case "plan": + return engine.PermissionModePlan, "Plan", true + default: + return "", "", false + } +} + +func permissionModeSummary(mode engine.PermissionMode) string { + switch mode { + case engine.PermissionModeAcceptEdits: + return "file edits auto-approve even when commands still ask" + case engine.PermissionModeBypassPermissions: + return "all permission prompts are bypassed" + case engine.PermissionModeDontAsk: + return "mutating and gated tools are blocked" + case engine.PermissionModePlan: + return "read-only planning workflow; write and mutating actions are denied" + default: + return "normal approval flow uses tier, sandbox, and rules" + } +} + +func permissionCommandHelp() string { + return "Permission Center\n" + + " /permissions Show current tier, sandbox, and rules\n" + + " /permissions tier \n" + + " /permissions sandbox \n" + + " /permissions mode \n" + + " /permissions allow \n" + + " /permissions deny \n" + + " /permissions rules Show current allow/deny rules\n" + + " /permissions rules clear Clear current session rules\n" + + " /permissions reset Reset tier, sandbox, mode, and rules\n" + + " /permissions save [project|global] Persist the current policy" +} + +func permissionCenterSummary(m *chatModel) string { + if m == nil || m.session == nil { + return "Permission Center unavailable." + } + level := effectivePermissionTier(m.session) + tier := autonomyTierName(level) + _, sandboxLabel, _ := normalizePermissionSandbox(effectivePermissionSandbox(m.settings)) + allowRules := effectiveAllowRules(m.settings) + denyRules := effectiveDenyRules(m.settings) + var b strings.Builder + b.WriteString("Permission Center\n") + b.WriteString(fmt.Sprintf(" Tier: %s\n", tier)) + b.WriteString(fmt.Sprintf(" Sandbox: %s\n", sandboxLabel)) + b.WriteString(fmt.Sprintf(" Mode: %s\n", permissionModeLabel(m.session))) + b.WriteString(fmt.Sprintf(" Rules: %d allow, %d deny\n", len(allowRules), len(denyRules))) + b.WriteString(fmt.Sprintf(" Behavior: %s\n", permissionBehaviorSummary(level))) + b.WriteString(fmt.Sprintf(" Mode behavior: %s\n", permissionModeSummary(m.session.Mode))) + if len(allowRules) > 0 { + b.WriteString(" Allow: " + strings.Join(allowRules, ", ") + "\n") + } + if len(denyRules) > 0 { + b.WriteString(" Deny: " + strings.Join(denyRules, ", ") + "\n") + } + b.WriteString("\n") + b.WriteString(permissionCommandHelp()) + return strings.TrimRight(b.String(), "\n") +} + +func permissionRulesSummary(m *chatModel) string { + if m == nil { + return "No active permission state." + } + allowRules := effectiveAllowRules(m.settings) + denyRules := effectiveDenyRules(m.settings) + var b strings.Builder + b.WriteString("Permission Rules\n") + if len(allowRules) == 0 { + b.WriteString(" Allow: none\n") + } else { + b.WriteString(" Allow:\n") + for _, rule := range allowRules { + b.WriteString(" - " + rule + "\n") + } + } + if len(denyRules) == 0 { + b.WriteString(" Deny: none") + } else { + b.WriteString(" Deny:\n") + for _, rule := range denyRules { + b.WriteString(" - " + rule + "\n") + } + } + return strings.TrimRight(b.String(), "\n") +} + +func effectiveAllowRules(settings hawkconfig.Settings) []string { + var rules []string + rules = append(rules, settings.AutoAllow...) + rules = append(rules, settings.AllowedTools...) + rules = append(rules, parseToolListFromCLI(allowedToolsFlag)...) + return dedupeStrings(rules) +} + +func effectiveDenyRules(settings hawkconfig.Settings) []string { + rules := append([]string{}, settings.DisallowedTools...) + rules = append(rules, parseToolListFromCLI(disallowedToolsFlag)...) + return dedupeStrings(rules) +} + +func dedupeStrings(values []string) []string { + var out []string + seen := make(map[string]bool, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func rebuildSessionPermissionRules(sess *engine.Session, settings hawkconfig.Settings) { + if sess == nil { + return + } + if sess.Permissions == nil { + sess.Permissions = engine.NewPermissionMemory() + } + sess.Permissions.Reset() + if sess.Perm != nil { + sess.Perm.Memory = sess.Permissions + } + for _, spec := range settings.AutoAllow { + sess.Permissions.AllowSpec(spec) + } + for _, spec := range settings.AllowedTools { + sess.Permissions.AllowSpec(spec) + } + for _, spec := range settings.DisallowedTools { + sess.Permissions.DenySpec(spec) + } + for _, spec := range parseToolListFromCLI(allowedToolsFlag) { + sess.Permissions.AllowSpec(spec) + } + for _, spec := range parseToolListFromCLI(disallowedToolsFlag) { + sess.Permissions.DenySpec(spec) + } +} + +func savePermissionSettings(scope string, settings hawkconfig.Settings, level engine.AutonomyLevel) (string, error) { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + scope = "project" + } + settings.Autonomy = permissionTierSettingValue(level) + settings.Sandbox = effectivePermissionSandbox(settings) + settings.AllowedTools = dedupeStrings(settings.AllowedTools) + settings.DisallowedTools = dedupeStrings(settings.DisallowedTools) + + switch scope { + case "project": + target := hawkconfig.LoadProjectSettings() + target.AutoAllow = append([]string{}, settings.AutoAllow...) + target.AllowedTools = append([]string{}, settings.AllowedTools...) + target.DisallowedTools = append([]string{}, settings.DisallowedTools...) + target.Autonomy = settings.Autonomy + target.Sandbox = settings.Sandbox + if err := hawkconfig.SaveProject(target); err != nil { + return "", err + } + return ".hawk/settings.json", nil + case "global": + target := hawkconfig.LoadGlobalSettings() + target.AutoAllow = append([]string{}, settings.AutoAllow...) + target.AllowedTools = append([]string{}, settings.AllowedTools...) + target.DisallowedTools = append([]string{}, settings.DisallowedTools...) + target.Autonomy = settings.Autonomy + target.Sandbox = settings.Sandbox + if err := hawkconfig.SaveGlobal(target); err != nil { + return "", err + } + return "~/.hawk/settings.json", nil + default: + return "", fmt.Errorf("valid save scopes: project, global") + } +} + +func resetPermissionCenter(m *chatModel) { + if m == nil || m.session == nil { + return + } + m.session.Autonomy = DefaultContainerAutonomy + m.settings.Autonomy = permissionTierSettingValue(DefaultContainerAutonomy) + m.settings.Sandbox = defaultPermissionSandbox + sandboxFlag = defaultPermissionSandbox + m.settings.AutoAllow = nil + m.settings.AllowedTools = nil + m.settings.DisallowedTools = nil + _ = m.session.SetPermissionMode(string(engine.PermissionModeDefault)) + rebuildSessionPermissionRules(m.session, m.settings) +} + +func (m *chatModel) handlePermissionsCommand(parts []string) (chatModel, tea.Cmd) { + if m.session == nil { + m.messages = append(m.messages, displayMsg{role: "error", content: "No active session."}) + return *m, nil + } + if len(parts) == 1 { + m.messages = append(m.messages, displayMsg{role: "system", content: permissionCenterSummary(m)}) + return *m, nil + } + + switch strings.ToLower(strings.TrimSpace(parts[1])) { + case "help", "status": + m.messages = append(m.messages, displayMsg{role: "system", content: permissionCenterSummary(m)}) + case "mode": + if len(parts) < 3 { + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Current mode: %s\nBehavior: %s\nUsage: /permissions mode ", permissionModeLabel(m.session), permissionModeSummary(m.session.Mode))}) + return *m, nil + } + mode, label, ok := normalizePermissionMode(parts[2]) + if !ok { + m.messages = append(m.messages, displayMsg{role: "error", content: "Valid modes: default, edits, bypass, dontask, plan"}) + return *m, nil + } + if err := m.session.SetPermissionMode(string(mode)); err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Mode change failed: %v", err)}) + return *m, nil + } + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Permission mode → %s\nBehavior: %s", label, permissionModeSummary(mode))}) + case "tier": + if len(parts) < 3 { + m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /permissions tier "}) + return *m, nil + } + level, label, ok := normalizePermissionTier(parts[2]) + if !ok { + m.messages = append(m.messages, displayMsg{role: "error", content: "Valid tiers: scout, builder, operator, autonomous"}) + return *m, nil + } + m.session.Autonomy = level + m.settings.Autonomy = permissionTierSettingValue(level) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Permission tier → %s\nBehavior: %s", label, permissionBehaviorSummary(level))}) + case "sandbox": + if len(parts) < 3 { + _, label, _ := normalizePermissionSandbox(effectivePermissionSandbox(m.settings)) + m.messages = append(m.messages, displayMsg{role: "system", content: "Current sandbox: " + label + "\nUsage: /permissions sandbox "}) + return *m, nil + } + mode, label, ok := normalizePermissionSandbox(parts[2]) + if !ok { + m.messages = append(m.messages, displayMsg{role: "error", content: "Valid sandbox modes: strict, workspace, off"}) + return *m, nil + } + m.settings.Sandbox = mode + sandboxFlag = mode + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Sandbox preference → %s\nApplies to host Bash policy; container isolation is unchanged until restart.", label)}) + case "allow": + if len(parts) < 3 { + m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /permissions allow e.g. /permissions allow Bash(git:*)"}) + return *m, nil + } + specs := parseToolListFromCLI([]string{strings.Join(parts[2:], " ")}) + if len(specs) == 0 { + m.messages = append(m.messages, displayMsg{role: "error", content: "No valid allow rule provided."}) + return *m, nil + } + m.settings.AllowedTools = dedupeStrings(append(m.settings.AllowedTools, specs...)) + rebuildSessionPermissionRules(m.session, m.settings) + m.messages = append(m.messages, displayMsg{role: "system", content: "Allow rules updated.\n" + permissionRulesSummary(m)}) + case "deny": + if len(parts) < 3 { + m.messages = append(m.messages, displayMsg{role: "error", content: "Usage: /permissions deny e.g. /permissions deny Bash(rm -rf *)"}) + return *m, nil + } + specs := parseToolListFromCLI([]string{strings.Join(parts[2:], " ")}) + if len(specs) == 0 { + m.messages = append(m.messages, displayMsg{role: "error", content: "No valid deny rule provided."}) + return *m, nil + } + m.settings.DisallowedTools = dedupeStrings(append(m.settings.DisallowedTools, specs...)) + rebuildSessionPermissionRules(m.session, m.settings) + m.messages = append(m.messages, displayMsg{role: "system", content: "Deny rules updated.\n" + permissionRulesSummary(m)}) + case "rules": + if len(parts) > 2 && strings.EqualFold(strings.TrimSpace(parts[2]), "clear") { + m.settings.AutoAllow = nil + m.settings.AllowedTools = nil + m.settings.DisallowedTools = nil + rebuildSessionPermissionRules(m.session, m.settings) + m.messages = append(m.messages, displayMsg{role: "system", content: "Permission rules cleared for the current session."}) + return *m, nil + } + m.messages = append(m.messages, displayMsg{role: "system", content: permissionRulesSummary(m)}) + case "save": + scope := "" + if len(parts) > 2 { + scope = parts[2] + } + path, err := savePermissionSettings(scope, m.settings, effectivePermissionTier(m.session)) + if err != nil { + m.messages = append(m.messages, displayMsg{role: "error", content: fmt.Sprintf("Save failed: %v", err)}) + return *m, nil + } + m.messages = append(m.messages, displayMsg{role: "system", content: "Permission policy saved to " + path}) + case "reset": + resetPermissionCenter(m) + m.messages = append(m.messages, displayMsg{role: "system", content: "Permission Center reset to defaults.\n" + permissionCenterSummary(m)}) + default: + m.messages = append(m.messages, displayMsg{role: "system", content: permissionCommandHelp()}) + } + return *m, nil +} diff --git a/cmd/permissions_center_test.go b/cmd/permissions_center_test.go new file mode 100644 index 00000000..8d1e318f --- /dev/null +++ b/cmd/permissions_center_test.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "strings" + "testing" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/engine" +) + +func TestNormalizePermissionTier(t *testing.T) { + level, label, ok := normalizePermissionTier("operator") + if !ok || level != engine.AutonomyFull || label != "Operator" { + t.Fatalf("operator = (%v, %q, %v)", level, label, ok) + } + level, label, ok = normalizePermissionTier("auto") + if !ok || level != engine.AutonomyYOLO || label != "Autonomous" { + t.Fatalf("auto = (%v, %q, %v)", level, label, ok) + } +} + +func TestNormalizePermissionSandbox(t *testing.T) { + mode, label, ok := normalizePermissionSandbox("workspace") + if !ok || mode != "workspace" || label != "Workspace" { + t.Fatalf("workspace = (%q, %q, %v)", mode, label, ok) + } + if _, _, ok := normalizePermissionSandbox("ghost"); ok { + t.Fatal("expected invalid sandbox to fail") + } +} + +func TestEffectivePermissionRules(t *testing.T) { + settings := hawkconfig.Settings{ + AutoAllow: []string{"Read"}, + AllowedTools: []string{"Bash(git:*)", "Read"}, + DisallowedTools: []string{"Bash(rm -rf *)"}, + } + allow := effectiveAllowRules(settings) + deny := effectiveDenyRules(settings) + if len(allow) != 2 { + t.Fatalf("allow len = %d, want 2 (%v)", len(allow), allow) + } + if len(deny) != 1 || deny[0] != "Bash(rm -rf *)" { + t.Fatalf("deny = %v", deny) + } +} + +func TestPermissionCenterSummary(t *testing.T) { + perm := engine.NewPermissionEngine() + perm.Mode = engine.PermissionModeAcceptEdits + model := &chatModel{ + session: &engine.Session{Autonomy: engine.AutonomySemi, Mode: engine.PermissionModeAcceptEdits, Perm: perm}, + settings: hawkconfig.Settings{ + Sandbox: "workspace", + AllowedTools: []string{"Bash(git:*)"}, + DisallowedTools: []string{"Bash(rm -rf *)"}, + }, + } + out := permissionCenterSummary(model) + for _, fragment := range []string{"Permission Center", "Tier: Builder", "Sandbox: Workspace", "Mode: Auto (Edits Allowed)", "Rules: 1 allow, 1 deny"} { + if !strings.Contains(out, fragment) { + t.Fatalf("summary %q missing %q", out, fragment) + } + } +} + +func TestNormalizePermissionMode(t *testing.T) { + mode, label, ok := normalizePermissionMode("bypass") + if !ok || mode != engine.PermissionModeBypassPermissions || label != "Bypass Permissions" { + t.Fatalf("bypass = (%q, %q, %v)", mode, label, ok) + } + mode, _, ok = normalizePermissionMode("plan") + if !ok || mode != engine.PermissionModePlan { + t.Fatalf("plan = (%q, %v)", mode, ok) + } +} diff --git a/cmd/progressive_disclosure.go b/cmd/progressive_disclosure.go index 23f8e333..82702f6b 100644 --- a/cmd/progressive_disclosure.go +++ b/cmd/progressive_disclosure.go @@ -119,7 +119,7 @@ Workflow: /diff Show git diff /lint [cmd] Run linter /check Full pre-ship check (review + fix + verify) - /plan Enter read-only planning mode + /permissions Permission center for tier, sandbox, mode, rules /research Autonomous research loop /vibe Enter vibe coding mode /think Turn idea into approved plan @@ -203,8 +203,7 @@ Diagnostics: System: /version Show version /env Show environment - /sandbox Toggle approval mode - /yolo Toggle auto-approve + /permissions Permission center /vim Toggle vim mode /theme Set theme /voice Toggle voice input diff --git a/cmd/root.go b/cmd/root.go index 5d6b7ba3..ffa872b0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -188,7 +188,7 @@ func init() { rootCmd.Flags().StringArrayVar(&toolsFlag, "tools", nil, `available tools: "" disables all tools, "default" enables all, or names like "Bash,Edit,Read"`) rootCmd.Flags().StringArrayVar(&allowedToolsFlag, "allowed-tools", nil, `comma or space-separated tool permission rules to allow (e.g. "Bash(git:*) Edit")`) rootCmd.Flags().StringArrayVar(&disallowedToolsFlag, "disallowed-tools", nil, `comma or space-separated tool permission rules to deny (e.g. "Bash(git:*) Edit")`) - rootCmd.Flags().StringVar(&permissionMode, "permission-mode", "", "permission mode: default, acceptEdits, bypassPermissions, dontAsk, or plan") + rootCmd.Flags().StringVar(&permissionMode, "permission-mode", "", "advanced permission mode: default, edits, bypass, dontask, or plan (same as /permissions mode)") rootCmd.Flags().BoolVar(&dangerouslySkipPermissions, "dangerously-skip-permissions", false, "bypass all permission checks") rootCmd.Flags().IntVar(&maxTurns, "max-turns", 0, "maximum number of agentic turns in non-interactive mode") rootCmd.Flags().Float64Var(&maxBudgetUSD, "max-budget-usd", 0, "maximum estimated API spend in USD") @@ -196,7 +196,7 @@ func init() { rootCmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "read system prompt from a file") rootCmd.Flags().StringVar(&appendSystemPromptFlag, "append-system-prompt", "", "append text to the default or custom system prompt") rootCmd.Flags().StringVar(&appendSystemPromptFile, "append-system-prompt-file", "", "read text from a file and append it to the system prompt") - rootCmd.Flags().StringVar(&sandboxFlag, "sandbox", "", "Bash permission profile: strict, workspace, or off (not Docker; see --no-container)") + rootCmd.Flags().StringVar(&sandboxFlag, "sandbox", "", "permission sandbox: strict, workspace, or off (same as /permissions sandbox; not Docker container mode)") rootCmd.Flags().BoolVar(&autoCommitFlag, "auto-commit", false, "auto-commit file changes made by Write and Edit tools") rootCmd.Flags().BoolVar(&watchFlag, "watch", false, "watch the working directory for file changes and re-run on changes") rootCmd.Flags().BoolVar(&repoMapFlag, "repo-map", false, "inject an AST-ranked repository map (Aider-style) into the system prompt") diff --git a/cmd/statusbar.go b/cmd/statusbar.go index d4aebee9..2f45a10e 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -189,7 +189,7 @@ func containerFooterLeft(m chatModel) (bold, dim string) { return bold, " Docker is not running. Start Docker and try again." } if m.containerReady && strings.TrimSpace(m.containerStatus) != "" { - tier := "Edit" + tier := "Builder" if m.session != nil && m.session.Autonomy != 0 { tier = autonomyTierName(m.session.Autonomy) } diff --git a/cmd/theme.go b/cmd/theme.go index c6284e8c..c4df7fc4 100644 --- a/cmd/theme.go +++ b/cmd/theme.go @@ -73,7 +73,7 @@ var doneGreen = lipgloss.Color("#4CAF50") // container output reads as its own zone in the status footer. var containerBlue = lipgloss.Color("#3BAADA") -// Autonomy tier colors (Inspect → Edit → Run → Trust), coolest to hottest. +// Autonomy tier colors (Scout → Builder → Operator → Autonomous), coolest to hottest. var ( tierInspect = lipgloss.Color("#75B1E2") // infoSky — read-only tierEdit = lipgloss.Color("#4ECDC4") // successTeal — default work diff --git a/cmd/tips.go b/cmd/tips.go index 243d31f3..f95d31de 100644 --- a/cmd/tips.go +++ b/cmd/tips.go @@ -25,7 +25,7 @@ func allTips() []Tip { {ID: "slash-diff", Text: "Use /diff to review changes made during this session.", Category: "git"}, {ID: "slash-commit", Text: "Use /commit to auto-commit changes with a generated message.", Category: "git"}, {ID: "slash-doctor", Text: "Use /doctor to run diagnostics on your project.", Category: "project"}, - {ID: "slash-plan", Text: "Use /plan to enter read-only mode for safe exploration.", Category: "safety"}, + {ID: "slash-plan", Text: "Use /permissions mode plan to enter read-only mode for safe exploration.", Category: "safety"}, {ID: "tab-complete", Text: "Press Tab to autocomplete slash commands.", Category: "shortcuts"}, {ID: "history-nav", Text: "Press Up/Down to navigate command history.", Category: "shortcuts"}, {ID: "esc-cancel", Text: "Press Esc to cancel a running query.", Category: "shortcuts"}, diff --git a/cmd/welcome_gate.go b/cmd/welcome_gate.go index 78adb2cb..43911169 100644 --- a/cmd/welcome_gate.go +++ b/cmd/welcome_gate.go @@ -61,7 +61,11 @@ func (m chatModel) renderWelcomeGateChromeRow(width int) string { if err != nil { cwd = "." } - left := lipgloss.NewStyle().Foreground(statusCWDColor).Inline(true).Render(" " + shortenHomePath(cwd)) + display := shortenHomePath(cwd) + if br, gerr := gitOutput("rev-parse", "--abbrev-ref", "HEAD"); gerr == nil && br != "" && br != "HEAD" { + display += ":" + br + } + left := lipgloss.NewStyle().Foreground(statusCWDColor).Inline(true).Render(" " + display) right := lipgloss.NewStyle().Foreground(textDisabled).Inline(true).Render(quitFooterHint) return layoutFooterRow(left, right, width) } diff --git a/external/eyrie b/external/eyrie index cc782e41..25a5d200 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit cc782e41288f74b2eeab4d1e547064a5c992ff11 +Subproject commit 25a5d200db9300ddb4f619066531a2f68de5e952 diff --git a/go.work.sum b/go.work.sum index 00cf473b..38e64695 100644 --- a/go.work.sum +++ b/go.work.sum @@ -19,23 +19,138 @@ cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= +cloud.google.com/go/accessapproval v1.8.8/go.mod h1:RFwPY9JDKseP4gJrX1BlAVsP5O6kI8NdGlTmaeDefmk= +cloud.google.com/go/accesscontextmanager v1.9.7/go.mod h1:i6e0nd5CPcrh7+YwGq4bKvju5YB9sgoAip+mXU73aMM= +cloud.google.com/go/aiplatform v1.109.0/go.mod h1:4rwKOMdubQOND81AlO3EckcskvEFCYSzXKfn42GMm8k= +cloud.google.com/go/analytics v0.30.1/go.mod h1:V/FnINU5kMOsttZnKPnXfKi6clJUHTEXUKQjHxcNK8A= +cloud.google.com/go/apigateway v1.7.7/go.mod h1:j1bCmrUK1BzVHpiIyTApxB7cRyhivKzltqLmp6j6i7U= +cloud.google.com/go/apigeeconnect v1.7.7/go.mod h1:ftGK3nca0JePiVLl0A6alaMjKdOc5C+sAkFMyH2RH8U= +cloud.google.com/go/apigeeregistry v0.10.0/go.mod h1:SAlF5OhKvyLDuwWAaFAIVJjrEqKRrGTPkJs+TWNnSqg= +cloud.google.com/go/appengine v1.9.7/go.mod h1:y1XpGVeAhbsNzHida79cHbr3pFRsym0ob8xnC8yphbo= +cloud.google.com/go/area120 v0.9.7/go.mod h1:5nJ0yksmjOMfc4Zpk+okWfJ3A1004FvB82rfia+ZLaY= +cloud.google.com/go/artifactregistry v1.17.2/go.mod h1:h4CIl9TJZskg9c9u1gC9vTsOTo1PrAnnxntprqS3AjM= +cloud.google.com/go/asset v1.22.0/go.mod h1:q80JP2TeWWzMCazYnrAfDf36aQKf1QiKzzpNLflJwf8= +cloud.google.com/go/assuredworkloads v1.13.0/go.mod h1:o/oHEOnUlribR+uJWTKQo8A5RhSl9K9FNeMOew4TJ3M= cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/automl v1.15.0/go.mod h1:U9zOtQb8zVrFNGTuW3BfxeqmLyeleLgT9B12EaXfODg= +cloud.google.com/go/baremetalsolution v1.4.0/go.mod h1:K6C6g4aS8LW95I0fEHZiBsBlh0UxwDLGf+S/vyfXbvg= +cloud.google.com/go/batch v1.13.0/go.mod h1:yHFeqBn8wUjmJs4sYbwZ7N3HdeGA+FkPAXjoCKMwGak= +cloud.google.com/go/beyondcorp v1.2.0/go.mod h1:sszcgxpPPBEfLzbI0aYCTg6tT1tyt3CmKav3NZIUcvI= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.72.0/go.mod h1:GUbRtmeCckOE85endLherHD9RsujY+gS7i++c1CqssQ= +cloud.google.com/go/bigtable v1.40.1/go.mod h1:LtPzCcrAFaGRZ82Hs8xMueUeYW9Jw12AmNdUTMfDnh4= +cloud.google.com/go/billing v1.21.0/go.mod h1:ZGairB3EVnb3i09E2SxFxo50p5unPaMTuo1jh6jW9js= +cloud.google.com/go/binaryauthorization v1.10.0/go.mod h1:WOuiaQkI4PU/okwrcREjSAr2AUtjQgVe+PlrXKOmKKw= +cloud.google.com/go/certificatemanager v1.9.6/go.mod h1:vWogV874jKZkSRDFCMM3r7wqybv8WXs3XhyNff6o/Zo= +cloud.google.com/go/channel v1.20.0/go.mod h1:nBR1Lz+/1TjSA16HTllvW9Y+QULODj3o3jEKrNNeOp4= +cloud.google.com/go/cloudbuild v1.23.1/go.mod h1:Gh/k1NnFRw1DkhekO2BaR4MTg30Op6EQQHCUZCIyTAg= +cloud.google.com/go/clouddms v1.8.8/go.mod h1:QtCyw+a73dlkDb2q20aTAPvfaTZCepDDi6Gb1AKq0a4= +cloud.google.com/go/cloudtasks v1.13.7/go.mod h1:H0TThOUG+Ml34e2+ZtW6k6nt4i9KuH3nYAJ5mxh7OM4= cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= +cloud.google.com/go/compute v1.49.1/go.mod h1:1uoZvP8Avyfhe3Y4he7sMOR16ZiAm2Q+Rc2P5rrJM28= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/contactcenterinsights v1.17.4/go.mod h1:kZe6yOnKDfpPz2GphDHynxk/Spx+53UX/pGf+SmWAKM= +cloud.google.com/go/container v1.45.0/go.mod h1:eB6jUfJLjne9VsTDGcH7mnj6JyZK+KOUIA6KZnYE/ds= +cloud.google.com/go/containeranalysis v0.14.2/go.mod h1:FjppROiUtP9cyMegdWdY/TsBSGc6kqh1GjA2NOJXXL8= +cloud.google.com/go/datacatalog v1.26.1/go.mod h1:2Qcq8vsHNxMDgjgadRFmFG47Y+uuIVsyEGUrlrKEdrg= +cloud.google.com/go/dataflow v0.11.1/go.mod h1:3s6y/h5Qz7uuxTmKJKBifkYZ3zs63jS+6VGtSu8Cf7Y= +cloud.google.com/go/dataform v0.12.1/go.mod h1:atGS8ReRjfNDUQib0X/o/7Gi2bqHI2G7/J86LKiGimE= +cloud.google.com/go/datafusion v1.8.7/go.mod h1:4dkFb1la41qCEXh1AzYtFwl842bu2ikTUXyKhjvFCb0= +cloud.google.com/go/datalabeling v0.9.7/go.mod h1:EEUVn+wNn3jl19P2S13FqE1s9LsKzRsPuuMRq2CMsOk= +cloud.google.com/go/dataplex v1.28.0/go.mod h1:VB+xlYJiJ5kreonXsa2cHPj0A3CfPh/mgiHG4JFhbUA= +cloud.google.com/go/dataproc/v2 v2.15.0/go.mod h1:tSdkodShfzrrUNPDVEL6MdH9/mIEvp/Z9s9PBdbsZg8= +cloud.google.com/go/dataqna v0.9.8/go.mod h1:2lHKmGPOqzzuqCc5NI0+Xrd5om4ulxGwPpLB4AnFgpA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.21.0/go.mod h1:9l+KyAHO+YVVcdBbNQZJu8svF17Nw5sMKuFR0LYf1nY= +cloud.google.com/go/datastream v1.15.1/go.mod h1:aV1Grr9LFon0YvqryE5/gF1XAhcau2uxN2OvQJPpqRw= +cloud.google.com/go/deploy v1.27.3/go.mod h1:7LFIYYTSSdljYRqY3n+JSmIFdD4lv6aMD5xg0crB5iw= +cloud.google.com/go/dialogflow v1.71.0/go.mod h1:mP4XrpgDvPYBP+cdLxFC1WJJlkwuy0H8L1Lada9No/M= +cloud.google.com/go/dlp v1.27.0/go.mod h1:PY4DMzV7lqRC5JvpxL05fXNeL8dknxYpFp4WjxmE22M= +cloud.google.com/go/documentai v1.39.0/go.mod h1:KmlLO93F7GRU8dENXRxvt+7V8o7eCG6Y6WDitKbcYJs= +cloud.google.com/go/domains v0.10.7/go.mod h1:T3WG/QUAO/52z4tUPooKS8AY7yXaFxPYn1V3F0/JbNQ= +cloud.google.com/go/edgecontainer v1.4.4/go.mod h1:yyNVHsCKtsX/0mqFdbljQw0Uo660q2dlMPaiqYiC2Tg= +cloud.google.com/go/errorreporting v0.3.2/go.mod h1:s5kjs5r3l6A8UUyIsgvAhGq6tkqyBCUss0FRpsoVTww= +cloud.google.com/go/essentialcontacts v1.7.7/go.mod h1:ytycWAEn/aKUMRKQPMVgMrAtphEMgjbzL8vFwM3tqXs= +cloud.google.com/go/eventarc v1.17.0/go.mod h1:wB3NTIQ+l4QPirJiTMeU+YpSc5+iyoDYWV4n2/Vmh78= +cloud.google.com/go/filestore v1.10.3/go.mod h1:94ZGyLTx9j+aWKozPQ6Wbq1DuImie/L/HIdGMshtwac= cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk= +cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo= +cloud.google.com/go/functions v1.19.7/go.mod h1:xbcKfS7GoIcaXr2FSwmtn9NXal1JR4TV6iYZlgXffwA= +cloud.google.com/go/gkebackup v1.8.1/go.mod h1:GAaAl+O5D9uISH5MnClUop2esQW4pDa2qe/95A4l7YQ= +cloud.google.com/go/gkeconnect v0.12.5/go.mod h1:wMD2RXcsAWlkREZWJDVeDV70PYka1iEb9stFmgpw+5o= +cloud.google.com/go/gkehub v0.16.0/go.mod h1:ADp27Ucor8v81wY+x/5pOxTorxkPj/xswH3AUpN62GU= +cloud.google.com/go/gkemulticloud v1.5.4/go.mod h1:7l9+6Tp4jySSGj4PStO8CE6RrHFdcRARK4ScReHX1bU= +cloud.google.com/go/gsuiteaddons v1.7.8/go.mod h1:DBKNHH4YXAdd/rd6zVvtOGAJNGo0ekOh+nIjTUDEJ5U= cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/iap v1.11.3/go.mod h1:+gXO0ClH62k2LVlfhHzrpiHQNyINlEVmGAE3+DB4ShU= +cloud.google.com/go/ids v1.5.7/go.mod h1:N3ZQOIgIBwwOu2tzyhmh3JDT+kt8PcoKkn2BRT9Qe4A= +cloud.google.com/go/iot v1.8.7/go.mod h1:HvVcypV8LPv1yTXSLCNK+YCtqGHhq+p0F3BXETfpN+U= +cloud.google.com/go/kms v1.23.2/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g= +cloud.google.com/go/language v1.14.6/go.mod h1:7y3J9OexQsfkWNGCxhT+7lb64pa60e12ZCoWDOHxJ1M= +cloud.google.com/go/lifesciences v0.10.7/go.mod h1:v3AbTki9iWttEls/Wf4ag3EqeLRHofploOcpsLnu7iY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s= +cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/managedidentities v1.7.7/go.mod h1:nwNlMxtBo2YJMvsKXRtAD1bL41qiCI9npS7cbqrsJUs= +cloud.google.com/go/maps v1.26.0/go.mod h1:+auempdONAP8emtm48aCfNo1ZC+3CJniRA1h8J4u7bY= +cloud.google.com/go/mediatranslation v0.9.7/go.mod h1:mz3v6PR7+Fd/1bYrRxNFGnd+p4wqdc/fyutqC5QHctw= +cloud.google.com/go/memcache v1.11.7/go.mod h1:AU1jYlUqCihxapcJ1GGMtlMWDVhzjbfUWBXqsXa4rBg= +cloud.google.com/go/metastore v1.14.8/go.mod h1:h1XI2LpD4ohJhQYn9TwXqKb5sVt6KSo47ft96SiFF1s= cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/networkconnectivity v1.19.1/go.mod h1:Q5v6uNNNz8BP232uuXM66XgWML9m379xhwv58Y+8Kb0= +cloud.google.com/go/networkmanagement v1.21.0/go.mod h1:clG/5Yt0wQ57qSH6Yh7oehQYlobHw3F6nb3Pn4ig5hU= +cloud.google.com/go/networksecurity v0.10.7/go.mod h1:FgoictpfaJkeBlM1o2m+ngPZi8mgJetbFDH4ws1i2fQ= +cloud.google.com/go/notebooks v1.12.7/go.mod h1:uR9pxAkKmlNloibMr9Q1t8WhIu4P2JeqJs7c064/0Mo= +cloud.google.com/go/optimization v1.7.7/go.mod h1:OY2IAlX23o52qwMAZ0w65wibKuV12a4x6IHDTCq6kcU= +cloud.google.com/go/orchestration v1.11.10/go.mod h1:tz7m1s4wNEvhNNIM3JOMH0lYxBssu9+7si5MCPw/4/0= +cloud.google.com/go/orgpolicy v1.15.1/go.mod h1:bpvi9YIyU7wCW9WiXL/ZKT7pd2Ovegyr2xENIeRX5q0= +cloud.google.com/go/osconfig v1.15.1/go.mod h1:NegylQQl0+5m+I+4Ey/g3HGeQxKkncQ1q+Il4DZ8PME= +cloud.google.com/go/oslogin v1.14.7/go.mod h1:NB6NqBHfDMwznePdBVX+ILllc1oPCdNSGp5u/WIyndY= +cloud.google.com/go/phishingprotection v0.9.7/go.mod h1:JTI4HNGyAbWolBoNOoCyCF0e3cqPNrYnlievHU49EwE= +cloud.google.com/go/policytroubleshooter v1.11.7/go.mod h1:JP/aQ+bUkt4Gz6lQXBi/+A/6nyNRZ0Pvxui5Xl9ieyk= +cloud.google.com/go/privatecatalog v0.10.8/go.mod h1:BkLHi+rtAGYBt5DocXLytHhF0n6F03Tegxgty40Y7aA= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= +cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= +cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI= +cloud.google.com/go/recaptchaenterprise/v2 v2.20.5/go.mod h1:TCHn8+vtwgygBOwwbUJgRi6R9qglIpTeImsWsWDr5Lo= +cloud.google.com/go/recommendationengine v0.9.7/go.mod h1:snZ/FL147u86Jqpv1j95R+CyU5NvL/UzYiyDo6UByTM= +cloud.google.com/go/recommender v1.13.6/go.mod h1:y5/5womtdOaIM3xx+76vbsiA+8EBTIVfWnxHDFHBGJM= +cloud.google.com/go/redis v1.18.3/go.mod h1:x8HtXZbvMBDNT6hMHaQ022Pos5d7SP7YsUH8fCJ2Wm4= +cloud.google.com/go/resourcemanager v1.10.7/go.mod h1:rScGkr6j2eFwxAjctvOP/8sqnEpDbQ9r5CKwKfomqjs= +cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw= +cloud.google.com/go/retail v1.25.1/go.mod h1:J75G8pd+DH0SHueL9IJw7Y5d2VhTsjFsk+F1t9f8jXc= +cloud.google.com/go/run v1.12.1/go.mod h1:DdMsf2m0/n3WHNDcyoqZmfE+LMd/uEJ7j1yIooDrgXU= +cloud.google.com/go/scheduler v1.11.8/go.mod h1:bNKU7/f04eoM6iKQpwVLvFNBgGyJNS87RiFN73mIPik= +cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= +cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= +cloud.google.com/go/securitycenter v1.38.1/go.mod h1:Ge2D/SlG2lP1FrQD7wXHy8qyeloRenvKXeB4e7zO6z0= +cloud.google.com/go/servicedirectory v1.12.7/go.mod h1:gOtN+qbuCMH6tj2dqlDY3qQL7w3V0+nkWaZElnJK8Ps= +cloud.google.com/go/shell v1.8.7/go.mod h1:OTke7qc3laNEW5Jr5OV9VR3IwU5x5VqGOE6705zFex4= +cloud.google.com/go/spanner v1.86.1/go.mod h1:bbwCXbM+zljwSPLZ44wZOdzcdmy89hbUGmM/r9sD0ws= +cloud.google.com/go/speech v1.28.1/go.mod h1:+EN8Zuy6y2BKe9P1RAmMaFPAgBns6m+XMgXAfkYtSSE= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= +cloud.google.com/go/storagetransfer v1.13.1/go.mod h1:S858w5l383ffkdqAqrAA+BC7KlhCqeNieK3sFf5Bj4Y= +cloud.google.com/go/talent v1.8.4/go.mod h1:3yukBXUTVFNyKcJpUExW/k5gqEy8qW6OCNj7WdN0MWo= +cloud.google.com/go/texttospeech v1.16.0/go.mod h1:AeSkoH3ziPvapsuyI07TWY4oGxluAjntX+pF4PJ2jy0= +cloud.google.com/go/tpu v1.8.4/go.mod h1:ul0cyWSHr6jHGZYElZe6HvQn35VY93RAlwpDiSBRnPA= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +cloud.google.com/go/translate v1.12.7/go.mod h1:wwJp14NZyWvcrFANhIXutXj0pOBkYciBHwSlUOykcjI= +cloud.google.com/go/video v1.27.1/go.mod h1:xzfAC77B4vtnbi/TT3UUxEjCa/+Ehy5EA8w470ytOig= +cloud.google.com/go/videointelligence v1.12.7/go.mod h1:XAk5hCMY+GihxJ55jNoMdwdXSNZnCl3wGs2+94gK7MA= +cloud.google.com/go/vision/v2 v2.9.6/go.mod h1:lJC+vP15D5znJvHQYjEoTKnpToX1L93BUlvBmzM0gyg= +cloud.google.com/go/vmmigration v1.9.1/go.mod h1:jI3lBlhQn9+BKIWE/MmMsOzGekCXCc34b1M0CihL3zY= +cloud.google.com/go/vmwareengine v1.3.6/go.mod h1:ps0rb+Skgpt9ppHYC0o5DqtJ5ld2FyS8sAqtbHH8t9s= +cloud.google.com/go/vpcaccess v1.8.7/go.mod h1:9RYw5bVvk4Z51Rc8vwXT63yjEiMD/l7XyEaDyrNHgmk= +cloud.google.com/go/webrisk v1.11.2/go.mod h1:yH44GeXz5iz4HFsIlGeoVvnjwnmfbni7Lwj1SelV4f0= +cloud.google.com/go/websecurityscanner v1.7.7/go.mod h1:ng/PzARaus3Bj4Os4LpUnyYHsbtJky1HbBDmz148v1o= +cloud.google.com/go/workflows v1.14.3/go.mod h1:CC9+YdVI2Kvp0L58WajHpEfKJxhrtRh3uQ0SYWcmAk4= codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU= codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= @@ -50,12 +165,12 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/GrayCodeAI/eyrie v0.1.0/go.mod h1:g/hyB7+YCSYSkw5zJfZrWxLJyCoy2psR8QOsXN4ZzsI= github.com/GrayCodeAI/hawk v0.1.0/go.mod h1:JIiKVFiFJL52OKNW/ndHRtjzVbVy6CX3HBydPpDHkwA= github.com/GrayCodeAI/inspect v0.1.0/go.mod h1:DDhIU8Ikg2kjIXsiBupbTbLhmf4Jn8Ut7SV70spF0Qs= github.com/GrayCodeAI/sight v0.1.0/go.mod h1:hcEdCWt07/igu5HeCxB052OkQ5WOQ/f/WkEO1z1fcOM= -github.com/GrayCodeAI/tok v0.1.0/go.mod h1:oqA7HXbXuyrZ3+uJC+TKJWmYYPlyShaXGDQpftEJ9OE= github.com/GrayCodeAI/trace v0.1.0/go.mod h1:sPBHmg0kctNnRJ/bRIq9rmPFKje31BEnWdBbrNc4tRM= github.com/GrayCodeAI/yaad v0.1.0/go.mod h1:fwJ+AwEqvnoGiDA51iQ1NO696xI9j7WkkYg4UuwrmQM= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= @@ -74,9 +189,11 @@ github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW5 github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= @@ -100,6 +217,8 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa h1:rRT2qwk9xbontVloCXEUIsl1ePz0XFcIWkGi2bvmSTY= github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= @@ -112,6 +231,7 @@ github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9 github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -133,6 +253,7 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -170,9 +291,11 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gitleaks/go-gitdiff v0.9.1/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d/go.mod h1:LLeMBFApkgIKwMzirxpU9XB7NvO2HdTw5FXmeP1M6c8= github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= +github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= github.com/go-git/x/plugin/objectsigner/auto v0.0.0-20260330134459-33df49246da9/go.mod h1:iP2cXPyXc//9v9THS3y/MLi0jnt7vEqwUDj11qQfFPg= github.com/go-git/x/plugin/objectsigner/auto v0.1.0 h1:RcLW29RgwSCmqrNSs7QOxvWkRbM1vPu0Vp9TCECZjMs= @@ -209,6 +332,7 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= @@ -225,10 +349,13 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= +github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -289,6 +416,7 @@ github.com/lucasjones/reggen v0.0.0-20200904144131-37ba4fa293bb/go.mod h1:5ELEyG github.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk= github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mark3labs/mcp-go v0.49.0/go.mod h1:BflTAZAzXlrTpiO44gmjMu89n2FO56rJ9m31fp4zd5k= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -391,6 +519,7 @@ github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiE github.com/wasilibs/go-re2 v1.10.0/go.mod h1:k+5XqO2bCJS+QpGOnqugyfwC04nw0jaglmjrrkG8U6o= github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= @@ -401,6 +530,7 @@ github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiav github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.etcd.io/etcd/api/v3 v3.5.12/go.mod h1:Ot+o0SWSyT6uHhA56al1oCED0JImsRiU9Dc26+C2a+4= go.etcd.io/etcd/client/pkg/v3 v3.5.12/go.mod h1:seTzl2d9APP8R5Y2hFL3NVlD6qC/dOT+3kvrqPyTas4= go.etcd.io/etcd/client/v2 v2.305.12/go.mod h1:aQ/yhsxMu+Oht1FOupSr60oBvcS9cKXHrzBpDsPTf9E= @@ -419,6 +549,7 @@ go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwEx go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= @@ -684,6 +815,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go. google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= +google.golang.org/genproto/googleapis/bytestream v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Tej9lWiwVvQJP+b43pjJIsr/3mZycXWCIyoiXmbFf40= google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= @@ -722,6 +854,8 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +modernc.org/ccgo/v4 v4.34.2/go.mod h1:1L7us56+kAKu04p25EATpmBBvhbcqqZ85ibqWVwVgog= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/internal/config/shell_completions.go b/internal/config/shell_completions.go index 7f190eb6..9c0aa7cd 100644 --- a/internal/config/shell_completions.go +++ b/internal/config/shell_completions.go @@ -30,7 +30,7 @@ _hawk() { '--continue[Resume last session]' '--session-id[Resume specific session]:session_id' '--output-format[Output format (text/json/stream-json)]:format:(text json stream-json)' - '--permission-mode[Permission mode]:mode:(default acceptEdits bypassPermissions plan)' + '--permission-mode[Advanced permission mode]:mode:(default edits bypass dontask plan)' '--tools[Comma-separated tool list]:tools' '--add-dir[Additional allowed directory]:directory:_directories' '--verbose[Enable verbose logging]' @@ -73,7 +73,7 @@ func BashCompletion() string { return 0 ;; --permission-mode) - COMPREPLY=($(compgen -W "default acceptEdits bypassPermissions plan" -- "$cur")) + COMPREPLY=($(compgen -W "default edits bypass dontask plan" -- "$cur")) return 0 ;; --add-dir) @@ -108,7 +108,7 @@ complete -c hawk -l model -d 'Model to use' -x -a 'claude-sonnet-4-20250514 clau complete -c hawk -l provider -d 'Provider' -x -a 'anthropic openai gemini openrouter groq deepseek mistral ollama' complete -c hawk -l continue -d 'Resume last session' complete -c hawk -l output-format -d 'Output format' -x -a 'text json stream-json' -complete -c hawk -l permission-mode -d 'Permission mode' -x -a 'default acceptEdits bypassPermissions plan' +complete -c hawk -l permission-mode -d 'Advanced permission mode' -x -a 'default edits bypass dontask plan' complete -c hawk -l verbose -d 'Enable verbose logging' complete -c hawk -l no-color -d 'Disable colors' ` diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index 6a94d164..d09b97ab 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -29,6 +29,15 @@ func NewPermissionMemory() *PermissionMemory { return &PermissionMemory{allowAll: make(map[string]bool)} } +// Reset clears all allow/deny memory so the active rule set can be rebuilt. +func (pm *PermissionMemory) Reset() { + pm.mu.Lock() + defer pm.mu.Unlock() + pm.allowRules = nil + pm.denyRules = nil + pm.allowAll = make(map[string]bool) +} + // PermissionMode controls how permission prompts are handled. type PermissionMode string diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 2ad154b6..93f83406 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -40,9 +40,12 @@ func (pe *PermissionEngine) SetMode(mode string) error { func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (bool, string) { isSafe := !ToolNeedsPermission(tc.Name, tc.Args) autoCfg := PresetConfig(pe.Autonomy) - if !autoCfg.NeedsPermission(tc.Name, isSafe) || pe.PromptFn == nil { + if !autoCfg.NeedsPermission(tc.Name, isSafe) { return true, "" } + if pe.PromptFn == nil { + return false, "Permission prompt unavailable." + } summary := ToolSummary(tc.Name, tc.Args) diff --git a/internal/engine/stream.go b/internal/engine/stream.go index 28034149..73ea2b35 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -205,8 +205,11 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { ch <- StreamEvent{Type: "done"} return } - // Injection detected: warn but continue if preResult.InjectionRisk != nil && preResult.InjectionRisk.IsRisky { + if preResult.InjectionRisk.RiskLevel == "high" { + ch <- StreamEvent{Type: "error", Content: "High-risk prompt injection detected. Message blocked."} + return + } s.log.Warn("injection risk detected", map[string]interface{}{ "level": preResult.InjectionRisk.RiskLevel, }) @@ -407,11 +410,13 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { var stopReason string var lastUsage *types.EyrieUsage - // Streaming with retry for transient stream errors + // Streaming with retry for transient stream errors and reasoning-only responses. const maxStreamRetries = 2 var streamErr error + var sawThinking bool for streamAttempt := 0; streamAttempt <= maxStreamRetries; streamAttempt++ { streamErr = nil + sawThinking = false for ev := range result.Events { select { case <-ctx.Done(): @@ -424,6 +429,9 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { textContent.WriteString(ev.Content) ch <- StreamEvent{Type: "content", Content: ev.Content} case "thinking": + if strings.TrimSpace(ev.Thinking) != "" { + sawThinking = true + } ch <- StreamEvent{Type: "thinking", Content: ev.Thinking} case "tool_call": if ev.ToolCall != nil { @@ -450,18 +458,31 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { } result.Close() - if streamErr == nil { + thinkingOnly := streamErr == nil && textContent.Len() == 0 && len(toolCalls) == 0 && sawThinking + shouldRetry := thinkingOnly || (streamErr != nil && isRetryableStreamError(streamErr)) + if !shouldRetry { break } - if !isRetryableStreamError(streamErr) { + if streamAttempt >= maxStreamRetries { + if thinkingOnly { + streamErr = fmt.Errorf("error_only_reasoning: model produced reasoning but no answer") + } break } - s.log.Warn("stream retry", map[string]interface{}{"attempt": streamAttempt + 1, "error": streamErr.Error()}) + retryReason := "transient stream error" + if thinkingOnly { + retryReason = "reasoning-only response" + streamErr = fmt.Errorf("error_only_reasoning: model produced reasoning but no answer") + } + s.log.Warn("stream retry", map[string]interface{}{ + "attempt": streamAttempt + 1, + "reason": retryReason, + "error": streamErr.Error(), + }) time.Sleep(time.Duration(streamAttempt+1) * time.Second) // Notify consumer to discard previously streamed content for this turn. - // The consumer should treat content before this event as stale. - ch <- StreamEvent{Type: "retry", Content: fmt.Sprintf("retrying (attempt %d)", streamAttempt+2)} + ch <- StreamEvent{Type: "retry", Content: fmt.Sprintf("retrying after %s (attempt %d)", retryReason, streamAttempt+2)} // Re-open the stream for retry result, err = s.client.StreamChatContinue(ctx, s.messages, opts, contCfg) @@ -474,6 +495,7 @@ func (s *Session) agentLoop(ctx context.Context, ch chan<- StreamEvent) { toolCalls = nil stopReason = "" lastUsage = nil + streamErr = nil } // Providers like OpenCode Go often omit stream usage; estimate so billing footer updates. diff --git a/internal/engine/stream_guards.go b/internal/engine/stream_guards.go index 172e47ae..b6a75740 100644 --- a/internal/engine/stream_guards.go +++ b/internal/engine/stream_guards.go @@ -2,6 +2,7 @@ package engine import ( "context" + "errors" "fmt" "github.com/GrayCodeAI/hawk/internal/engine/branching" @@ -12,7 +13,11 @@ import ( // On first-stage loop detection, injects a break-loop message and continues. func (s *Session) checkGuardConditions(ctx context.Context, ch chan<- StreamEvent, turnCount int, snowball *branching.SnowballDetector, loopDet *LoopDetector) bool { if ctx.Err() != nil { - ch <- StreamEvent{Type: "content", Content: "\n\nTime budget exhausted."} + msg := "Request cancelled." + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + msg = "Time budget exhausted." + } + ch <- StreamEvent{Type: "content", Content: "\n\n" + msg} ch <- StreamEvent{Type: "done"} return false } diff --git a/internal/engine/stream_helpers.go b/internal/engine/stream_helpers.go index efd0392a..3abeaf27 100644 --- a/internal/engine/stream_helpers.go +++ b/internal/engine/stream_helpers.go @@ -5,6 +5,7 @@ import ( "time" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" + "github.com/GrayCodeAI/hawk/internal/resilience/retry" ) // truncate shortens a string to maxLen characters, appending "..." if truncated. @@ -30,11 +31,12 @@ func toolTimeout(name string) time.Duration { // isRetryableStreamError checks if a streaming error is transient and worth retrying. func isRetryableStreamError(err error) bool { + if retry.IsRetryable(err) { + return true + } msg := err.Error() return strings.Contains(msg, "connection reset") || - strings.Contains(msg, "timeout") || - strings.Contains(msg, "EOF") || - strings.Contains(msg, "broken pipe") + strings.Contains(msg, "EOF") } // shouldRemember returns true if the assistant response contains language that