From e8393c3b8029317ce6b91b40f7d15348259cdec5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 14:33:33 +0530 Subject: [PATCH 01/13] fix: refresh docker availability on retry --- cmd/container_boot.go | 1 + internal/sandbox/container_test.go | 27 +++++++++++++++++++++++++++ internal/sandbox/selector.go | 15 ++++++++++----- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/cmd/container_boot.go b/cmd/container_boot.go index df2fadf3..8f699fa2 100644 --- a/cmd/container_boot.go +++ b/cmd/container_boot.go @@ -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") } diff --git a/internal/sandbox/container_test.go b/internal/sandbox/container_test.go index 61d7e045..a92a795c 100644 --- a/internal/sandbox/container_test.go +++ b/internal/sandbox/container_test.go @@ -39,6 +39,33 @@ func TestDockerAvailable_UsesShortLivedCache(t *testing.T) { } } +func TestResetDockerAvailabilityCacheForcesFreshProbe(t *testing.T) { + resetDockerAvailabilityCache() + t.Cleanup(resetDockerAvailabilityCache) + + var calls atomic.Int32 + dockerAvailabilityProbe = func() bool { + n := calls.Add(1) + return n >= 2 + } + + if DockerAvailable() { + t.Fatal("first probe should observe Docker as unavailable") + } + if DockerAvailable() { + t.Fatal("cached false should still be false before reset") + } + + ResetDockerAvailabilityCache() + + if !DockerAvailable() { + t.Fatal("reset cache should force a fresh probe that sees Docker as available") + } + if got := calls.Load(); got != 2 { + t.Fatalf("docker availability probe calls = %d, want 2", got) + } +} + func TestContainerSandbox_New(t *testing.T) { cs := NewContainerSandbox("/tmp/test-project") if cs == nil { diff --git a/internal/sandbox/selector.go b/internal/sandbox/selector.go index cb7548f2..5e75218c 100644 --- a/internal/sandbox/selector.go +++ b/internal/sandbox/selector.go @@ -119,6 +119,15 @@ func dockerAvailable() bool { return dockerAvailabilityCached } +// ResetDockerAvailabilityCache clears the cached daemon probe result so the +// next availability check performs a fresh docker info probe. +func ResetDockerAvailabilityCache() { + dockerAvailabilityMu.Lock() + defer dockerAvailabilityMu.Unlock() + dockerAvailabilityChecked = time.Time{} + dockerAvailabilityCached = false +} + func probeDockerAvailable() bool { if _, err := exec.LookPath("docker"); err != nil { return false @@ -132,9 +141,5 @@ func probeDockerAvailable() bool { } func resetDockerAvailabilityCache() { - dockerAvailabilityMu.Lock() - defer dockerAvailabilityMu.Unlock() - dockerAvailabilityChecked = time.Time{} - dockerAvailabilityCached = false - dockerAvailabilityProbe = probeDockerAvailable + ResetDockerAvailabilityCache() } From a11376be159557777e842655b7947c179e543091 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 15:01:24 +0530 Subject: [PATCH 02/13] fix: use bolder welcome control-plane icons --- cmd/chat_welcome.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 3512cf98..2a20a8bd 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -232,7 +232,9 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg // CONTAINER badge is shown, the redundant iso segment is dropped. func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown bool) string { 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 { @@ -262,7 +264,7 @@ func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown trustIcon = icons.CircleOutline() trustColor = dimC } else if tr.Trusted { - trustIcon = icons.CheckDecagram() + trustIcon = icons.CheckBold() trustColor = ansiVividGreen } else if tr.Blocked { trustIcon = icons.CloseCircle() From 95fe3e896599097e0cc8bc4c853f57e9a33b3975 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 15:09:32 +0530 Subject: [PATCH 03/13] fix: use clearer welcome trust icon --- cmd/chat_welcome.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 2a20a8bd..bf98db5e 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -264,7 +264,7 @@ func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown trustIcon = icons.CircleOutline() trustColor = dimC } else if tr.Trusted { - trustIcon = icons.CheckBold() + trustIcon = icons.CheckDecagram() trustColor = ansiVividGreen } else if tr.Blocked { trustIcon = icons.CloseCircle() From e99e778f9e1ab503b57a92d7bbce85e91d86888d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 15:23:23 +0530 Subject: [PATCH 04/13] fix: use clearer agents icon in welcome row --- cmd/chat_welcome.go | 2 +- cmd/welcome_inline_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index bf98db5e..1993cbb1 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -329,7 +329,7 @@ func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC, 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, + agentsColor, icons.FileDocument(), rst, agentsMark, mcpColor, icons.Network(), mcpCount, rst, mcpMark, ) } diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go index 163a77b7..9ecb491a 100644 --- a/cmd/welcome_inline_test.go +++ b/cmd/welcome_inline_test.go @@ -69,15 +69,15 @@ func TestBuildWelcomeMessage_InlineShowsGuidance(t *testing.T) { t.Fatalf("minimal welcome missing %q in:\n%s", want, out) } } - for _, wantIcon := range []string{icons.Robot(), icons.Network()} { + for _, wantIcon := range []string{icons.FileDocument(), icons.Network()} { if !strings.Contains(out, wantIcon) { t.Fatalf("minimal welcome missing semantic icon %q in:\n%s", wantIcon, out) } } for mode, rowIcons := range map[string][]string{ - "active": {icons.Bolt(), icons.Robot(), icons.Network()}, - "nerd": {icons.Nerd("bolt"), icons.Nerd("robot"), icons.Nerd("network")}, - "ascii": {icons.ASCII("bolt"), icons.ASCII("robot"), icons.ASCII("network")}, + "active": {icons.Bolt(), icons.FileDocument(), icons.Network()}, + "nerd": {icons.Nerd("bolt"), icons.Nerd("file_document"), icons.Nerd("network")}, + "ascii": {icons.ASCII("bolt"), icons.ASCII("file_document"), icons.ASCII("network")}, } { seenIcons := make(map[string]struct{}, len(rowIcons)) for _, icon := range rowIcons { From 45ab8cf03691f2868fdc52e5949ebe65f692826c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 15:29:12 +0530 Subject: [PATCH 05/13] fix: restore readable agents icon --- cmd/chat_welcome.go | 2 +- cmd/welcome_inline_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 1993cbb1..bf98db5e 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -329,7 +329,7 @@ func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC, 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.FileDocument(), rst, agentsMark, + agentsColor, icons.Robot(), rst, agentsMark, mcpColor, icons.Network(), mcpCount, rst, mcpMark, ) } diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go index 9ecb491a..163a77b7 100644 --- a/cmd/welcome_inline_test.go +++ b/cmd/welcome_inline_test.go @@ -69,15 +69,15 @@ func TestBuildWelcomeMessage_InlineShowsGuidance(t *testing.T) { t.Fatalf("minimal welcome missing %q in:\n%s", want, out) } } - for _, wantIcon := range []string{icons.FileDocument(), icons.Network()} { + for _, wantIcon := range []string{icons.Robot(), icons.Network()} { if !strings.Contains(out, wantIcon) { t.Fatalf("minimal welcome missing semantic icon %q in:\n%s", wantIcon, out) } } for mode, rowIcons := range map[string][]string{ - "active": {icons.Bolt(), icons.FileDocument(), icons.Network()}, - "nerd": {icons.Nerd("bolt"), icons.Nerd("file_document"), icons.Nerd("network")}, - "ascii": {icons.ASCII("bolt"), icons.ASCII("file_document"), icons.ASCII("network")}, + "active": {icons.Bolt(), icons.Robot(), icons.Network()}, + "nerd": {icons.Nerd("bolt"), icons.Nerd("robot"), icons.Nerd("network")}, + "ascii": {icons.ASCII("bolt"), icons.ASCII("robot"), icons.ASCII("network")}, } { seenIcons := make(map[string]struct{}, len(rowIcons)) for _, icon := range rowIcons { From 104d3cbddd8d9ab19ab1050891b07d0150a490c6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 15:42:43 +0530 Subject: [PATCH 06/13] fix: emphasize welcome indicator icons --- cmd/chat_welcome.go | 13 +++++++++---- cmd/welcome_inline_test.go | 5 +++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index bf98db5e..2ecc78a9 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -312,6 +312,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 @@ -327,10 +332,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, ) } diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go index 163a77b7..3820818b 100644 --- a/cmd/welcome_inline_test.go +++ b/cmd/welcome_inline_test.go @@ -243,6 +243,11 @@ func TestWelcomeIndicatorRow_UsesSemanticStatesAndCounts(t *testing.T) { } }) } + + active := welcomeIndicatorRow(1, true, 1, "", "", "", "", "") + if strings.Count(active, ansiBold) != 3 { + t.Fatalf("welcomeIndicatorRow() should bold all three semantic icons, got %q", active) + } } func TestConnectedMCPCount_CountsDistinctUsableServers(t *testing.T) { From 73457d5d6744f1fb808c7ad51b912736d6b2c9c3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 15:59:49 +0530 Subject: [PATCH 07/13] fix: make welcome status icons readable --- cmd/chat_welcome.go | 73 ++++++++++++++++++++++---------------- cmd/welcome_inline_test.go | 31 +++++++++------- 2 files changed, 61 insertions(+), 43 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 2ecc78a9..0a3e06ad 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -120,11 +120,11 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg sepC := ansiGrayDim rst := ansiReset - // 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 - markNone := sepC + "○" + rst + // Status marks — green [ok] = present, dim [.] = none (not an error). + // Using a neutral mark for "none" avoids an alarming all-red look on a + // fresh repo. + markPresent := welcomeStandaloneIcon(greenC, "check_decagram", rst) + markNone := welcomeStandaloneIcon(sepC, "circle_outline", rst) totalW := width if totalW < 40 { @@ -232,45 +232,41 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg // CONTAINER badge is shown, the redundant iso segment is dropped. func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown bool) string { work := sess.WorkMode() - // 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() + modeIcon := "terminal" modeLabel := "Action Mode" modeColor := ansiCyan switch work { case engine.WorkModePlan: - modeIcon = icons.Brain() + modeIcon = "brain" modeLabel = "Planning Mode" modeColor = ansiMagenta case engine.WorkModeReview: - modeIcon = icons.Magnify() + modeIcon = "magnify" modeLabel = "Review Mode" modeColor = ansiAmber } - isoIcon := icons.Container() isoColor := ansiAmber iso := sess.Isolation().ShortLabel() - isoSeg := " · " + isoColor + isoIcon + " " + iso + rst + isoSeg := " · " + welcomeLabelIcon(isoColor, "container", rst) + " " + iso + rst if badgeShown { isoSeg = "" } tr := engine.ProjectTrust("") - var trustIcon string + trustIcon := "circle_outline" trustColor := dimC if !tr.Enforced { - trustIcon = icons.CircleOutline() trustColor = dimC } else if tr.Trusted { - trustIcon = icons.CheckDecagram() + trustIcon = "check_decagram" trustColor = ansiVividGreen } else if tr.Blocked { - trustIcon = icons.CloseCircle() + trustIcon = "close_circle" trustColor = ansiCoral } else { - trustIcon = icons.CloseThick() + trustIcon = "close_thick" trustColor = ansiAmber } trustLabel := tr.String() @@ -287,9 +283,31 @@ func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown } } - return modeColor + modeIcon + " " + modeLabel + rst + + return welcomeLabelIcon(modeColor, modeIcon, rst) + " " + modeLabel + rst + isoSeg + - " · " + trustColor + trustIcon + " " + trustLabel + rst + " · " + welcomeLabelIcon(trustColor, trustIcon, rst) + " " + trustLabel + rst +} + +// welcomeDisplayIcon returns a font-independent, high-contrast status chip. +// Nerd Font PUA glyphs are one terminal cell and can have tiny cap heights +// depending on the user's font fallback; ASCII fallbacks stay readable. +func welcomeDisplayIcon(name string) string { + ascii := strings.TrimSpace(icons.ASCII(name)) + if ascii == "" { + return "[?]" + } + if strings.HasPrefix(ascii, "[") && strings.HasSuffix(ascii, "]") { + return ascii + } + return "[" + ascii + "]" +} + +func welcomeLabelIcon(color, name, rst string) string { + return color + ansiBold + welcomeDisplayIcon(name) + rst + color +} + +func welcomeStandaloneIcon(color, name, rst string) string { + return color + ansiBold + welcomeDisplayIcon(name) + rst } type mcpServerNamed interface { @@ -312,11 +330,6 @@ 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 @@ -333,9 +346,9 @@ func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC, } return fmt.Sprintf( "%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, + welcomeLabelIcon(skillsColor, "bolt", rst), skillsCount, rst, skillsMark, + welcomeLabelIcon(agentsColor, "robot", rst), rst, agentsMark, + welcomeLabelIcon(mcpColor, "network", rst), mcpCount, rst, mcpMark, ) } @@ -348,13 +361,13 @@ func welcomeModeBadge(dockerRunning *bool) string { switch { case dockerRunning == nil: // Startup — Talon Gold, bold, timer = waiting for the sandbox. - return "\033[1m" + ansiOrange + icons.Timer() + " Container Starting" + rst + return "\033[1m" + ansiOrange + welcomeDisplayIcon("timer") + " Container Starting" + rst case *dockerRunning: // Ready — container blue communicates healthy isolation. - return "\033[1m" + ansiContBlue + icons.Shield() + " Container" + rst + return "\033[1m" + ansiContBlue + welcomeDisplayIcon("shield") + " Container" + rst default: // Failure — no host fallback exists. - return "\033[1m" + ansiCoral + icons.Alert() + " Container Required" + rst + return "\033[1m" + ansiCoral + welcomeDisplayIcon("alert") + " Container Required" + rst } } diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go index 3820818b..d470f1b2 100644 --- a/cmd/welcome_inline_test.go +++ b/cmd/welcome_inline_test.go @@ -18,11 +18,11 @@ type welcomeMCPStub struct { server string } -// TestWelcomeScreenNerdIconsUnique renders the full welcome in Nerd mode -// for every execution state and asserts each PUA icon glyph appears at most -// once. Guards the "one icon per concept" rule on the welcome screen so the -// mode/iso/trust segments and the badge never reuse a glyph. -func TestWelcomeScreenNerdIconsUnique(t *testing.T) { +// TestWelcomeScreenReadableIcons renders the full welcome in Nerd mode and +// verifies that operational status uses font-independent, visible chips. +// PUA glyph metrics vary by terminal font, so welcome status must not depend +// on a tiny fallback glyph being legible. +func TestWelcomeScreenReadableIcons(t *testing.T) { icons.SetMode(icons.ModeNerd) defer icons.SetMode(icons.ModeASCII) @@ -31,16 +31,21 @@ func TestWelcomeScreenNerdIconsUnique(t *testing.T) { states := []*bool{nil, &running, &stopped} for i, docker := range states { out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 100, 24, docker) + for _, chip := range []string{"[!]", "[*]", "[net]", "[ok]", "[.]"} { + if !strings.Contains(out, chip) { + t.Fatalf("state %d: welcome output missing visible status chip %q:\n%s", i, chip, out) + } + } seen := make(map[rune]struct{}) for _, r := range out { if r < 0xE000 || r > 0xF8FF { continue } - if _, dup := seen[r]; dup { - t.Fatalf("state %d: PUA glyph %U reused on welcome screen:\n%s", i, r, out) - } seen[r] = struct{}{} } + if len(seen) != 0 { + t.Fatalf("state %d: welcome screen should not depend on PUA glyphs:\n%s", i, out) + } } } @@ -69,15 +74,15 @@ func TestBuildWelcomeMessage_InlineShowsGuidance(t *testing.T) { t.Fatalf("minimal welcome missing %q in:\n%s", want, out) } } - for _, wantIcon := range []string{icons.Robot(), icons.Network()} { + for _, wantIcon := range []string{welcomeDisplayIcon("robot"), welcomeDisplayIcon("network")} { if !strings.Contains(out, wantIcon) { t.Fatalf("minimal welcome missing semantic icon %q in:\n%s", wantIcon, out) } } for mode, rowIcons := range map[string][]string{ - "active": {icons.Bolt(), icons.Robot(), icons.Network()}, - "nerd": {icons.Nerd("bolt"), icons.Nerd("robot"), icons.Nerd("network")}, - "ascii": {icons.ASCII("bolt"), icons.ASCII("robot"), icons.ASCII("network")}, + "active": {welcomeDisplayIcon("bolt"), welcomeDisplayIcon("robot"), welcomeDisplayIcon("network")}, + "nerd": {welcomeDisplayIcon("bolt"), welcomeDisplayIcon("robot"), welcomeDisplayIcon("network")}, + "ascii": {welcomeDisplayIcon("bolt"), welcomeDisplayIcon("robot"), welcomeDisplayIcon("network")}, } { seenIcons := make(map[string]struct{}, len(rowIcons)) for _, icon := range rowIcons { @@ -246,7 +251,7 @@ func TestWelcomeIndicatorRow_UsesSemanticStatesAndCounts(t *testing.T) { active := welcomeIndicatorRow(1, true, 1, "", "", "", "", "") if strings.Count(active, ansiBold) != 3 { - t.Fatalf("welcomeIndicatorRow() should bold all three semantic icons, got %q", active) + t.Fatalf("welcomeIndicatorRow() should bold all three semantic chips, got %q", active) } } From e073bfcccd192527e339331c84739f8003490a85 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 16:03:21 +0530 Subject: [PATCH 08/13] Revert "fix: make welcome status icons readable" This reverts commit 73457d5d6744f1fb808c7ad51b912736d6b2c9c3. --- cmd/chat_welcome.go | 73 ++++++++++++++++---------------------- cmd/welcome_inline_test.go | 31 +++++++--------- 2 files changed, 43 insertions(+), 61 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 0a3e06ad..2ecc78a9 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -120,11 +120,11 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg sepC := ansiGrayDim rst := ansiReset - // Status marks — green [ok] = present, dim [.] = none (not an error). - // Using a neutral mark for "none" avoids an alarming all-red look on a - // fresh repo. - markPresent := welcomeStandaloneIcon(greenC, "check_decagram", rst) - markNone := welcomeStandaloneIcon(sepC, "circle_outline", rst) + // 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 + markNone := sepC + "○" + rst totalW := width if totalW < 40 { @@ -232,41 +232,45 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg // CONTAINER badge is shown, the redundant iso segment is dropped. func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown bool) string { work := sess.WorkMode() - modeIcon := "terminal" + // 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 { case engine.WorkModePlan: - modeIcon = "brain" + modeIcon = icons.Brain() modeLabel = "Planning Mode" modeColor = ansiMagenta case engine.WorkModeReview: - modeIcon = "magnify" + modeIcon = icons.Magnify() modeLabel = "Review Mode" modeColor = ansiAmber } + isoIcon := icons.Container() isoColor := ansiAmber iso := sess.Isolation().ShortLabel() - isoSeg := " · " + welcomeLabelIcon(isoColor, "container", rst) + " " + iso + rst + isoSeg := " · " + isoColor + isoIcon + " " + iso + rst if badgeShown { isoSeg = "" } tr := engine.ProjectTrust("") - trustIcon := "circle_outline" + var trustIcon string trustColor := dimC if !tr.Enforced { + trustIcon = icons.CircleOutline() trustColor = dimC } else if tr.Trusted { - trustIcon = "check_decagram" + trustIcon = icons.CheckDecagram() trustColor = ansiVividGreen } else if tr.Blocked { - trustIcon = "close_circle" + trustIcon = icons.CloseCircle() trustColor = ansiCoral } else { - trustIcon = "close_thick" + trustIcon = icons.CloseThick() trustColor = ansiAmber } trustLabel := tr.String() @@ -283,31 +287,9 @@ func welcomeControlPlaneLine(sess *engine.Session, dimC, rst string, badgeShown } } - return welcomeLabelIcon(modeColor, modeIcon, rst) + " " + modeLabel + rst + + return modeColor + modeIcon + " " + modeLabel + rst + isoSeg + - " · " + welcomeLabelIcon(trustColor, trustIcon, rst) + " " + trustLabel + rst -} - -// welcomeDisplayIcon returns a font-independent, high-contrast status chip. -// Nerd Font PUA glyphs are one terminal cell and can have tiny cap heights -// depending on the user's font fallback; ASCII fallbacks stay readable. -func welcomeDisplayIcon(name string) string { - ascii := strings.TrimSpace(icons.ASCII(name)) - if ascii == "" { - return "[?]" - } - if strings.HasPrefix(ascii, "[") && strings.HasSuffix(ascii, "]") { - return ascii - } - return "[" + ascii + "]" -} - -func welcomeLabelIcon(color, name, rst string) string { - return color + ansiBold + welcomeDisplayIcon(name) + rst + color -} - -func welcomeStandaloneIcon(color, name, rst string) string { - return color + ansiBold + welcomeDisplayIcon(name) + rst + " · " + trustColor + trustIcon + " " + trustLabel + rst } type mcpServerNamed interface { @@ -330,6 +312,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 @@ -346,9 +333,9 @@ func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC, } return fmt.Sprintf( "%s Skills (%d)%s %s · %s AGENTS.md%s %s · %s MCPs (%d)%s %s", - welcomeLabelIcon(skillsColor, "bolt", rst), skillsCount, rst, skillsMark, - welcomeLabelIcon(agentsColor, "robot", rst), rst, agentsMark, - welcomeLabelIcon(mcpColor, "network", rst), mcpCount, rst, mcpMark, + boldIcon(skillsColor, icons.Bolt()), skillsCount, rst, skillsMark, + boldIcon(agentsColor, icons.Robot()), rst, agentsMark, + boldIcon(mcpColor, icons.Network()), mcpCount, rst, mcpMark, ) } @@ -361,13 +348,13 @@ func welcomeModeBadge(dockerRunning *bool) string { switch { case dockerRunning == nil: // Startup — Talon Gold, bold, timer = waiting for the sandbox. - return "\033[1m" + ansiOrange + welcomeDisplayIcon("timer") + " Container Starting" + rst + return "\033[1m" + ansiOrange + icons.Timer() + " Container Starting" + rst case *dockerRunning: // Ready — container blue communicates healthy isolation. - return "\033[1m" + ansiContBlue + welcomeDisplayIcon("shield") + " Container" + rst + return "\033[1m" + ansiContBlue + icons.Shield() + " Container" + rst default: // Failure — no host fallback exists. - return "\033[1m" + ansiCoral + welcomeDisplayIcon("alert") + " Container Required" + rst + return "\033[1m" + ansiCoral + icons.Alert() + " Container Required" + rst } } diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go index d470f1b2..3820818b 100644 --- a/cmd/welcome_inline_test.go +++ b/cmd/welcome_inline_test.go @@ -18,11 +18,11 @@ type welcomeMCPStub struct { server string } -// TestWelcomeScreenReadableIcons renders the full welcome in Nerd mode and -// verifies that operational status uses font-independent, visible chips. -// PUA glyph metrics vary by terminal font, so welcome status must not depend -// on a tiny fallback glyph being legible. -func TestWelcomeScreenReadableIcons(t *testing.T) { +// TestWelcomeScreenNerdIconsUnique renders the full welcome in Nerd mode +// for every execution state and asserts each PUA icon glyph appears at most +// once. Guards the "one icon per concept" rule on the welcome screen so the +// mode/iso/trust segments and the badge never reuse a glyph. +func TestWelcomeScreenNerdIconsUnique(t *testing.T) { icons.SetMode(icons.ModeNerd) defer icons.SetMode(icons.ModeASCII) @@ -31,21 +31,16 @@ func TestWelcomeScreenReadableIcons(t *testing.T) { states := []*bool{nil, &running, &stopped} for i, docker := range states { out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 100, 24, docker) - for _, chip := range []string{"[!]", "[*]", "[net]", "[ok]", "[.]"} { - if !strings.Contains(out, chip) { - t.Fatalf("state %d: welcome output missing visible status chip %q:\n%s", i, chip, out) - } - } seen := make(map[rune]struct{}) for _, r := range out { if r < 0xE000 || r > 0xF8FF { continue } + if _, dup := seen[r]; dup { + t.Fatalf("state %d: PUA glyph %U reused on welcome screen:\n%s", i, r, out) + } seen[r] = struct{}{} } - if len(seen) != 0 { - t.Fatalf("state %d: welcome screen should not depend on PUA glyphs:\n%s", i, out) - } } } @@ -74,15 +69,15 @@ func TestBuildWelcomeMessage_InlineShowsGuidance(t *testing.T) { t.Fatalf("minimal welcome missing %q in:\n%s", want, out) } } - for _, wantIcon := range []string{welcomeDisplayIcon("robot"), welcomeDisplayIcon("network")} { + for _, wantIcon := range []string{icons.Robot(), icons.Network()} { if !strings.Contains(out, wantIcon) { t.Fatalf("minimal welcome missing semantic icon %q in:\n%s", wantIcon, out) } } for mode, rowIcons := range map[string][]string{ - "active": {welcomeDisplayIcon("bolt"), welcomeDisplayIcon("robot"), welcomeDisplayIcon("network")}, - "nerd": {welcomeDisplayIcon("bolt"), welcomeDisplayIcon("robot"), welcomeDisplayIcon("network")}, - "ascii": {welcomeDisplayIcon("bolt"), welcomeDisplayIcon("robot"), welcomeDisplayIcon("network")}, + "active": {icons.Bolt(), icons.Robot(), icons.Network()}, + "nerd": {icons.Nerd("bolt"), icons.Nerd("robot"), icons.Nerd("network")}, + "ascii": {icons.ASCII("bolt"), icons.ASCII("robot"), icons.ASCII("network")}, } { seenIcons := make(map[string]struct{}, len(rowIcons)) for _, icon := range rowIcons { @@ -251,7 +246,7 @@ func TestWelcomeIndicatorRow_UsesSemanticStatesAndCounts(t *testing.T) { active := welcomeIndicatorRow(1, true, 1, "", "", "", "", "") if strings.Count(active, ansiBold) != 3 { - t.Fatalf("welcomeIndicatorRow() should bold all three semantic chips, got %q", active) + t.Fatalf("welcomeIndicatorRow() should bold all three semantic icons, got %q", active) } } From a7f9bdfb625c6d91f04e4925f46c688010f053e7 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 16:53:33 +0530 Subject: [PATCH 09/13] fix: restore dense terminal icon rendering --- cmd/chat_welcome.go | 2 +- cmd/spinner_wave.go | 12 ++-- cmd/spinner_wave_test.go | 11 ++++ internal/ui/icons/codepoints.go | 110 +++++++++++++++++--------------- internal/ui/icons/icons_test.go | 20 ++++++ 5 files changed, 96 insertions(+), 59 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 2ecc78a9..86d97173 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -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 diff --git a/cmd/spinner_wave.go b/cmd/spinner_wave.go index 25a65e59..98d82a3e 100644 --- a/cmd/spinner_wave.go +++ b/cmd/spinner_wave.go @@ -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() } @@ -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() diff --git a/cmd/spinner_wave_test.go b/cmd/spinner_wave_test.go index dc7fcc5f..dde12f26 100644 --- a/cmd/spinner_wave_test.go +++ b/cmd/spinner_wave_test.go @@ -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]) + } +} diff --git a/internal/ui/icons/codepoints.go b/internal/ui/icons/codepoints.go index 400e91a9..5a4e8abd 100644 --- a/internal/ui/icons/codepoints.go +++ b/internal/ui/icons/codepoints.go @@ -17,6 +17,7 @@ // internal/testaudit. // // Codepoints are derived from: +// - Nerd Fonts Material Design Icons (legacy status vocabulary) // - Nerd Fonts v3 Codicons (EA60..EC1E) — https://github.com/microsoft/vscode-codicons // - Nerd Fonts v3 FontAwesome (ED00..F2FF) — https://fontawesome.com // - Nerd Fonts v3 Octicons (F400..F533) — https://primer.style/octicons @@ -87,59 +88,62 @@ const ( // person 60007 → EA97 archive 60056 → EAD8 // browser 60078 → EAFE vm 60026 → EAEA (collision — see below) // device-mobile 60123 → EB2B - puaChevronRight = "\ueb06" // nf-cod-chevron_right (60086) - puaRobot = "\ueb48" // nf-cod-hubot (60168) — same shape as robot - puaCircleFilled = "\uea71" // nf-cod-circle-filled (60017) - puaCircleOutline = "\ueac0" // nf-cod-circle-outline (60092) - puaAlert = "\uea9c" // nf-cod-alert (60012) - puaCheckBold = "\ueac2" // nf-cod-check (60082) - puaCloseThick = "\ueaa6" // nf-cod-close (60022) - puaArrowRight = "\ueaec" // nf-cod-arrow_right (60060) - puaArrowLeft = "\ueaeb" // nf-cod-arrow_left (60059) - puaArrowUp = "\ueaf1" // nf-cod-arrow_up (60065) - puaArrowDown = "\ueaea" // nf-cod-arrow_down (60058) - puaSwapHorizontal = "\uea7c" // nf-cod-arrow-swap (60363) - puaTimerSand = "\uebbc" // nf-cod-watch (60284) — clock-like - puaReload = "\uea7c" // nf-cod-sync (60023) - puaStop = "\ueab7" // nf-cod-error (60039) — stop-circle - puaBell = "\ueace" // nf-cod-bell (60066) - puaCancel = "\ueaa6" // nf-cod-close (60022) — alias of close - puaCheckDecagram = "\uebd7" // nf-cod-pass-filled (60339) — filled check - puaAlertOctagram = "\uebd0" // nf-cod-stop-circle (60325) - puaArrowUpBold = "\ueb07" // nf-cod-chevron-up (60087) — return-ish - puaRefresh = "\ueb77" // nf-cod-refresh (60215) - puaHourglass = "\uebbc" // nf-cod-watch (60284) - puaCheckCircle = "\uebcc" // nf-cod-pass (60324) — checked circle - puaCloseCircle = "\uebd0" // nf-cod-stop-circle (60325) — close circle - puaImage = "\ueb22" // nf-cod-file-media (60138) - puaFileDocument = "\uec4e" // nf-cod-file-text (60510) - puaKey = "\ueb51" // nf-cod-key (60177) - puaCog = "\ueb38" // nf-cod-gear (60152) - puaMagnify = "\uea9d" // nf-cod-search (60013) - puaBolt = "\ueab6" // nf-cod-zap (60038) - puaBrain = "\uea91" // nf-cod-lightbulb (60001) — visual metaphor - puaEmail = "\ueb1c" // nf-cod-mail (60188) - puaHelpCircle = "\ueaa4" // nf-cod-info (60020) — closest match - puaBranch = "\uea63" // nf-cod-repo_forked — fork/branch glyph; present in JetBrains Mono NF and every Nerd Font - puaPullRequest = "\uea64" // nf-cod-git_pull_request — PR glyph; present in JetBrains Mono NF and every Nerd Font - puaClockOutline = "\uf017" // nf-fa-clock_o (61463) - puaPause = "\uead1" // nf-cod-debug-pause (60113) - puaExpandAll = "\uebc1" // nf-cod-expand-all (60309) - puaContainer = "\ueb90" // nf-cod-server (60240) - puaShield = "\ueb93" // nf-cod-shield (60243) - puaTerminal = "\ueab5" // nf-cod-terminal (60037) - puaCaretRight = "\ueb06" // nf-cod-chevron_right (60086) - puaCaretDown = "\ueb04" // nf-cod-chevron_down (60084) - puaTriangleSmall = "\uebb1" // nf-cod-triangle-up (60273) — collapsed - puaCircleHalf = "\uea71" // nf-cod-circle-filled (60017) — visual fallback - puaCircleQuarter = "\uea71" // nf-cod-circle-filled (60017) — visual fallback - puaCircleSlice5 = "\uea71" // nf-cod-circle-filled (60017) — visual fallback - puaCircleSlice6 = "\uea71" // nf-cod-circle-filled (60017) — visual fallback - puaReturn = "\uea4c" // nf-cod-keyboard-tab (60476) — closest match - puaRotateVariant = "\uea7c" // nf-cod-sync (60023) - puaQuestion = "\ueb12" // nf-cod-question (60210) - puaFile = "\uea7b" // nf-cod-file (60027) - puaMail = "\ueb1c" // nf-cod-mail (60188) + // Keep the original Material Design PUA glyphs for the established + // status vocabulary. They have the larger, denser silhouettes used by + // the original CLI; newer concepts below continue to use Codicons. + puaChevronRight = "\ue5cc" // nf-md-chevron_right + puaRobot = "\ue244" // nf-md-robot + puaCircleFilled = "\uf444" // nf-md-circle_medium (filled) + puaCircleOutline = "\uf4a7" // nf-md-circle (outline) + puaAlert = "\ue002" // nf-md-alert + puaCheckBold = "\ue5ca" // nf-md-check_bold + puaCloseThick = "\ue5cd" // nf-md-close_thick + puaArrowRight = "\ue5c8" // nf-md-arrow_right + puaArrowLeft = "\ue5c4" // nf-md-arrow_left + puaArrowUp = "\ue5d8" // nf-md-arrow_up + puaArrowDown = "\ue5db" // nf-md-arrow_down + puaSwapHorizontal = "\ue5d5" // nf-md-swap_horizontal + puaTimerSand = "\uf51f" // nf-md-timer_sand + puaReload = "\uf045" // nf-md-reload + puaStop = "\uf04a" // nf-md-stop + puaBell = "\uf009" // nf-md-bell + puaCancel = "\uf015" // nf-md-cancel + puaCheckDecagram = "\uf079" // nf-md-check_decagram + puaAlertOctagram = "\ueb27" // nf-md-alert_octagram + puaArrowUpBold = "\ue5d9" // nf-md-arrow_up_bold (return/enter key) + puaRefresh = "\ueb77" // nf-cod-refresh (60215) + puaHourglass = "\uebbc" // nf-cod-watch (60284) + puaCheckCircle = "\uebcc" // nf-cod-pass (60324) — checked circle + puaCloseCircle = puaAlertOctagram // nf-md-alert_octagram — close circle + puaImage = "\ueb22" // nf-cod-file-media (60138) + puaFileDocument = "\uec4e" // nf-cod-file-text (60510) + puaKey = "\ueb51" // nf-cod-key (60177) + puaCog = "\ueb38" // nf-cod-gear (60152) + puaMagnify = "\uea9d" // nf-cod-search (60013) + puaBolt = "\ueab6" // nf-cod-zap (60038) + puaBrain = "\uea91" // nf-cod-lightbulb (60001) — visual metaphor + puaEmail = "\ueb1c" // nf-cod-mail (60188) + puaHelpCircle = "\ueaa4" // nf-cod-info (60020) — closest match + puaBranch = "\uea63" // nf-cod-repo_forked — fork/branch glyph; present in JetBrains Mono NF and every Nerd Font + puaPullRequest = "\uea64" // nf-cod-git_pull_request — PR glyph; present in JetBrains Mono NF and every Nerd Font + puaClockOutline = "\uf017" // nf-fa-clock_o (61463) + puaPause = "\uead1" // nf-cod-debug-pause (60113) + puaExpandAll = "\uebc1" // nf-cod-expand-all (60309) + puaContainer = "\ueb90" // nf-cod-server (60240) + puaShield = "\ueb93" // nf-cod-shield (60243) + puaTerminal = "\ueab5" // nf-cod-terminal (60037) + puaCaretRight = "\ueb06" // nf-cod-chevron_right (60086) + puaCaretDown = "\ueb04" // nf-cod-chevron_down (60084) + puaTriangleSmall = "\uebb1" // nf-cod-triangle-up (60273) — collapsed + puaCircleHalf = "\uea71" // nf-cod-circle-filled (60017) — visual fallback + puaCircleQuarter = "\uea71" // nf-cod-circle-filled (60017) — visual fallback + puaCircleSlice5 = "\uea71" // nf-cod-circle-filled (60017) — visual fallback + puaCircleSlice6 = "\uea71" // nf-cod-circle-filled (60017) — visual fallback + puaReturn = "\uea4c" // nf-cod-keyboard-tab (60476) — closest match + puaRotateVariant = "\uea7c" // nf-cod-sync (60023) + puaQuestion = "\ueb12" // nf-cod-question (60210) + puaFile = "\uea7b" // nf-cod-file (60027) + puaMail = "\ueb1c" // nf-cod-mail (60188) // Llama is not in Nerd Fonts. We pick a reserved-looking PUA slot // outside any Nerd Font set so a patched font never renders an diff --git a/internal/ui/icons/icons_test.go b/internal/ui/icons/icons_test.go index c05f5841..c93a9fb9 100644 --- a/internal/ui/icons/icons_test.go +++ b/internal/ui/icons/icons_test.go @@ -36,6 +36,26 @@ func TestGlyph_ModeASCII(t *testing.T) { } } +func TestEstablishedStatusGlyphsKeepDenseLegacyMappings(t *testing.T) { + SetMode(ModeNerd) + t.Cleanup(func() { SetMode(ModeAuto) }) + want := map[string]string{ + "robot": "\ue244", + "circle_filled": "\uf444", + "circle_outline": "\uf4a7", + "alert": "\ue002", + "check_bold": "\ue5ca", + "close_thick": "\ue5cd", + "timer": "\uf51f", + "check_decagram": "\uf079", + } + for name, expected := range want { + if got := Nerd(name); got != expected { + t.Errorf("Nerd(%q) = %q, want legacy dense glyph %q", name, got, expected) + } + } +} + func TestASCII_IgnoresMode(t *testing.T) { SetMode(ModeNerd) if got := ASCII("chevron_right"); got != ">" { From d490a7dfbdd3ab274758532458f4d02e1ba0c964 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 17:01:21 +0530 Subject: [PATCH 10/13] fix: use current Nerd Font icon mappings --- internal/ui/icons/codepoints.go | 110 +++++++++++++++----------------- internal/ui/icons/icons_test.go | 20 +++--- 2 files changed, 63 insertions(+), 67 deletions(-) diff --git a/internal/ui/icons/codepoints.go b/internal/ui/icons/codepoints.go index 5a4e8abd..400e91a9 100644 --- a/internal/ui/icons/codepoints.go +++ b/internal/ui/icons/codepoints.go @@ -17,7 +17,6 @@ // internal/testaudit. // // Codepoints are derived from: -// - Nerd Fonts Material Design Icons (legacy status vocabulary) // - Nerd Fonts v3 Codicons (EA60..EC1E) — https://github.com/microsoft/vscode-codicons // - Nerd Fonts v3 FontAwesome (ED00..F2FF) — https://fontawesome.com // - Nerd Fonts v3 Octicons (F400..F533) — https://primer.style/octicons @@ -88,62 +87,59 @@ const ( // person 60007 → EA97 archive 60056 → EAD8 // browser 60078 → EAFE vm 60026 → EAEA (collision — see below) // device-mobile 60123 → EB2B - // Keep the original Material Design PUA glyphs for the established - // status vocabulary. They have the larger, denser silhouettes used by - // the original CLI; newer concepts below continue to use Codicons. - puaChevronRight = "\ue5cc" // nf-md-chevron_right - puaRobot = "\ue244" // nf-md-robot - puaCircleFilled = "\uf444" // nf-md-circle_medium (filled) - puaCircleOutline = "\uf4a7" // nf-md-circle (outline) - puaAlert = "\ue002" // nf-md-alert - puaCheckBold = "\ue5ca" // nf-md-check_bold - puaCloseThick = "\ue5cd" // nf-md-close_thick - puaArrowRight = "\ue5c8" // nf-md-arrow_right - puaArrowLeft = "\ue5c4" // nf-md-arrow_left - puaArrowUp = "\ue5d8" // nf-md-arrow_up - puaArrowDown = "\ue5db" // nf-md-arrow_down - puaSwapHorizontal = "\ue5d5" // nf-md-swap_horizontal - puaTimerSand = "\uf51f" // nf-md-timer_sand - puaReload = "\uf045" // nf-md-reload - puaStop = "\uf04a" // nf-md-stop - puaBell = "\uf009" // nf-md-bell - puaCancel = "\uf015" // nf-md-cancel - puaCheckDecagram = "\uf079" // nf-md-check_decagram - puaAlertOctagram = "\ueb27" // nf-md-alert_octagram - puaArrowUpBold = "\ue5d9" // nf-md-arrow_up_bold (return/enter key) - puaRefresh = "\ueb77" // nf-cod-refresh (60215) - puaHourglass = "\uebbc" // nf-cod-watch (60284) - puaCheckCircle = "\uebcc" // nf-cod-pass (60324) — checked circle - puaCloseCircle = puaAlertOctagram // nf-md-alert_octagram — close circle - puaImage = "\ueb22" // nf-cod-file-media (60138) - puaFileDocument = "\uec4e" // nf-cod-file-text (60510) - puaKey = "\ueb51" // nf-cod-key (60177) - puaCog = "\ueb38" // nf-cod-gear (60152) - puaMagnify = "\uea9d" // nf-cod-search (60013) - puaBolt = "\ueab6" // nf-cod-zap (60038) - puaBrain = "\uea91" // nf-cod-lightbulb (60001) — visual metaphor - puaEmail = "\ueb1c" // nf-cod-mail (60188) - puaHelpCircle = "\ueaa4" // nf-cod-info (60020) — closest match - puaBranch = "\uea63" // nf-cod-repo_forked — fork/branch glyph; present in JetBrains Mono NF and every Nerd Font - puaPullRequest = "\uea64" // nf-cod-git_pull_request — PR glyph; present in JetBrains Mono NF and every Nerd Font - puaClockOutline = "\uf017" // nf-fa-clock_o (61463) - puaPause = "\uead1" // nf-cod-debug-pause (60113) - puaExpandAll = "\uebc1" // nf-cod-expand-all (60309) - puaContainer = "\ueb90" // nf-cod-server (60240) - puaShield = "\ueb93" // nf-cod-shield (60243) - puaTerminal = "\ueab5" // nf-cod-terminal (60037) - puaCaretRight = "\ueb06" // nf-cod-chevron_right (60086) - puaCaretDown = "\ueb04" // nf-cod-chevron_down (60084) - puaTriangleSmall = "\uebb1" // nf-cod-triangle-up (60273) — collapsed - puaCircleHalf = "\uea71" // nf-cod-circle-filled (60017) — visual fallback - puaCircleQuarter = "\uea71" // nf-cod-circle-filled (60017) — visual fallback - puaCircleSlice5 = "\uea71" // nf-cod-circle-filled (60017) — visual fallback - puaCircleSlice6 = "\uea71" // nf-cod-circle-filled (60017) — visual fallback - puaReturn = "\uea4c" // nf-cod-keyboard-tab (60476) — closest match - puaRotateVariant = "\uea7c" // nf-cod-sync (60023) - puaQuestion = "\ueb12" // nf-cod-question (60210) - puaFile = "\uea7b" // nf-cod-file (60027) - puaMail = "\ueb1c" // nf-cod-mail (60188) + puaChevronRight = "\ueb06" // nf-cod-chevron_right (60086) + puaRobot = "\ueb48" // nf-cod-hubot (60168) — same shape as robot + puaCircleFilled = "\uea71" // nf-cod-circle-filled (60017) + puaCircleOutline = "\ueac0" // nf-cod-circle-outline (60092) + puaAlert = "\uea9c" // nf-cod-alert (60012) + puaCheckBold = "\ueac2" // nf-cod-check (60082) + puaCloseThick = "\ueaa6" // nf-cod-close (60022) + puaArrowRight = "\ueaec" // nf-cod-arrow_right (60060) + puaArrowLeft = "\ueaeb" // nf-cod-arrow_left (60059) + puaArrowUp = "\ueaf1" // nf-cod-arrow_up (60065) + puaArrowDown = "\ueaea" // nf-cod-arrow_down (60058) + puaSwapHorizontal = "\uea7c" // nf-cod-arrow-swap (60363) + puaTimerSand = "\uebbc" // nf-cod-watch (60284) — clock-like + puaReload = "\uea7c" // nf-cod-sync (60023) + puaStop = "\ueab7" // nf-cod-error (60039) — stop-circle + puaBell = "\ueace" // nf-cod-bell (60066) + puaCancel = "\ueaa6" // nf-cod-close (60022) — alias of close + puaCheckDecagram = "\uebd7" // nf-cod-pass-filled (60339) — filled check + puaAlertOctagram = "\uebd0" // nf-cod-stop-circle (60325) + puaArrowUpBold = "\ueb07" // nf-cod-chevron-up (60087) — return-ish + puaRefresh = "\ueb77" // nf-cod-refresh (60215) + puaHourglass = "\uebbc" // nf-cod-watch (60284) + puaCheckCircle = "\uebcc" // nf-cod-pass (60324) — checked circle + puaCloseCircle = "\uebd0" // nf-cod-stop-circle (60325) — close circle + puaImage = "\ueb22" // nf-cod-file-media (60138) + puaFileDocument = "\uec4e" // nf-cod-file-text (60510) + puaKey = "\ueb51" // nf-cod-key (60177) + puaCog = "\ueb38" // nf-cod-gear (60152) + puaMagnify = "\uea9d" // nf-cod-search (60013) + puaBolt = "\ueab6" // nf-cod-zap (60038) + puaBrain = "\uea91" // nf-cod-lightbulb (60001) — visual metaphor + puaEmail = "\ueb1c" // nf-cod-mail (60188) + puaHelpCircle = "\ueaa4" // nf-cod-info (60020) — closest match + puaBranch = "\uea63" // nf-cod-repo_forked — fork/branch glyph; present in JetBrains Mono NF and every Nerd Font + puaPullRequest = "\uea64" // nf-cod-git_pull_request — PR glyph; present in JetBrains Mono NF and every Nerd Font + puaClockOutline = "\uf017" // nf-fa-clock_o (61463) + puaPause = "\uead1" // nf-cod-debug-pause (60113) + puaExpandAll = "\uebc1" // nf-cod-expand-all (60309) + puaContainer = "\ueb90" // nf-cod-server (60240) + puaShield = "\ueb93" // nf-cod-shield (60243) + puaTerminal = "\ueab5" // nf-cod-terminal (60037) + puaCaretRight = "\ueb06" // nf-cod-chevron_right (60086) + puaCaretDown = "\ueb04" // nf-cod-chevron_down (60084) + puaTriangleSmall = "\uebb1" // nf-cod-triangle-up (60273) — collapsed + puaCircleHalf = "\uea71" // nf-cod-circle-filled (60017) — visual fallback + puaCircleQuarter = "\uea71" // nf-cod-circle-filled (60017) — visual fallback + puaCircleSlice5 = "\uea71" // nf-cod-circle-filled (60017) — visual fallback + puaCircleSlice6 = "\uea71" // nf-cod-circle-filled (60017) — visual fallback + puaReturn = "\uea4c" // nf-cod-keyboard-tab (60476) — closest match + puaRotateVariant = "\uea7c" // nf-cod-sync (60023) + puaQuestion = "\ueb12" // nf-cod-question (60210) + puaFile = "\uea7b" // nf-cod-file (60027) + puaMail = "\ueb1c" // nf-cod-mail (60188) // Llama is not in Nerd Fonts. We pick a reserved-looking PUA slot // outside any Nerd Font set so a patched font never renders an diff --git a/internal/ui/icons/icons_test.go b/internal/ui/icons/icons_test.go index c93a9fb9..8327a324 100644 --- a/internal/ui/icons/icons_test.go +++ b/internal/ui/icons/icons_test.go @@ -36,22 +36,22 @@ func TestGlyph_ModeASCII(t *testing.T) { } } -func TestEstablishedStatusGlyphsKeepDenseLegacyMappings(t *testing.T) { +func TestStatusGlyphsUseCodiconMappings(t *testing.T) { SetMode(ModeNerd) t.Cleanup(func() { SetMode(ModeAuto) }) want := map[string]string{ - "robot": "\ue244", - "circle_filled": "\uf444", - "circle_outline": "\uf4a7", - "alert": "\ue002", - "check_bold": "\ue5ca", - "close_thick": "\ue5cd", - "timer": "\uf51f", - "check_decagram": "\uf079", + "robot": "\ueb48", + "circle_filled": "\uea71", + "circle_outline": "\ueac0", + "alert": "\uea9c", + "check_bold": "\ueac2", + "close_thick": "\ueaa6", + "timer": "\uebbc", + "check_decagram": "\uebd7", } for name, expected := range want { if got := Nerd(name); got != expected { - t.Errorf("Nerd(%q) = %q, want legacy dense glyph %q", name, got, expected) + t.Errorf("Nerd(%q) = %q, want Codicon glyph %q", name, got, expected) } } } From f31ca59d15cace0b99e64a986c5dd15f79440b03 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 17:06:13 +0530 Subject: [PATCH 11/13] fix: strengthen welcome icon rendering --- cmd/chat_welcome.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 86d97173..8fc9c421 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -231,6 +231,9 @@ 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() // Use the denser terminal glyphs here. The UI already has the semantic // text; these icons should add contrast, not vanish into the line height. @@ -252,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 = "" } @@ -287,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 { From a21add4118ba0a34cda05c8855a7b50806c2a1f9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 17:20:25 +0530 Subject: [PATCH 12/13] feat: make terminal icon mode deterministic --- docs/terminal-icons.md | 23 ++++++++++++++++++++ internal/ui/icons/detect.go | 37 ++++++-------------------------- internal/ui/icons/detect_test.go | 18 ++++++++-------- 3 files changed, 38 insertions(+), 40 deletions(-) create mode 100644 docs/terminal-icons.md diff --git a/docs/terminal-icons.md b/docs/terminal-icons.md new file mode 100644 index 00000000..24be39fa --- /dev/null +++ b/docs/terminal-icons.md @@ -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. diff --git a/internal/ui/icons/detect.go b/internal/ui/icons/detect.go index b25d5ac5..da53efe9 100644 --- a/internal/ui/icons/detect.go +++ b/internal/ui/icons/detect.go @@ -41,18 +41,16 @@ func init() { // ModeAuto from env vars. Subsequent calls return the cached value // unless SetMode is used. // -// Resolution is conservative: ASCII is the default; Nerd Font is -// enabled only when env markers explicitly indicate a patched terminal -// is in use AND stdout is a TTY. +// Resolution is explicit and deterministic: interactive TTYs use real Nerd +// Font glyphs by default, while captured/non-interactive output uses ASCII. +// A terminal name cannot prove which font is configured, so users can select +// the tier directly with HAWK_ICONS=nerd|ascii. // // Precedence: // 1. HAWK_ICONS=nerd|ascii → ModeNerd / ModeASCII // 2. NO_COLOR set → ModeASCII // 3. !stdoutIsTTY() → ModeASCII (piped output stays clean) -// 4. TERM / TERM_PROGRAM / LC_TERMINAL matches a known Nerd-Font-friendly -// terminal → ModeNerd -// 5. LANG / LC_ALL / LC_CTYPE contains "UTF-8" → ModeNerd -// 6. otherwise → ModeASCII +// 4. otherwise → ModeNerd (interactive TTY) func Mode() IconMode { m := IconMode(modeVal.Load()) if m != ModeAuto { @@ -82,30 +80,7 @@ func resolveMode() IconMode { if !stdoutIsTTY() { return ModeASCII } - term := strings.ToLower(os.Getenv("TERM")) - program := strings.ToLower(os.Getenv("TERM_PROGRAM")) - locus := strings.ToLower(os.Getenv("LC_TERMINAL")) - for _, t := range knownNerdTerm { - if strings.Contains(term, t) || strings.Contains(program, t) || strings.Contains(locus, t) { - return ModeNerd - } - } - for _, v := range []string{os.Getenv("LC_ALL"), os.Getenv("LC_CTYPE"), os.Getenv("LANG")} { - if v == "" { - continue - } - low := strings.ToLower(v) - if strings.Contains(low, "utf-8") || strings.Contains(low, "utf8") { - return ModeNerd - } - } - return ModeASCII -} - -var knownNerdTerm = []string{ - "xterm-256color", "tmux-256color", "screen-256color", - "alacritty", "wezterm", "kitty", "ghostty", - "vscode", "hyper", "iterm", "apple_terminal", + return ModeNerd } // stdoutIsTTY is overridable from tests. diff --git a/internal/ui/icons/detect_test.go b/internal/ui/icons/detect_test.go index 8ceaf816..b0a1597c 100644 --- a/internal/ui/icons/detect_test.go +++ b/internal/ui/icons/detect_test.go @@ -52,7 +52,7 @@ func TestMode_NonTTYForcesAscii(t *testing.T) { } } -func TestMode_KnownTerminalEnablesNerd(t *testing.T) { +func TestMode_TTYDefaultsToNerd(t *testing.T) { for _, term := range []string{"xterm-256color", "tmux-256color", "screen-256color", "alacritty", "wezterm", "kitty", "ghostty"} { t.Run(term, func(t *testing.T) { t.Setenv("HAWK_ICONS", "") @@ -63,13 +63,13 @@ func TestMode_KnownTerminalEnablesNerd(t *testing.T) { withInjectedTTY(t, true) SetMode(ModeAuto) if Mode() != ModeNerd { - t.Errorf("TERM=%s should enable Nerd, got %s", term, Mode()) + t.Errorf("interactive TERM=%s should default to Nerd, got %s", term, Mode()) } }) } } -func TestMode_TERMProgramEnablesNerd(t *testing.T) { +func TestMode_TERMProgramDoesNotAffectMode(t *testing.T) { for _, program := range []string{"iTerm.app", "WezTerm", "Ghostty", "vscode", "hyper"} { t.Run(program, func(t *testing.T) { t.Setenv("HAWK_ICONS", "") @@ -80,13 +80,13 @@ func TestMode_TERMProgramEnablesNerd(t *testing.T) { withInjectedTTY(t, true) SetMode(ModeAuto) if Mode() != ModeNerd { - t.Errorf("TERM_PROGRAM=%s should enable Nerd, got %s", program, Mode()) + t.Errorf("TERM_PROGRAM=%s should not change interactive Nerd default, got %s", program, Mode()) } }) } } -func TestMode_UTF8LocaleEnablesNerd(t *testing.T) { +func TestMode_UTF8LocaleDoesNotAffectMode(t *testing.T) { t.Setenv("HAWK_ICONS", "") t.Setenv("NO_COLOR", "") t.Setenv("TERM", "xterm") @@ -97,11 +97,11 @@ func TestMode_UTF8LocaleEnablesNerd(t *testing.T) { withInjectedTTY(t, true) SetMode(ModeAuto) if Mode() != ModeNerd { - t.Errorf("UTF-8 locale should enable Nerd, got %s", Mode()) + t.Errorf("UTF-8 locale should not change interactive Nerd default, got %s", Mode()) } } -func TestMode_DumbTerminalDefaultsAscii(t *testing.T) { +func TestMode_InteractiveTTYDefaultsToNerd(t *testing.T) { t.Setenv("HAWK_ICONS", "") t.Setenv("NO_COLOR", "") t.Setenv("TERM", "dumb") @@ -112,8 +112,8 @@ func TestMode_DumbTerminalDefaultsAscii(t *testing.T) { t.Setenv("LANG", "") withInjectedTTY(t, true) SetMode(ModeAuto) - if Mode() != ModeASCII { - t.Errorf("dumb TERM with no UTF-8 locale should default ASCII, got %s", Mode()) + if Mode() != ModeNerd { + t.Errorf("interactive dumb TERM should still default to Nerd, got %s", Mode()) } } From 8d6cf61d852f709b299d26514691f61da2dee4e4 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 9 Aug 2026 17:35:45 +0530 Subject: [PATCH 13/13] fix: soften default code panel background --- cmd/theme.go | 5 +++-- cmd/theme_picker.go | 2 +- internal/theme/theme_palettes.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/theme.go b/cmd/theme.go index c8d987f6..1f5976d0 100644 --- a/cmd/theme.go +++ b/cmd/theme.go @@ -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) diff --git a/cmd/theme_picker.go b/cmd/theme_picker.go index 31b8da89..3e6026a3 100644 --- a/cmd/theme_picker.go +++ b/cmd/theme_picker.go @@ -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() } diff --git a/internal/theme/theme_palettes.go b/internal/theme/theme_palettes.go index 2243fc9a..829acac0 100644 --- a/internal/theme/theme_palettes.go +++ b/internal/theme/theme_palettes.go @@ -7,7 +7,7 @@ package theme // darkPalette is the default dark theme with Hawk's Talon Gold accent. var darkPalette = Palette{ - Panel: "#0e0e10", + Panel: "#1b1e26", PromptBg: "#262626", Line: "#242429", Line2: "#414147",