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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions cmd/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func essentialTools() []tool.Tool {
tool.MultiEditTool{},
tool.BrowserTool{},
tool.ScreenshotTool{},
tool.ToolHealthTool{},
tool.RequestCredentialTool{Gateway: func() tool.CredentialGateFn {
// The actual gateway is wired at session start via SetCredentialGate.
// This returns nil until then; the tool checks for nil and errors.
Expand All @@ -80,6 +81,23 @@ func essentialTools() []tool.Tool {
func optionalTools() []tool.Tool {
// Specialized tools that can be lazy-loaded on demand
return []tool.Tool{
// Structured developer workflows. These stay lazy until the intent
// router or ToolSearch promotes them, keeping the startup schema small.
tool.GitTool{},
tool.OutlineTool{},
&tool.SmartReaderTool{},
tool.PatchTool{},
tool.TransactionTool{},
tool.NewAutoImportTool(),
tool.ImportOrganizerTool{},
tool.NewRefactorTool(),
tool.ConflictResolverTool{},
tool.DebuggerTool{},
tool.DevEnvTool{},
tool.ProjectVerifyTool{},
tool.DependencyAuditTool{},
tool.GitHubTool{},
&tool.PRGeneratorTool{},
tool.SpecifyTool{},
tool.PlanTool{},
tool.TasksTool{},
Expand Down Expand Up @@ -277,12 +295,12 @@ func defaultRegistry(settings hawkconfig.Settings) (*tool.Registry, error) {
}
registry.EnableLazyModelSurface(essentialNames)

// Lazy-load optional tools in background (executable, not model-visible).
go func() {
for _, t := range optionalTools() {
_ = registry.Register(t)
}
}()
// Register optional tools synchronously. Registration only adds in-memory
// schemas; keeping it deterministic ensures intent promotion cannot race
// the first user turn. They remain hidden from the model until promoted.
for _, t := range optionalTools() {
_ = registry.Register(t)
}

// Load MCP tools in the background so a hung/absent stdio server delays
// tool availability — not first paint. loadStartupMCPToolSets can block up
Expand Down
28 changes: 19 additions & 9 deletions cmd/chat_welcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg
// 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 + icons.CheckBold() + rst
markPresent := greenC + ansiBold + icons.CheckBold() + rst
markNone := sepC + "○" + rst

totalW := width
Expand Down Expand Up @@ -231,8 +231,13 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg
// indicator on the welcome screen (moved out of the footer bar). When the
// CONTAINER badge is shown, the redundant iso segment is dropped.
func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown bool) string {
boldIcon := func(color, glyph string) string {
return color + ansiBold + glyph + ansiReset + color
}
work := sess.WorkMode()
modeIcon := icons.Cog()
// Use the denser terminal glyphs here. The UI already has the semantic
// text; these icons should add contrast, not vanish into the line height.
modeIcon := icons.Terminal()
modeLabel := "Action Mode"
modeColor := ansiCyan
switch work {
Expand All @@ -250,7 +255,7 @@ func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown
isoColor := ansiAmber
iso := sess.Isolation().ShortLabel()

isoSeg := " · " + isoColor + isoIcon + " " + iso + rst
isoSeg := " · " + boldIcon(isoColor, isoIcon) + " " + iso + rst
if badgeShown {
isoSeg = ""
}
Expand Down Expand Up @@ -285,9 +290,9 @@ func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown
}
}

return modeColor + modeIcon + " " + modeLabel + rst +
return boldIcon(modeColor, modeIcon) + " " + modeLabel + rst +
isoSeg +
" · " + trustColor + trustIcon + " " + trustLabel + rst
" · " + boldIcon(trustColor, trustIcon) + " " + trustLabel + rst
}

type mcpServerNamed interface {
Expand All @@ -310,6 +315,11 @@ func connectedMCPCount(registry *tool.Registry) int {
}

func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC, idleC, rst, markPresent, markNone string) string {
boldIcon := func(color, glyph string) string {
// Keep the label's color after making only the glyph bold. This gives
// narrow Nerd Font icons more visual weight without bolding the copy.
return color + ansiBold + glyph + ansiReset + color
}
skillsColor, skillsMark := idleC, markNone
if skillsCount > 0 {
skillsColor, skillsMark = ansiLightPink, markPresent
Expand All @@ -325,10 +335,10 @@ func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC,
mcpColor, mcpMark = ansiCyan, markPresent
}
return fmt.Sprintf(
"%s%s Skills (%d)%s %s · %s%s AGENTS.md%s %s · %s%s MCPs (%d)%s %s",
skillsColor, icons.Bolt(), skillsCount, rst, skillsMark,
agentsColor, icons.Robot(), rst, agentsMark,
mcpColor, icons.Network(), mcpCount, rst, mcpMark,
"%s Skills (%d)%s %s · %s AGENTS.md%s %s · %s MCPs (%d)%s %s",
boldIcon(skillsColor, icons.Bolt()), skillsCount, rst, skillsMark,
boldIcon(agentsColor, icons.Robot()), rst, agentsMark,
boldIcon(mcpColor, icons.Network()), mcpCount, rst, mcpMark,
)
}

Expand Down
1 change: 1 addition & 0 deletions cmd/container_boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func shouldUseContainer() bool {
// closed with an actionable error; there is deliberately no host fallback.
func startRequiredContainer(projectDir string) (*sandbox.ContainerSandbox, error) {
cs := sandbox.NewContainerSandbox(projectDir)
sandbox.ResetDockerAvailabilityCache()
if !dockerAvailable() {
return nil, fmt.Errorf("docker is required but is not running — start Docker and retry")
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/GrayCodeAI/hawk/internal/resilience/health"
"github.com/GrayCodeAI/hawk/internal/session"
"github.com/GrayCodeAI/hawk/internal/storage"
"github.com/GrayCodeAI/hawk/internal/tool"
"github.com/GrayCodeAI/hawk/internal/ui/icons"
)

Expand Down Expand Up @@ -267,5 +268,9 @@ func builtInToolsSummary() string {
for _, t := range optional {
b.WriteString(fmt.Sprintf(" %s - %s\n", t.Name(), t.Description()))
}
b.WriteString("\nIntent bundles:\n")
for _, summary := range tool.IntentBundleSummary() {
b.WriteString(" " + summary + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
22 changes: 19 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/GrayCodeAI/hawk/internal/onboarding"
"github.com/GrayCodeAI/hawk/internal/plugin"
"github.com/GrayCodeAI/hawk/internal/session"
"github.com/GrayCodeAI/hawk/internal/tool"
"github.com/GrayCodeAI/hawk/internal/update"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -719,12 +720,27 @@ var toolsCmd = &cobra.Command{
if toolsJSON {
tools := allTools()
type toolEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Name string `json:"name"`
Description string `json:"description"`
Risk string `json:"risk"`
ReadOnly bool `json:"read_only"`
Categories []string `json:"categories,omitempty"`
Aliases []string `json:"aliases,omitempty"`
}
entries := make([]toolEntry, len(tools))
for i, t := range tools {
entries[i] = toolEntry{Name: t.Name(), Description: t.Description()}
risk := "medium"
if rp, ok := t.(tool.RiskLevelProvider); ok && rp.RiskLevel() != "" {
risk = rp.RiskLevel()
}
var aliases []string
if aliased, ok := t.(tool.AliasedTool); ok {
aliases = aliased.Aliases()
}
entries[i] = toolEntry{
Name: t.Name(), Description: t.Description(), Risk: risk,
ReadOnly: tool.IsReadOnly(t.Name()), Categories: tool.IntentCategoriesForTool(t.Name()), Aliases: aliases,
}
}
data, _ := json.MarshalIndent(entries, "", " ")
cmd.Println(string(data))
Expand Down
12 changes: 7 additions & 5 deletions cmd/spinner_wave.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ func renderSpinnerWaveLine(glyph, verb string, wavePhase, dotPhase int) string {
head := wavePhase % total

var b strings.Builder
b.WriteString(renderSpinnerWaveSlotGlyph(glyph, wavePhase, head == 0, true))
b.WriteString(renderSpinnerWaveSlotGlyph(glyph, wavePhase, head == 0, true, false))
if verb == "" {
return b.String()
}
Expand All @@ -73,24 +73,26 @@ func renderSpinnerWaveLine(glyph, verb string, wavePhase, dotPhase int) string {
g = icons.CircleFilled()
bold = true
}
b.WriteString(renderSpinnerWaveSlotGlyph(g, wavePhase+pos, head == pos, bold))
b.WriteString(renderSpinnerWaveSlotGlyph(g, wavePhase+pos, head == pos, bold, false))
pos++
}
return b.String()
}

func renderSpinnerWaveSlot(r rune, colorIdx int, isHead, bold bool) string {
return renderSpinnerWaveSlotGlyph(string(r), colorIdx, isHead, bold)
return renderSpinnerWaveSlotGlyph(string(r), colorIdx, isHead, bold, true)
}

func renderSpinnerWaveSlotGlyph(glyph string, colorIdx int, isHead, bold bool) string {
func renderSpinnerWaveSlotGlyph(glyph string, colorIdx int, isHead, bold, italic bool) string {
color := ansiSpinnerWaveColor(colorIdx)
if isHead || bold {
color += ansiBold
}
var b strings.Builder
b.WriteString(color)
b.WriteString(ansiItalic)
if italic {
b.WriteString(ansiItalic)
}
b.WriteString(glyph)
b.WriteString(ansiReset)
return b.String()
Expand Down
11 changes: 11 additions & 0 deletions cmd/spinner_wave_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,14 @@ func TestSpinnerWave_GlyphUsesWaveColor(t *testing.T) {
t.Fatal("expected wave color on spinner glyph")
}
}

func TestSpinnerWaveIconIsNotItalicized(t *testing.T) {
out := renderSpinnerWaveLine("◐", "Go", 0, 0)
firstReset := strings.Index(out, ansiReset)
if firstReset < 0 {
t.Fatalf("spinner frame has no reset escape: %q", out)
}
if strings.Contains(out[:firstReset], ansiItalic) {
t.Fatalf("spinner icon should not be italicized: %q", out[:firstReset])
}
}
5 changes: 3 additions & 2 deletions cmd/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@ var textWhite = lipgloss.Color("#FFFFFF")
// borderDim — input/panel/divider border.
var borderDim = compat.AdaptiveColor{Light: lipgloss.Color("#C6C6C6"), Dark: lipgloss.Color("#555555")}

// bgCode — code block background.
var bgCode = lipgloss.Color("#2A2A3A")
// bgCode — code block background. The default is a dark slate surface rather
// than pure black so code blocks remain distinct without looking detached.
var bgCode = lipgloss.Color("#1B1E26")

// ---------------------------------------------------------------------------
// 9. Spinner-line ANSI escapes (raw, not lipgloss)
Expand Down
2 changes: 1 addition & 1 deletion cmd/theme_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func renderThemePreview(themeName string) string {

// Handle auto theme specially
if themeName == "auto" {
preview.WriteString(fmt.Sprintf(" Panel: %s dark\n", lipgloss.NewStyle().Background(lipgloss.Color("#0e0e10")).Render(" ")))
preview.WriteString(fmt.Sprintf(" Panel: %s dark\n", lipgloss.NewStyle().Background(lipgloss.Color("#1b1e26")).Render(" ")))
preview.WriteString(fmt.Sprintf(" Brand: %s Talon Gold\n", lipgloss.NewStyle().Background(lipgloss.Color(internaltheme.BrandPrimary)).Render(" ")))
return preview.String()
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/welcome_inline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,11 @@ func TestWelcomeIndicatorRow_UsesSemanticStatesAndCounts(t *testing.T) {
}
})
}

active := welcomeIndicatorRow(1, true, 1, "<active>", "<idle>", "</active>", "<ready>", "<none>")
if strings.Count(active, ansiBold) != 3 {
t.Fatalf("welcomeIndicatorRow() should bold all three semantic icons, got %q", active)
}
}

func TestConnectedMCPCount_CountsDistinctUsableServers(t *testing.T) {
Expand Down
46 changes: 46 additions & 0 deletions docs/intelligent-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Intelligent CLI capabilities

Hawk keeps the startup tool schema small while making the full capability
surface discoverable on demand. The registry currently contains core tools,
lazy tools, and MCP tools; intent routing promotes only the tools relevant to
the current request.

## Capability bundles

The router recognizes these request families:

- `web`: URL fetching, search, browser navigation, and screenshots
- `code-understanding`: compact reads, outlines, code search, graph, LSP, and impact
- `verification`: project detection, build/test/lint/format checks, diagnostics, and static analysis
- `git`: structured Git, GitHub inspection, history, worktrees, conflicts, and PR summaries
- `editing`: patches, atomic edits, imports, and deterministic refactors
- `data`: SQL and notebook workflows
- `security`: dependency, static-analysis, secret, and history review
- `tool-health`: runtime prerequisite and tool-surface diagnostics

Promotion changes only which schemas are sent to the model. It does not execute
anything, grant approval, or bypass the permission engine.

## Useful tools

```text
ToolHealth inspect registered tools and git/go/node/python/docker/gh/Chrome availability
ProjectVerify detect and run bounded build/test/lint/format checks without a shell
DependencyAudit check dependency integrity or outdated packages without installing anything
GitHub inspect repositories, PRs, issues, checks, and workflow runs through gh
```

All verification commands use fixed executable/argument lists, per-command
timeouts, project-root validation, and structured status/exit-code output.
Dependency and GitHub operations are network-gated and read-only by default.

## Inspecting the registry

```bash
hawk tools
hawk tools --json
```

The JSON form includes risk level, read-only status, aliases, and intent
categories. Use `ToolSearch` with `select:<ToolName>` when a lazy tool was not
promoted automatically.
23 changes: 23 additions & 0 deletions docs/terminal-icons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Terminal icons

Hawk uses current Nerd Font Codicon glyphs for interactive terminal output.
The application does not try to infer the installed font from `TERM`: terminal
names do not report the active font, and guessing can produce tiny fallback
boxes or missing glyphs.

Interactive TTYs use Nerd Font icons by default. Captured output, CI, and
`NO_COLOR` use ASCII automatically. Select the tier explicitly when needed:

```bash
# Real icons (requires a Nerd Font configured in the terminal profile)
HAWK_ICONS=nerd ./bin/hawk

# Portable text-only output
HAWK_ICONS=ascii ./bin/hawk
```

For the real icons, configure the terminal profile—not Hawk's Go code—with a
patched font such as `JetBrainsMono Nerd Font` or `Symbols Nerd Font Mono`.
Font size and glyph scale are controlled by that profile. Hawk applies bold
weight to status icons for contrast, but there is no portable ANSI escape that
can resize one glyph independently of the surrounding text.
9 changes: 8 additions & 1 deletion internal/engine/approval_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,15 @@ func (g *ApprovalGate) classifyAction(toolName string, args map[string]interface
}

switch canon {
case "WebFetch", "WebSearch":
case "WebFetch", "WebSearch", "DependencyAudit", "GitHub":
return ApprovalNetwork, true
case "Git":
if subcommand, ok := args["subcommand"].(string); ok {
switch subcommand {
case "fetch", "pull", "push", "clone", "remote":
return ApprovalNetwork, true
}
}
case "SQL":
if allow, ok := args["allow_write"].(bool); ok && allow {
return ApprovalDatabaseWrite, true
Expand Down
10 changes: 10 additions & 0 deletions internal/engine/permission_session_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ func canonicalToolName(name string) string {
return "WebFetch"
case "web_search", "websearch":
return "WebSearch"
case "tool_health", "toolhealth", "tools_health":
return "ToolHealth"
case "project_verify", "projectverify", "verify_project":
return "ProjectVerify"
case "dependency_audit", "dependencyaudit", "deps":
return "DependencyAudit"
case "git_history", "githistory", "git-history":
return "GitHistory"
case "github", "gh":
return "GitHub"
case "sql", "sql_query":
return "SQL"
case "agent", "task":
Expand Down
Loading
Loading