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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/chat_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions cmd/error_classify.go
Original file line number Diff line number Diff line change
@@ -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
}
183 changes: 5 additions & 178 deletions cmd/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os"
"os/signal"
"path/filepath"
"regexp"
"runtime/debug"
"strings"
"sync"
Expand All @@ -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 <host> 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 ────────────────────────────────────────────────────────────
Expand Down
14 changes: 14 additions & 0 deletions cmd/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@
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 {

Check failure on line 34 in cmd/formatter.go

View workflow job for this annotation

GitHub Actions / lint

var stderrIsTerminal is unused (unused)
return term.IsTerminal(int(os.Stderr.Fd()))
}

// TreeNode represents a node in a tree structure for FormatTree.
type TreeNode struct {
Name string
Expand Down
Loading
Loading