Skip to content

Commit b7ca0c6

Browse files
authored
feat: add intelligent tool routing and verification workflows (#187)
* fix: refresh docker availability on retry * fix: use bolder welcome control-plane icons * fix: use clearer welcome trust icon * fix: use clearer agents icon in welcome row * fix: restore readable agents icon * fix: emphasize welcome indicator icons * fix: make welcome status icons readable * Revert "fix: make welcome status icons readable" This reverts commit 73457d5. * fix: restore dense terminal icon rendering * fix: use current Nerd Font icon mappings * fix: strengthen welcome icon rendering * feat: make terminal icon mode deterministic * fix: soften default code panel background * feat: add intelligent tool routing and verification workflows
1 parent 62fd953 commit b7ca0c6

39 files changed

Lines changed: 1399 additions & 84 deletions

‎cmd/chat_tools.go‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func essentialTools() []tool.Tool {
6464
tool.MultiEditTool{},
6565
tool.BrowserTool{},
6666
tool.ScreenshotTool{},
67+
tool.ToolHealthTool{},
6768
tool.RequestCredentialTool{Gateway: func() tool.CredentialGateFn {
6869
// The actual gateway is wired at session start via SetCredentialGate.
6970
// This returns nil until then; the tool checks for nil and errors.
@@ -80,6 +81,23 @@ func essentialTools() []tool.Tool {
8081
func optionalTools() []tool.Tool {
8182
// Specialized tools that can be lazy-loaded on demand
8283
return []tool.Tool{
84+
// Structured developer workflows. These stay lazy until the intent
85+
// router or ToolSearch promotes them, keeping the startup schema small.
86+
tool.GitTool{},
87+
tool.OutlineTool{},
88+
&tool.SmartReaderTool{},
89+
tool.PatchTool{},
90+
tool.TransactionTool{},
91+
tool.NewAutoImportTool(),
92+
tool.ImportOrganizerTool{},
93+
tool.NewRefactorTool(),
94+
tool.ConflictResolverTool{},
95+
tool.DebuggerTool{},
96+
tool.DevEnvTool{},
97+
tool.ProjectVerifyTool{},
98+
tool.DependencyAuditTool{},
99+
tool.GitHubTool{},
100+
&tool.PRGeneratorTool{},
83101
tool.SpecifyTool{},
84102
tool.PlanTool{},
85103
tool.TasksTool{},
@@ -277,12 +295,12 @@ func defaultRegistry(settings hawkconfig.Settings) (*tool.Registry, error) {
277295
}
278296
registry.EnableLazyModelSurface(essentialNames)
279297

280-
// Lazy-load optional tools in background (executable, not model-visible).
281-
go func() {
282-
for _, t := range optionalTools() {
283-
_ = registry.Register(t)
284-
}
285-
}()
298+
// Register optional tools synchronously. Registration only adds in-memory
299+
// schemas; keeping it deterministic ensures intent promotion cannot race
300+
// the first user turn. They remain hidden from the model until promoted.
301+
for _, t := range optionalTools() {
302+
_ = registry.Register(t)
303+
}
286304

287305
// Load MCP tools in the background so a hung/absent stdio server delays
288306
// tool availability — not first paint. loadStartupMCPToolSets can block up

‎cmd/chat_welcome.go‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg
123123
// Status marks — green ✓ = present, dim ○ = none (not an error),
124124
// red × = actual problem (e.g. Docker enabled but not running). Using a
125125
// neutral mark for "none" avoids the alarming all-red look on a fresh repo.
126-
markPresent := greenC + icons.CheckBold() + rst
126+
markPresent := greenC + ansiBold + icons.CheckBold() + rst
127127
markNone := sepC + "○" + rst
128128

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

253-
isoSeg := " · " + isoColor + isoIcon + " " + iso + rst
258+
isoSeg := " · " + boldIcon(isoColor, isoIcon) + " " + iso + rst
254259
if badgeShown {
255260
isoSeg = ""
256261
}
@@ -285,9 +290,9 @@ func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown
285290
}
286291
}
287292

288-
return modeColor + modeIcon + " " + modeLabel + rst +
293+
return boldIcon(modeColor, modeIcon) + " " + modeLabel + rst +
289294
isoSeg +
290-
" · " + trustColor + trustIcon + " " + trustLabel + rst
295+
" · " + boldIcon(trustColor, trustIcon) + " " + trustLabel + rst
291296
}
292297

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

312317
func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC, idleC, rst, markPresent, markNone string) string {
318+
boldIcon := func(color, glyph string) string {
319+
// Keep the label's color after making only the glyph bold. This gives
320+
// narrow Nerd Font icons more visual weight without bolding the copy.
321+
return color + ansiBold + glyph + ansiReset + color
322+
}
313323
skillsColor, skillsMark := idleC, markNone
314324
if skillsCount > 0 {
315325
skillsColor, skillsMark = ansiLightPink, markPresent
@@ -325,10 +335,10 @@ func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC,
325335
mcpColor, mcpMark = ansiCyan, markPresent
326336
}
327337
return fmt.Sprintf(
328-
"%s%s Skills (%d)%s %s · %s%s AGENTS.md%s %s · %s%s MCPs (%d)%s %s",
329-
skillsColor, icons.Bolt(), skillsCount, rst, skillsMark,
330-
agentsColor, icons.Robot(), rst, agentsMark,
331-
mcpColor, icons.Network(), mcpCount, rst, mcpMark,
338+
"%s Skills (%d)%s %s · %s AGENTS.md%s %s · %s MCPs (%d)%s %s",
339+
boldIcon(skillsColor, icons.Bolt()), skillsCount, rst, skillsMark,
340+
boldIcon(agentsColor, icons.Robot()), rst, agentsMark,
341+
boldIcon(mcpColor, icons.Network()), mcpCount, rst, mcpMark,
332342
)
333343
}
334344

‎cmd/container_boot.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func shouldUseContainer() bool {
3131
// closed with an actionable error; there is deliberately no host fallback.
3232
func startRequiredContainer(projectDir string) (*sandbox.ContainerSandbox, error) {
3333
cs := sandbox.NewContainerSandbox(projectDir)
34+
sandbox.ResetDockerAvailabilityCache()
3435
if !dockerAvailable() {
3536
return nil, fmt.Errorf("docker is required but is not running — start Docker and retry")
3637
}

‎cmd/diagnostics.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/GrayCodeAI/hawk/internal/resilience/health"
1717
"github.com/GrayCodeAI/hawk/internal/session"
1818
"github.com/GrayCodeAI/hawk/internal/storage"
19+
"github.com/GrayCodeAI/hawk/internal/tool"
1920
"github.com/GrayCodeAI/hawk/internal/ui/icons"
2021
)
2122

@@ -267,5 +268,9 @@ func builtInToolsSummary() string {
267268
for _, t := range optional {
268269
b.WriteString(fmt.Sprintf(" %s - %s\n", t.Name(), t.Description()))
269270
}
271+
b.WriteString("\nIntent bundles:\n")
272+
for _, summary := range tool.IntentBundleSummary() {
273+
b.WriteString(" " + summary + "\n")
274+
}
270275
return strings.TrimRight(b.String(), "\n")
271276
}

‎cmd/root.go‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/GrayCodeAI/hawk/internal/onboarding"
1717
"github.com/GrayCodeAI/hawk/internal/plugin"
1818
"github.com/GrayCodeAI/hawk/internal/session"
19+
"github.com/GrayCodeAI/hawk/internal/tool"
1920
"github.com/GrayCodeAI/hawk/internal/update"
2021
"github.com/spf13/cobra"
2122
)
@@ -719,12 +720,27 @@ var toolsCmd = &cobra.Command{
719720
if toolsJSON {
720721
tools := allTools()
721722
type toolEntry struct {
722-
Name string `json:"name"`
723-
Description string `json:"description"`
723+
Name string `json:"name"`
724+
Description string `json:"description"`
725+
Risk string `json:"risk"`
726+
ReadOnly bool `json:"read_only"`
727+
Categories []string `json:"categories,omitempty"`
728+
Aliases []string `json:"aliases,omitempty"`
724729
}
725730
entries := make([]toolEntry, len(tools))
726731
for i, t := range tools {
727-
entries[i] = toolEntry{Name: t.Name(), Description: t.Description()}
732+
risk := "medium"
733+
if rp, ok := t.(tool.RiskLevelProvider); ok && rp.RiskLevel() != "" {
734+
risk = rp.RiskLevel()
735+
}
736+
var aliases []string
737+
if aliased, ok := t.(tool.AliasedTool); ok {
738+
aliases = aliased.Aliases()
739+
}
740+
entries[i] = toolEntry{
741+
Name: t.Name(), Description: t.Description(), Risk: risk,
742+
ReadOnly: tool.IsReadOnly(t.Name()), Categories: tool.IntentCategoriesForTool(t.Name()), Aliases: aliases,
743+
}
728744
}
729745
data, _ := json.MarshalIndent(entries, "", " ")
730746
cmd.Println(string(data))

‎cmd/spinner_wave.go‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func renderSpinnerWaveLine(glyph, verb string, wavePhase, dotPhase int) string {
5353
head := wavePhase % total
5454

5555
var b strings.Builder
56-
b.WriteString(renderSpinnerWaveSlotGlyph(glyph, wavePhase, head == 0, true))
56+
b.WriteString(renderSpinnerWaveSlotGlyph(glyph, wavePhase, head == 0, true, false))
5757
if verb == "" {
5858
return b.String()
5959
}
@@ -73,24 +73,26 @@ func renderSpinnerWaveLine(glyph, verb string, wavePhase, dotPhase int) string {
7373
g = icons.CircleFilled()
7474
bold = true
7575
}
76-
b.WriteString(renderSpinnerWaveSlotGlyph(g, wavePhase+pos, head == pos, bold))
76+
b.WriteString(renderSpinnerWaveSlotGlyph(g, wavePhase+pos, head == pos, bold, false))
7777
pos++
7878
}
7979
return b.String()
8080
}
8181

8282
func renderSpinnerWaveSlot(r rune, colorIdx int, isHead, bold bool) string {
83-
return renderSpinnerWaveSlotGlyph(string(r), colorIdx, isHead, bold)
83+
return renderSpinnerWaveSlotGlyph(string(r), colorIdx, isHead, bold, true)
8484
}
8585

86-
func renderSpinnerWaveSlotGlyph(glyph string, colorIdx int, isHead, bold bool) string {
86+
func renderSpinnerWaveSlotGlyph(glyph string, colorIdx int, isHead, bold, italic bool) string {
8787
color := ansiSpinnerWaveColor(colorIdx)
8888
if isHead || bold {
8989
color += ansiBold
9090
}
9191
var b strings.Builder
9292
b.WriteString(color)
93-
b.WriteString(ansiItalic)
93+
if italic {
94+
b.WriteString(ansiItalic)
95+
}
9496
b.WriteString(glyph)
9597
b.WriteString(ansiReset)
9698
return b.String()

‎cmd/spinner_wave_test.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,14 @@ func TestSpinnerWave_GlyphUsesWaveColor(t *testing.T) {
6666
t.Fatal("expected wave color on spinner glyph")
6767
}
6868
}
69+
70+
func TestSpinnerWaveIconIsNotItalicized(t *testing.T) {
71+
out := renderSpinnerWaveLine("◐", "Go", 0, 0)
72+
firstReset := strings.Index(out, ansiReset)
73+
if firstReset < 0 {
74+
t.Fatalf("spinner frame has no reset escape: %q", out)
75+
}
76+
if strings.Contains(out[:firstReset], ansiItalic) {
77+
t.Fatalf("spinner icon should not be italicized: %q", out[:firstReset])
78+
}
79+
}

‎cmd/theme.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ var textWhite = lipgloss.Color("#FFFFFF")
148148
// borderDim — input/panel/divider border.
149149
var borderDim = compat.AdaptiveColor{Light: lipgloss.Color("#C6C6C6"), Dark: lipgloss.Color("#555555")}
150150

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

154155
// ---------------------------------------------------------------------------
155156
// 9. Spinner-line ANSI escapes (raw, not lipgloss)

‎cmd/theme_picker.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ func renderThemePreview(themeName string) string {
192192

193193
// Handle auto theme specially
194194
if themeName == "auto" {
195-
preview.WriteString(fmt.Sprintf(" Panel: %s dark\n", lipgloss.NewStyle().Background(lipgloss.Color("#0e0e10")).Render(" ")))
195+
preview.WriteString(fmt.Sprintf(" Panel: %s dark\n", lipgloss.NewStyle().Background(lipgloss.Color("#1b1e26")).Render(" ")))
196196
preview.WriteString(fmt.Sprintf(" Brand: %s Talon Gold\n", lipgloss.NewStyle().Background(lipgloss.Color(internaltheme.BrandPrimary)).Render(" ")))
197197
return preview.String()
198198
}

‎cmd/welcome_inline_test.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ func TestWelcomeIndicatorRow_UsesSemanticStatesAndCounts(t *testing.T) {
243243
}
244244
})
245245
}
246+
247+
active := welcomeIndicatorRow(1, true, 1, "<active>", "<idle>", "</active>", "<ready>", "<none>")
248+
if strings.Count(active, ansiBold) != 3 {
249+
t.Fatalf("welcomeIndicatorRow() should bold all three semantic icons, got %q", active)
250+
}
246251
}
247252

248253
func TestConnectedMCPCount_CountsDistinctUsableServers(t *testing.T) {

0 commit comments

Comments
 (0)