From caa473a8a66ed36c516ff1ab229cf421a8a42304 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 01:32:45 +0530 Subject: [PATCH 1/2] feat(cli): --quiet flag, pager, per-stream TTY, error deduplication - Add --quiet/-q global flag to suppress spinners, progress, and decoration - Add StartPager/StopPager for long output (uses less/more via PAGER) - Add per-stream TTY detection (stdin/stdout/stderr independent) - Add IsQuiet/CanPrompt/ShouldColor/ShouldUnicode helpers in cmd/ui.go - Deduplicate error classification: single source in internal/hawkerr with ClassifyExitCode and ClassifyErrorMessage both delegating to it --- cmd/error_classify.go | 37 +++++ cmd/errors.go | 183 +---------------------- cmd/formatter.go | 14 ++ cmd/pager.go | 149 +++++++++++++++++++ cmd/root.go | 9 +- cmd/ui.go | 58 ++++++++ internal/hawkerr/classify.go | 274 +++++++++++++++++++++++++++++++++++ internal/hawkerr/exitcode.go | 90 +----------- 8 files changed, 545 insertions(+), 269 deletions(-) create mode 100644 cmd/error_classify.go create mode 100644 cmd/pager.go create mode 100644 cmd/ui.go create mode 100644 internal/hawkerr/classify.go diff --git a/cmd/error_classify.go b/cmd/error_classify.go new file mode 100644 index 00000000..32a6f1af --- /dev/null +++ b/cmd/error_classify.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + "strings" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/hawkerr" +) + +// friendlyErrorMessage returns the user-friendly message for an error. +// Delegates to the shared hawkerr.ClassifyErrorMessage for the base message, +// then enriches specific cases (like model-not-found) with dynamic hints +// that require the config package. +func friendlyErrorMessage(err error) string { + msg := hawkerr.ClassifyErrorMessage(err) + + // Enrich model-not-found errors with concrete examples from the catalog. + // The base message from hawkerr lacks these because hawkerr can't import + // internal/config (would create an import cycle). + if err != nil { + ec := hawkerr.ClassifyError(err) + if ec.ExitCode == hawkerr.ExitNotFound { + low := strings.ToLower(err.Error()) + if strings.Contains(low, "model") || strings.Contains(low, "unknown") || + strings.Contains(low, "does not exist") { + ex1, ex2 := hawkconfig.ExampleModelHints() + msg = fmt.Sprintf( + "Model not found. Check your model name with /model.\n Examples from the eyrie catalog: %s, %s\n Use /models to list all models, or /config to change provider.", + ex1, ex2, + ) + } + } + } + + return msg +} diff --git a/cmd/errors.go b/cmd/errors.go index 1d2f69b4..e3d6796c 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -7,7 +7,6 @@ import ( "os" "os/signal" "path/filepath" - "regexp" "runtime/debug" "strings" "sync" @@ -18,184 +17,12 @@ import ( "github.com/GrayCodeAI/hawk/internal/storage" ) -// ─── friendlyError ──────────────────────────────────────────────────────────── -// Translates raw API/system errors into user-friendly messages with actionable -// suggestions. Covers provider auth, network, rate limits, context overflow, -// model mismatches, file I/O, tool timeouts, config issues, MCP, and SSH. - -var reRetryAfter = regexp.MustCompile(`(?i)retry[- ]?after[:\s]+(\d+)`) - +// friendlyError translates a raw error into a user-friendly message with an +// actionable suggestion. It delegates to the shared classifyError in +// error_classify.go so the human-readable message and the exit code never +// disagree about what kind of failure occurred. func friendlyError(err error) string { - if err == nil { - return "" - } - msg := err.Error() - low := strings.ToLower(msg) - - // ── Provider-specific API key errors ────────────────────────────────── - providerKeys := []struct { - patterns []string - envVar string - provider string - }{ - {[]string{"anthropic_api_key", "anthropic api key", "x-api-key"}, "ANTHROPIC_API_KEY", "Anthropic"}, - {[]string{"openai_api_key", "openai api key", "openai key"}, "OPENAI_API_KEY", "OpenAI"}, - {[]string{"gemini_api_key", "google_api_key", "gemini api key"}, "GEMINI_API_KEY", "Gemini"}, - {[]string{"openrouter_api_key", "openrouter api key"}, "OPENROUTER_API_KEY", "OpenRouter"}, - {[]string{"canopywave_api_key", "canopywave api key"}, "CANOPYWAVE_API_KEY", "CanopyWave"}, - {[]string{"zai_payg_api_key", "zai_api_key"}, "ZAI_API_KEY", "Z.AI"}, - {[]string{"zai_coding_api_key", "zai_coding_api_key"}, "ZAI_CODING_API_KEY", "Z.AI Coding Plan"}, - {[]string{"xai_api_key", "xai api key"}, "XAI_API_KEY", "xAI (Grok)"}, - {[]string{"opencodego_api_key", "opencodego api key"}, "OPENCODEGO_API_KEY", "OpenCodeGo"}, - {[]string{"moonshot_api_key", "moonshot api key"}, "MOONSHOT_API_KEY", "Kimi (Moonshot)"}, - {[]string{"xiaomi_mimo_payg_api_key", "xiaomi mimo payg"}, "XIAOMI_MIMO_PAYG_API_KEY", "Xiaomi (MiMo) Pay-as-you-go"}, - {[]string{"xiaomi_mimo_token_plan_api_key", "xiaomi mimo token plan"}, "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "Xiaomi (MiMo) Token Plan"}, - {[]string{"xiaomi_mimo_api_key", "xiaomi mimo api key"}, "XIAOMI_MIMO_PAYG_API_KEY", "Xiaomi (MiMo)"}, - } - for _, pk := range providerKeys { - for _, pat := range pk.patterns { - if strings.Contains(low, pat) { - return fmt.Sprintf("%s API key is missing or invalid. Run /config to save it in %s.", pk.provider, hawkconfig.CredentialStoreName()) - } - } - } - - // ── SSH connection failures (check early, before generic network/auth) ── - if strings.Contains(low, "ssh") && (strings.Contains(low, "connection") || strings.Contains(low, "refused") || - strings.Contains(low, "timeout") || strings.Contains(low, "auth") || strings.Contains(low, "handshake") || - strings.Contains(low, "key exchange")) { - return "SSH connection failed. Check your SSH configuration, keys, and that the remote host is reachable.\n Try: ssh -vv to diagnose." - } - - // ── MCP server not responding (check early, before generic timeouts) ── - if strings.Contains(low, "mcp") && (strings.Contains(low, "not responding") || strings.Contains(low, "connection") || - strings.Contains(low, "failed") || strings.Contains(low, "timeout") || strings.Contains(low, "refused")) { - return "MCP server is not responding. Check that the server is running and accessible.\n Use /mcp to see configured servers, or /doctor for diagnostics." - } - - // ── Tool timeout (check early, before generic timeouts) ─────────────── - if strings.Contains(low, "tool timeout") || strings.Contains(low, "tool_timeout") || - (strings.Contains(low, "tool") && strings.Contains(low, "timed out")) { - 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." - if match := reRetryAfter.FindStringSubmatch(msg); len(match) > 1 { - base += fmt.Sprintf(" Retry after %s seconds.", match[1]) - } - base += " Wait a moment and try again, or switch providers with /config." - return base - } - - // ── Authentication / authorization ──────────────────────────────────── - if strings.Contains(low, "401") || strings.Contains(low, "unauthorized") || strings.Contains(low, "invalid api key") || strings.Contains(low, "invalid_api_key") || strings.Contains(low, "authentication") { - return "Authentication failed. Your API key may be invalid or expired.\n Check with /env, or update it with /config." - } - if strings.Contains(low, "403") || strings.Contains(low, "forbidden") || strings.Contains(low, "access denied") { - return "Access denied by the API provider. Verify your API key has the required permissions." - } - - // ── Provider billing / credits (OpenRouter free tier, etc.) ─────────── - if strings.Contains(low, "requires more credits") || strings.Contains(low, "can only afford") || - strings.Contains(low, "insufficient credits") || strings.Contains(low, "insufficient balance") || - strings.Contains(low, "payment required") || strings.Contains(low, "out of credits") { - return "Insufficient provider credits for this request.\n Add credits at your provider dashboard, switch to a cheaper model with /model, or try again with a shorter prompt." - } - - // ── Context too long / token limit ──────────────────────────────────── - if strings.Contains(low, "context length") || strings.Contains(low, "context_length") || - strings.Contains(low, "token limit") || strings.Contains(low, "too many tokens") || - strings.Contains(low, "maximum context") || - strings.Contains(low, "max_tokens exceeded") || strings.Contains(low, "max tokens exceeded") || - strings.Contains(low, "context window") || strings.Contains(low, "prompt is too long") { - return "The conversation exceeds the model's context window.\n Use /compact to summarize and free up space, or start a new session." - } - - // ── Invalid model name ──────────────────────────────────────────────── - if strings.Contains(low, "model not found") || strings.Contains(low, "model_not_found") || - strings.Contains(low, "unknown model") || strings.Contains(low, "invalid model") || - strings.Contains(low, "does not exist") || (strings.Contains(low, "404") && strings.Contains(low, "model")) { - ex1, ex2 := hawkconfig.ExampleModelHints() - return fmt.Sprintf( - "Model not found. Check your model name with /model.\n Examples from the eyrie catalog: %s, %s\n Use /models to list all models, or /config to change provider.", - ex1, ex2, - ) - } - - // ── Network unreachable / connection refused / DNS ───────────────────── - if strings.Contains(low, "network is unreachable") || strings.Contains(low, "network unreachable") { - return "Network is unreachable. Check that you have an active internet connection." - } - if strings.Contains(low, "connection refused") { - return "Connection refused. The API endpoint may be down, or a local proxy/firewall is blocking the connection.\n If using Ollama, make sure it is running (ollama serve)." - } - if strings.Contains(low, "no such host") || strings.Contains(low, "dns") || - strings.Contains(low, "lookup") && strings.Contains(low, "no such host") { - return "DNS resolution failed. Check your internet connection and DNS settings." - } - if strings.Contains(low, "connection reset") || strings.Contains(low, "broken pipe") || - strings.Contains(low, "eof") && (strings.Contains(low, "unexpected") || strings.Contains(low, "connection")) { - return "Connection was reset by the server. This may be a transient issue -- try again." - } - - // ── HTTP status codes (generic) ─────────────────────────────────────── - if strings.Contains(low, "404") || strings.Contains(low, "not found") { - return "Endpoint or resource not found. Check your model with /model or provider with /config." - } - if strings.Contains(low, "500") || strings.Contains(low, "internal server error") { - return "The API provider returned a server error (500). Try again shortly." - } - if strings.Contains(low, "502") || strings.Contains(low, "bad gateway") { - return "The API provider is temporarily unavailable (502). Try again shortly." - } - if strings.Contains(low, "503") || strings.Contains(low, "service unavailable") { - return "The API provider is temporarily unavailable (503). Try again shortly." - } - if strings.Contains(low, "504") || strings.Contains(low, "gateway timeout") { - return "The API provider timed out (504). The request may have been too large -- try /compact." - } - - // ── Timeouts ────────────────────────────────────────────────────────── - if strings.Contains(low, "timeout") || strings.Contains(low, "deadline exceeded") || - strings.Contains(low, "context canceled") { - return "Request timed out. Check your connection and try again, or use /compact to reduce context size." - } - - // ── Permission denied on file operations ────────────────────────────── - if strings.Contains(low, "permission denied") { - return "Permission denied. Check file/directory permissions.\n You may need to adjust permissions or run from a writable directory." - } - - // ── Disk full ───────────────────────────────────────────────────────── - if strings.Contains(low, "no space left") || strings.Contains(low, "disk full") || - strings.Contains(low, "not enough space") || strings.Contains(low, "disk quota") { - return "Disk is full or quota exceeded. Free up space and try again.\n Check Hawk's user state sessions directory for old sessions you can remove." - } - - // ── Invalid JSON in config/settings ─────────────────────────────────── - if (strings.Contains(low, "json") || strings.Contains(low, "unmarshal") || strings.Contains(low, "syntax error")) && - (strings.Contains(low, "settings") || strings.Contains(low, "config") || strings.Contains(low, "parse") || strings.Contains(low, "invalid character")) { - return "Invalid JSON in configuration. Check your Hawk settings.json files for syntax errors.\n Tip: use a JSON linter to find the issue." - } - - // ── TLS / certificate errors ────────────────────────────────────────── - if strings.Contains(low, "certificate") || strings.Contains(low, "tls") || strings.Contains(low, "x509") { - return "TLS/certificate error. This may be caused by a corporate proxy, expired certificate, or network issue.\n If behind a proxy, you may need to configure custom CA certificates." - } - - // ── Fallback ────────────────────────────────────────────────────────── - return msg + return friendlyErrorMessage(err) } // ─── panicRecovery ──────────────────────────────────────────────────────────── diff --git a/cmd/formatter.go b/cmd/formatter.go index 54ba97e6..8f2d08ca 100644 --- a/cmd/formatter.go +++ b/cmd/formatter.go @@ -21,6 +21,20 @@ var stdoutIsTerminal = func() bool { return term.IsTerminal(int(os.Stdout.Fd())) } +// stdinIsTerminal reports whether stdin is connected to a terminal (TTY). +// Used to decide whether interactive prompts are viable. It is a var so tests +// can override it. +var stdinIsTerminal = func() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + +// stderrIsTerminal reports whether stderr is connected to a terminal (TTY). +// Used to gate decorative output on the error stream independently. It is a +// var so tests can override it. +var stderrIsTerminal = func() bool { + return term.IsTerminal(int(os.Stderr.Fd())) +} + // TreeNode represents a node in a tree structure for FormatTree. type TreeNode struct { Name string diff --git a/cmd/pager.go b/cmd/pager.go new file mode 100644 index 00000000..1a7fb033 --- /dev/null +++ b/cmd/pager.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "golang.org/x/term" +) + +// pager manages an external pager process (less, more, etc.) for long output. +// When stdout is a TTY and the user hasn't disabled paging, StartPager() spawns +// the pager and returns an io.Writer that pipes into it. Call StopPager() when +// done to wait for the pager to exit. +// +// Modeled on GitHub's gh CLI IOStreams pager pattern. +type pager struct { + cmd *exec.Cmd + pipe io.WriteCloser +} + +var activePager *pager + +// StartPager launches an external pager if all of the following hold: +// - stdout is a TTY +// - the --quiet flag is not set +// - the PAGER environment variable is not set to "cat" or empty +// - a pager binary (less, more) is available on PATH +// +// It returns an io.Writer that should be used for all subsequent output. If +// paging is not active, it returns os.Stdout. +func StartPager() io.Writer { + if quietFlag || !stdoutIsTerminal() { + return os.Stdout + } + + pagerCmd := resolvePager() + if pagerCmd == "" { + return os.Stdout + } + + // Parse the pager command (may include flags, e.g. "less -FRX") + fields := strings.Fields(pagerCmd) + if len(fields) == 0 { + return os.Stdout + } + + name := fields[0] + args := fields[1:] + + // less defaults: quit-if-one-screen, no init, preserve color, raw control chars. + if name == "less" { + args = append([]string{"-FRX"}, args...) + } + + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + pipe, err := cmd.StdinPipe() + if err != nil { + return os.Stdout + } + + if err := cmd.Start(); err != nil { + _ = pipe.Close() + return os.Stdout + } + + activePager = &pager{cmd: cmd, pipe: pipe} + return pipe +} + +// StopPager waits for the pager process to exit and cleans up resources. It is +// safe to call even if no pager was started. +func StopPager() { + if activePager == nil { + return + } + p := activePager + activePager = nil + + // Close the write pipe first so the pager sees EOF and can exit. + if p.pipe != nil { + _ = p.pipe.Close() + } + if p.cmd != nil { + _, _ = p.cmd.Process.Wait() + } +} + +// resolvePager returns the pager command string from environment or defaults. +// Returns "" if paging should be disabled. +func resolvePager() string { + // Disable paging via environment. + if v := os.Getenv("HAWK_PAGER"); v != "" { + if v == "cat" || v == "none" { + return "" + } + return v + } + + // Respect a generic PAGER but allow "cat" to disable. + if v := os.Getenv("PAGER"); v != "" { + if v == "cat" || v == "none" { + return "" + } + return v + } + + // Auto-detect: prefer less, fall back to more. + if path, err := exec.LookPath("less"); err == nil { + return path + } + if path, err := exec.LookPath("more"); err == nil { + return path + } + return "" +} + +// IsPagerActive reports whether a pager is currently running. +func IsPagerActive() bool { + return activePager != nil +} + +// ShouldPage reports whether the given line count would benefit from paging. +// Used as a heuristic: if output exceeds the terminal height, paging helps. +func shouldPage(lineCount int) bool { + if !stdoutIsTerminal() || quietFlag { + return false + } + _, height, err := safeTermSize() + if err != nil || height == 0 { + return false + } + return lineCount > height +} + +// safeTermSize returns the terminal width and height without panicking. +func safeTermSize() (width, height int, err error) { + fd := int(os.Stdout.Fd()) + if !term.IsTerminal(fd) { + err = fmt.Errorf("not a terminal") + return + } + return term.GetSize(fd) +} diff --git a/cmd/root.go b/cmd/root.go index 8b1b1e08..7652532f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,6 +64,7 @@ var ( recoverFlag bool startupProfileFlag bool preflightLiveFlag bool + quietFlag bool ) var ( @@ -226,6 +227,7 @@ func init() { rootCmd.Flags().BoolVar(&skipCatalogRefreshFlag, "no-auto-catalog-refresh", false, "disable automatic catalog refresh when cache is missing, empty, or stale") rootCmd.Flags().BoolVar(&recoverFlag, "recover", false, "scan for interrupted sessions and offer to resume") rootCmd.Flags().BoolVar(&startupProfileFlag, "startup-profile", false, "print startup performance profile") + rootCmd.Flags().BoolVarP(&quietFlag, "quiet", "q", false, "suppress non-essential output (spinners, progress, decoration); machine-parseable output only") preflightCmd.Flags().BoolVar(&preflightLiveFlag, "live", false, "verify selected provider connectivity and authentication") rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(setupCmd) @@ -281,12 +283,9 @@ func confirmDangerousSkipPermissions() error { } // isStdinTerminal reports whether stdin is connected to a terminal. +// Delegates to the shared stdinIsTerminal so tests can override uniformly. func isStdinTerminal() bool { - fi, err := os.Stdin.Stat() - if err != nil { - return false - } - return fi.Mode()&os.ModeCharDevice != 0 + return stdinIsTerminal() } var completionCmd = &cobra.Command{ diff --git a/cmd/ui.go b/cmd/ui.go new file mode 100644 index 00000000..aa1d2156 --- /dev/null +++ b/cmd/ui.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "os" + + "golang.org/x/term" +) + +// IsQuiet returns true when the user has requested machine-parseable output +// via --quiet / -q. When quiet, spinners, progress bars, decorative boxes, and +// ANSI color should be suppressed. +func IsQuiet() bool { + return quietFlag +} + +// CanPrompt returns true when the user can be interactively prompted (stdin is +// a TTY and --quiet is not set). +func CanPrompt() bool { + return !quietFlag && stdinIsTerminal() +} + +// ShouldColor returns true when colored output is appropriate for stdout. +// Respects --quiet, NO_COLOR, FORCE_COLOR, and TTY state. +func ShouldColor() bool { + if quietFlag { + return false + } + if os.Getenv("NO_COLOR") != "" { + return false + } + if os.Getenv("FORCE_COLOR") != "" { + return true + } + return stdoutIsTerminal() +} + +// ShouldUnicode returns true when Unicode box-drawing and glyphs are +// appropriate for stdout. Same gates as ShouldColor but also requires a TTY. +func ShouldUnicode() bool { + if !ShouldColor() { + return false + } + return stdoutIsTerminal() +} + +// TermSize returns the terminal dimensions for the given stream, falling back +// to sensible defaults when not a terminal. +func TermSize() (width, height int) { + fd := int(os.Stdout.Fd()) + if !term.IsTerminal(fd) { + return 80, 24 + } + w, h, err := term.GetSize(fd) + if err != nil || w == 0 || h == 0 { + return 80, 24 + } + return w, h +} diff --git a/internal/hawkerr/classify.go b/internal/hawkerr/classify.go new file mode 100644 index 00000000..aa964bc7 --- /dev/null +++ b/internal/hawkerr/classify.go @@ -0,0 +1,274 @@ +package hawkerr + +import ( + "fmt" + "regexp" + "strings" +) + +// errorClass combines a stable exit code with a user-friendly message for a +// given error. It is the single source of truth for error classification — +// both ClassifyExitCode() and ClassifyErrorMessage() delegate here so they +// never disagree about what kind of failure occurred. +type errorClass struct { + exitCode int + message string +} + +// ClassifiedError is the public result of error classification, exposing both +// the exit code and the human-readable message. +type ClassifiedError struct { + ExitCode int + Message string +} + +var reRetryAfter = regexp.MustCompile(`(?i)retry[- ]?after[:\s]+(\d+)`) + +// classify maps a raw error to a stable exit code + friendly message. +// The pattern matching order is deliberate — most specific / most actionable +// classes first. This function is the single source of truth used by both +// ClassifyExitCode() (script exit code) and ClassifyErrorMessage() (human +// output). +// +// A nil error yields (ExitOK, ""). +func classify(err error) errorClass { + if err == nil { + return errorClass{exitCode: ExitOK, message: ""} + } + msg := err.Error() + low := strings.ToLower(msg) + + // ── Provider-specific API key errors ────────────────────────────────── + providerKeys := []struct { + patterns []string + provider string + }{ + {[]string{"anthropic_api_key", "anthropic api key", "x-api-key"}, "Anthropic"}, + {[]string{"openai_api_key", "openai api key", "openai key"}, "OpenAI"}, + {[]string{"gemini_api_key", "google_api_key", "gemini api key"}, "Gemini"}, + {[]string{"openrouter_api_key", "openrouter api key"}, "OpenRouter"}, + {[]string{"canopywave_api_key", "canopywave api key"}, "CanopyWave"}, + {[]string{"zai_payg_api_key", "zai_api_key"}, "Z.AI"}, + {[]string{"zai_coding_api_key", "zai_coding_api_key"}, "Z.AI Coding Plan"}, + {[]string{"xai_api_key", "xai api key"}, "xAI (Grok)"}, + {[]string{"opencodego_api_key", "opencodego api key"}, "OpenCodeGo"}, + {[]string{"moonshot_api_key", "moonshot api key"}, "Kimi (Moonshot)"}, + {[]string{"xiaomi_mimo_payg_api_key", "xiaomi mimo payg"}, "Xiaomi (MiMo) Pay-as-you-go"}, + {[]string{"xiaomi_mimo_token_plan_api_key", "xiaomi mimo token plan"}, "Xiaomi (MiMo) Token Plan"}, + {[]string{"xiaomi_mimo_api_key", "xiaomi mimo api key"}, "Xiaomi (MiMo)"}, + } + for _, pk := range providerKeys { + for _, pat := range pk.patterns { + if strings.Contains(low, pat) { + return errorClass{ + exitCode: ExitAuth, + message: fmt.Sprintf("%s API key is missing or invalid. Run /config to save it in your OS credential store.", pk.provider), + } + } + } + } + + // ── SSH connection failures (check early, before generic network/auth) ── + if strings.Contains(low, "ssh") && (strings.Contains(low, "connection") || strings.Contains(low, "refused") || + strings.Contains(low, "timeout") || strings.Contains(low, "auth") || strings.Contains(low, "handshake") || + strings.Contains(low, "key exchange")) { + return errorClass{ + exitCode: ExitNetwork, + message: "SSH connection failed. Check your SSH configuration, keys, and that the remote host is reachable.\n Try: ssh -vv to diagnose.", + } + } + + // ── MCP server not responding (check early, before generic timeouts) ── + if strings.Contains(low, "mcp") && (strings.Contains(low, "not responding") || strings.Contains(low, "connection") || + strings.Contains(low, "failed") || strings.Contains(low, "timeout") || strings.Contains(low, "refused")) { + return errorClass{ + exitCode: ExitToolFailure, + message: "MCP server is not responding. Check that the server is running and accessible.\n Use /mcp to see configured servers, or /doctor for diagnostics.", + } + } + + // ── Tool timeout (check early, before generic timeouts) ─────────────── + if strings.Contains(low, "tool timeout") || strings.Contains(low, "tool_timeout") || + (strings.Contains(low, "tool") && strings.Contains(low, "timed out")) { + return errorClass{ + exitCode: ExitToolFailure, + message: "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 errorClass{ + exitCode: ExitGeneral, + message: "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." + if match := reRetryAfter.FindStringSubmatch(msg); len(match) > 1 { + base += fmt.Sprintf(" Retry after %s seconds.", match[1]) + } + base += " Wait a moment and try again, or switch providers with /config." + return errorClass{exitCode: ExitRateLimit, message: base} + } + + // ── Provider billing / credits (OpenRouter free tier, etc.) ─────────── + if strings.Contains(low, "requires more credits") || strings.Contains(low, "can only afford") || + strings.Contains(low, "insufficient credits") || strings.Contains(low, "insufficient balance") || + strings.Contains(low, "payment required") || strings.Contains(low, "out of credits") { + return errorClass{ + exitCode: ExitRateLimit, + message: "Insufficient provider credits for this request.\n Add credits at your provider dashboard, switch to a cheaper model with /model, or try again with a shorter prompt.", + } + } + + // ── Authentication / authorization ──────────────────────────────────── + if strings.Contains(low, "401") || strings.Contains(low, "unauthorized") || strings.Contains(low, "invalid api key") || strings.Contains(low, "invalid_api_key") || strings.Contains(low, "authentication") { + return errorClass{ + exitCode: ExitAuth, + message: "Authentication failed. Your API key may be invalid or expired.\n Check with /env, or update it with /config.", + } + } + if strings.Contains(low, "403") || strings.Contains(low, "forbidden") || strings.Contains(low, "access denied") { + return errorClass{ + exitCode: ExitAuth, + message: "Access denied by the API provider. Verify your API key has the required permissions.", + } + } + + // ── Context too long / token limit ──────────────────────────────────── + if strings.Contains(low, "context length") || strings.Contains(low, "context_length") || + strings.Contains(low, "token limit") || strings.Contains(low, "too many tokens") || + strings.Contains(low, "maximum context") || + strings.Contains(low, "max_tokens exceeded") || strings.Contains(low, "max tokens exceeded") || + strings.Contains(low, "context window") || strings.Contains(low, "prompt is too long") { + return errorClass{ + exitCode: ExitContextLimit, + message: "The conversation exceeds the model's context window.\n Use /compact to summarize and free up space, or start a new session.", + } + } + + // ── Invalid model name ──────────────────────────────────────────────── + if strings.Contains(low, "model not found") || strings.Contains(low, "model_not_found") || + strings.Contains(low, "unknown model") || strings.Contains(low, "invalid model") || + strings.Contains(low, "does not exist") || (strings.Contains(low, "404") && strings.Contains(low, "model")) { + return errorClass{ + exitCode: ExitNotFound, + message: "Model not found. Check your model name with /model.\n Use /models to list all models, or /config to change provider.", + } + } + + // ── Network unreachable / connection refused / DNS ───────────────────── + if strings.Contains(low, "network is unreachable") || strings.Contains(low, "network unreachable") { + return errorClass{exitCode: ExitNetwork, message: "Network is unreachable. Check that you have an active internet connection."} + } + if strings.Contains(low, "connection refused") { + return errorClass{ + exitCode: ExitNetwork, + message: "Connection refused. The API endpoint may be down, or a local proxy/firewall is blocking the connection.\n If using Ollama, make sure it is running (ollama serve).", + } + } + if strings.Contains(low, "no such host") || strings.Contains(low, "dns") || + strings.Contains(low, "lookup") && strings.Contains(low, "no such host") { + return errorClass{ + exitCode: ExitNetwork, + message: "DNS resolution failed. Check your internet connection and DNS settings.", + } + } + if strings.Contains(low, "connection reset") || strings.Contains(low, "broken pipe") || + strings.Contains(low, "eof") && (strings.Contains(low, "unexpected") || strings.Contains(low, "connection")) { + return errorClass{exitCode: ExitNetwork, message: "Connection was reset by the server. This may be a transient issue -- try again."} + } + + // ── HTTP status codes (generic) ─────────────────────────────────────── + if strings.Contains(low, "404") || strings.Contains(low, "not found") { + return errorClass{ + exitCode: ExitNotFound, + message: "Endpoint or resource not found. Check your model with /model or provider with /config.", + } + } + if strings.Contains(low, "500") || strings.Contains(low, "internal server error") { + return errorClass{exitCode: ExitNetwork, message: "The API provider returned a server error (500). Try again shortly."} + } + if strings.Contains(low, "502") || strings.Contains(low, "bad gateway") { + return errorClass{exitCode: ExitNetwork, message: "The API provider is temporarily unavailable (502). Try again shortly."} + } + if strings.Contains(low, "503") || strings.Contains(low, "service unavailable") { + return errorClass{exitCode: ExitNetwork, message: "The API provider is temporarily unavailable (503). Try again shortly."} + } + if strings.Contains(low, "504") || strings.Contains(low, "gateway timeout") { + return errorClass{ + exitCode: ExitNetwork, + message: "The API provider timed out (504). The request may have been too large -- try /compact.", + } + } + + // ── Tool / operation timeouts ───────────────────────────────────────── + if strings.Contains(low, "timeout") || strings.Contains(low, "timed out") || + strings.Contains(low, "deadline exceeded") || strings.Contains(low, "context canceled") { + return errorClass{ + exitCode: ExitTimeout, + message: "Request timed out. Check your connection and try again, or use /compact to reduce context size.", + } + } + + // ── Policy / permission / guardrail denial ──────────────────────────── + if strings.Contains(low, "permission denied") || strings.Contains(low, "guardrail") || + strings.Contains(low, "policy") || strings.Contains(low, "blocked by") || + strings.Contains(low, "approval denied") || strings.Contains(low, "not permitted") || + strings.Contains(low, "operation not allowed") { + return errorClass{ + exitCode: ExitPolicyBlock, + message: "Permission denied. Check file/directory permissions.\n You may need to adjust permissions or run from a writable directory.", + } + } + + // ── Disk full ───────────────────────────────────────────────────────── + if strings.Contains(low, "no space left") || strings.Contains(low, "disk full") || + strings.Contains(low, "not enough space") || strings.Contains(low, "disk quota") { + return errorClass{ + exitCode: ExitDiskFull, + message: "Disk is full or quota exceeded. Free up space and try again.\n Check Hawk's user state sessions directory for old sessions you can remove.", + } + } + + // ── Invalid JSON in config/settings ─────────────────────────────────── + if (strings.Contains(low, "json") || strings.Contains(low, "unmarshal") || strings.Contains(low, "syntax error") || strings.Contains(low, "invalid character")) && + (strings.Contains(low, "settings") || strings.Contains(low, "config") || strings.Contains(low, "parse")) { + return errorClass{ + exitCode: ExitConfig, + message: "Invalid JSON in configuration. Check your Hawk settings.json files for syntax errors.\n Tip: use a JSON linter to find the issue.", + } + } + + // ── TLS / certificate errors ────────────────────────────────────────── + if strings.Contains(low, "certificate") || strings.Contains(low, "tls") || strings.Contains(low, "x509") { + return errorClass{ + exitCode: ExitNetwork, + message: "TLS/certificate error. This may be caused by a corporate proxy, expired certificate, or network issue.\n If behind a proxy, you may need to configure custom CA certificates.", + } + } + + // ── Fallback ────────────────────────────────────────────────────────── + return errorClass{exitCode: ExitGeneral, message: msg} +} + +// ClassifyError maps a raw error to a stable exit code + friendly message. +// This is the single entry point for error classification. +func ClassifyError(err error) ClassifiedError { + c := classify(err) + return ClassifiedError{ExitCode: c.exitCode, Message: c.message} +} + +// ClassifyErrorMessage returns the user-friendly message for an error. +// Delegates to the shared classify() so it never disagrees with +// ClassifyExitCode. +func ClassifyErrorMessage(err error) string { + return classify(err).message +} diff --git a/internal/hawkerr/exitcode.go b/internal/hawkerr/exitcode.go index cd224eda..427b05e3 100644 --- a/internal/hawkerr/exitcode.go +++ b/internal/hawkerr/exitcode.go @@ -1,7 +1,5 @@ package hawkerr -import "strings" - // Exit-code taxonomy. // // hawk historically collapsed every failure to exit code 1, which gives an @@ -32,91 +30,11 @@ const ( // ClassifyExitCode maps an error to a stable exit code from the taxonomy above. // -// It deliberately mirrors the textual classification already performed by the -// CLI's friendlyError (cmd/errors.go): the same provider/network/auth signals -// that produce a friendly message here produce a stable exit code, so the two -// never disagree about what kind of failure occurred. Order matters — the most -// specific and most actionable classes are checked first. +// The actual pattern matching lives in classify() so the human-readable +// message (ClassifyErrorMessage) and the exit code never disagree about what +// kind of failure occurred. // // A nil error yields ExitOK; an unrecognized error yields ExitGeneral. func ClassifyExitCode(err error) int { - if err == nil { - return ExitOK - } - low := strings.ToLower(err.Error()) - - contains := func(subs ...string) bool { - for _, s := range subs { - if strings.Contains(low, s) { - return true - } - } - return false - } - - switch { - // Rate limiting / quota / credits — checked before generic auth because a - // 429 is retriable whereas a 401 is not. - case contains("429", "rate limit", "rate_limit", "too many requests", - "insufficient credits", "insufficient balance", "out of credits", - "requires more credits", "can only afford", "quota exceeded"): - return ExitRateLimit - - // Authentication / authorization — bad or missing key, 401/403. - case contains("401", "unauthorized", "invalid api key", "invalid_api_key", - "authentication", "api key is missing", "403", "forbidden", - "access denied", "payment required"): - return ExitAuth - - // Context window overflow — distinct from a generic 400 so callers can - // react by compacting rather than aborting. - case contains("context length", "context_length", "context window", - "token limit", "too many tokens", "maximum context", - "max_tokens exceeded", "max tokens exceeded", "prompt is too long"): - return ExitContextLimit - - // Policy / permission / guardrail denial. - case contains("permission denied", "guardrail", "policy", "blocked by", - "approval denied", "not permitted", "operation not allowed"): - return ExitPolicyBlock - - // Tool execution failures and tool timeouts. - case contains("tool timeout", "tool_timeout", "tool execution", - "tool failed", "tool error"): - return ExitToolFailure - - // Disk space / quota. - case contains("no space left", "disk full", "not enough space", "disk quota"): - return ExitDiskFull - - // Malformed configuration. - case contains("invalid json in config") || - (contains("settings", "config") && contains("unmarshal", "syntax error", "invalid character")): - return ExitConfig - - // Not found — model/endpoint/resource (404). - case contains("model not found", "model_not_found", "unknown model", - "invalid model", "404", "no such host"): - // "no such host" is a DNS failure, not a 404 — route it to network. - if contains("no such host") { - return ExitNetwork - } - return ExitNotFound - - // Network errors — DNS, refused/reset connections, provider 5xx, TLS. - case contains("network is unreachable", "network unreachable", - "connection refused", "connection reset", "broken pipe", - "dns", "lookup", "500", "502", "503", "504", - "internal server error", "bad gateway", "service unavailable", - "gateway timeout", "certificate", "tls", "x509"): - return ExitNetwork - - // Timeouts / cancellation — checked after the 5xx gateway-timeout cases so - // "504 gateway timeout" stays a network error. - case contains("timeout", "timed out", "deadline exceeded", "context canceled"): - return ExitTimeout - - default: - return ExitGeneral - } + return classify(err).exitCode } From 6fd5384b36a8d54334edc99362d40537befedab1 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 01:33:51 +0530 Subject: [PATCH 2/2] fix(tui): Esc is no-op during active agent turn Prevents accidental cancellation of long-running operations. Users must press Ctrl+C to cancel mid-turn, matching Grok CLI behavior. --- cmd/chat_update.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index ad2a8393..84abd7d1 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -649,6 +649,11 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } case tea.KeyEsc: + // Mid-turn: Esc is a no-op to prevent accidental cancellation of + // long-running operations. The user must press Ctrl+C to cancel. + if m.waiting { + return m, nil + } if len(m.slashSuggestionsFor(m.input.Value())) > 0 { m.slashSel = 0 return m, nil