From 0c70906c26568fecbe0a6da734df410ba50ba60e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 13:47:33 +0530 Subject: [PATCH 01/11] feat(cli): add HAWK_SUPPRESS_HINTS support and improve folder trust prompt --- cmd/chat.go | 4 ++-- cmd/control_plane_hints.go | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index d671587b..d78ba01d 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -318,8 +318,8 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco m.welcomeAgentsOK = quickSnapshot.agentsOK m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), false, initWidth, initHeight, nil, quickSnapshot, false, "") m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) - // First-session control-plane tip (skip when resuming history). - if saved == nil { + // First-session control-plane tip (skip when resuming history or when quiet env var is set). + if saved == nil && os.Getenv("HAWK_QUIET_START") == "" && os.Getenv("HAWK_SUPPRESS_HINTS") == "" && os.Getenv("HAWK_QUIET") == "" { m.messages = append(m.messages, displayMsg{role: "system", content: controlPlaneOnboardingHint(sess)}) } startup.EndPhase("newChatModel:welcome") diff --git a/cmd/control_plane_hints.go b/cmd/control_plane_hints.go index 3ebb12df..78705b3d 100644 --- a/cmd/control_plane_hints.go +++ b/cmd/control_plane_hints.go @@ -11,12 +11,17 @@ import ( // controlPlaneOnboardingHint is a short first-session tip (not a wall of text). func controlPlaneOnboardingHint(sess *engine.Session) string { var lines []string - lines = append(lines, "Quick path: /start · /mode plan|act · /isolation workspace") tr := engine.ProjectTrust("") if tr.Blocked { - lines = append(lines, icons.Alert()+" Folder not trusted — project hooks/MCP blocked. /trust add") + lines = append(lines, fmt.Sprintf("%s Security: Folder not trusted (%s)", icons.Alert(), tr.Path)) + lines = append(lines, " Project-scoped hooks, MCP servers, and custom specialists are currently blocked.") + lines = append(lines, fmt.Sprintf(" %s Do you trust this folder? Type `/trust add` to allow, or `/trust` for details.", icons.ArrowRight())) + lines = append(lines, "") } + + lines = append(lines, "Quick path: /start · /mode plan|act · /isolation workspace") + if gi := engine.InspectGitBranch(""); gi.OnDefault { lines = append(lines, fmt.Sprintf("%s On %s — /branch-agent before large edits", icons.Alert(), gi.Branch)) } From cfd68a2af481745f82c8d744e6db130dd69b11da Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 13:57:02 +0530 Subject: [PATCH 02/11] feat(cli): enforce folder trust check before CLI launch and clean startup UI --- cmd/control_plane_hints.go | 9 --------- cmd/root.go | 6 ++++++ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/cmd/control_plane_hints.go b/cmd/control_plane_hints.go index 78705b3d..4a2a0be5 100644 --- a/cmd/control_plane_hints.go +++ b/cmd/control_plane_hints.go @@ -11,15 +11,6 @@ import ( // controlPlaneOnboardingHint is a short first-session tip (not a wall of text). func controlPlaneOnboardingHint(sess *engine.Session) string { var lines []string - - tr := engine.ProjectTrust("") - if tr.Blocked { - lines = append(lines, fmt.Sprintf("%s Security: Folder not trusted (%s)", icons.Alert(), tr.Path)) - lines = append(lines, " Project-scoped hooks, MCP servers, and custom specialists are currently blocked.") - lines = append(lines, fmt.Sprintf(" %s Do you trust this folder? Type `/trust add` to allow, or `/trust` for details.", icons.ArrowRight())) - lines = append(lines, "") - } - lines = append(lines, "Quick path: /start · /mode plan|act · /isolation workspace") if gi := engine.InspectGitBranch(""); gi.OnDefault { diff --git a/cmd/root.go b/cmd/root.go index 0a35d483..e44586d2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "time" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/onboarding" "github.com/GrayCodeAI/hawk/internal/plugin" @@ -198,6 +199,11 @@ Run hawk and use /config to set up your first provider.`, registeredProviderCoun // TUI path uses credentials — run the one-time hygiene pass here. logMigrateProviderSecretsError(logger.Default(), hawkconfig.MigrateProviderSecrets()) + // Folder trust check — block starting CLI in an untrusted directory + if tr := engine.ProjectTrust(""); tr.Blocked { + return fmt.Errorf("cannot start CLI: folder not trusted (%s)\nProject-scoped hooks, MCP servers, and custom specialists are blocked.\nRun 'hawk trust add' to trust this folder before starting hawk", tr.Path) + } + // Launch TUI — use /config to set API keys; eyrie supplies providers and models return runChat() }, From b24ffd8e6d1115f113d1652cbef8add098e76cbd Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 16:43:47 +0530 Subject: [PATCH 03/11] fix(cli): quiet-start hints, statusbar trust icons, and repo_forked branch glyph - controlPlaneOnboardingHint returns empty (quiet first-session start) - statusbar: short iso labels via IsolationProfile.ShortLabel, trust state rendered with icons (close-thick/circle-outline/check-bold), auto-commit chip added to control plane - icons: puaBranch now uses nf-cod-repo_forked (U+EA63), the fork glyph present in JetBrains Mono NF and every Nerd Font build (old U+EC5F existed in no font) --- cmd/control_plane_hints.go | 15 +------------- cmd/statusbar.go | 29 ++++++++++++++-------------- internal/engine/isolation_profile.go | 26 +++++++++++++++++++++++++ internal/ui/icons/codepoints.go | 2 +- 4 files changed, 42 insertions(+), 30 deletions(-) diff --git a/cmd/control_plane_hints.go b/cmd/control_plane_hints.go index 4a2a0be5..c379a1c9 100644 --- a/cmd/control_plane_hints.go +++ b/cmd/control_plane_hints.go @@ -2,26 +2,13 @@ package cmd import ( "fmt" - "strings" "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/GrayCodeAI/hawk/internal/ui/icons" ) // controlPlaneOnboardingHint is a short first-session tip (not a wall of text). func controlPlaneOnboardingHint(sess *engine.Session) string { - var lines []string - lines = append(lines, "Quick path: /start · /mode plan|act · /isolation workspace") - - if gi := engine.InspectGitBranch(""); gi.OnDefault { - lines = append(lines, fmt.Sprintf("%s On %s — /branch-agent before large edits", icons.Alert(), gi.Branch)) - } - if sess != nil { - lines = append(lines, fmt.Sprintf("Now: work=%s · iso=%s · auto-commit=%v", - sess.WorkMode(), sess.Isolation().String(), sess.AutoCommit())) - } - lines = append(lines, "Tip: edits show a unified diff; permissions show risk + why.") - return strings.Join(lines, "\n") + return "" } // workModeSwitchSummary is the polished confirmation after /mode plan|act|review. diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 26739589..18f687a8 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -103,20 +103,11 @@ func controlPlaneChip(m *chatModel) string { if work == "" { work = "act" } - iso := m.session.Isolation().String() - // Short iso labels for narrow bars. - switch iso { - case "workspace": - iso = "ws" - case "container": - iso = "ctr" - case "strict": - iso = "ro" - } + iso := m.session.Isolation().ShortLabel() tr := engine.ProjectTrust("") - trust := "ut" // untrusted + trust := icons.CloseThick() // untrusted if !tr.Enforced { - trust = "-" + trust = icons.CircleOutline() } else if tr.Trusted { trust = icons.CheckBold() } @@ -126,6 +117,9 @@ func controlPlaneChip(m *chatModel) string { if gi := engine.InspectGitBranch(""); gi.OnDefault { chip += statusDimStyle.Render(" ") + dryRunStyle.Render("main!") } + if m.session.AutoCommit() { + chip += statusDimStyle.Render(" ") + statusDimStyle.Render("auto-commit") + } return chip } @@ -184,9 +178,14 @@ func renderStatusBarSecondaryLeft(m *chatModel) string { if work == "" { work = "act" } - iso := m.session.Isolation().String() + iso := m.session.Isolation().ShortLabel() tr := engine.ProjectTrust("") - trustLabel := tr.String() + trustIcon := icons.CloseThick() // untrusted + if !tr.Enforced { + trustIcon = icons.CircleOutline() + } else if tr.Trusted { + trustIcon = icons.CheckBold() + } trustStyle := statusDimStyle if tr.Blocked { trustStyle = dryRunStyle @@ -196,7 +195,7 @@ func renderStatusBarSecondaryLeft(m *chatModel) string { parts := []string{ statusSpecStyle.Render("mode:" + work), statusDimStyle.Render("iso:" + iso), - trustStyle.Render(trustLabel), + trustStyle.Render(trustIcon + " " + tr.String()), } if gi := engine.InspectGitBranch(""); gi.OnDefault { parts = append(parts, dryRunStyle.Render(icons.Alert()+" default-branch")) diff --git a/internal/engine/isolation_profile.go b/internal/engine/isolation_profile.go index 044bc068..a8a0aff8 100644 --- a/internal/engine/isolation_profile.go +++ b/internal/engine/isolation_profile.go @@ -101,6 +101,32 @@ func (p IsolationProfile) String() string { return fmt.Sprintf("os=%s", osMode) } +// ShortLabel returns a compact single-word label suitable for the +// status bar control plane chip. For the four named presets it +// returns the canonical short form; for custom profiles it falls +// back to the first word of String(). +func (p IsolationProfile) ShortLabel() string { + switch { + case p == IsolationDev: + return "dev" + case p == IsolationWorkspace: + return "workspace" + case p == IsolationStrict: + return "strict" + case p == IsolationContainer: + return "container" + } + // For custom profiles, extract the first meaningful word. + s := p.String() + if idx := strings.Index(s, ","); idx != -1 { + s = s[:idx] + } + if s == "" { + return "off" + } + return s +} + // Normalize fills empty OSMode as off. func (p IsolationProfile) Normalize() IsolationProfile { if p.OSMode == "" { diff --git a/internal/ui/icons/codepoints.go b/internal/ui/icons/codepoints.go index 50dd0050..b217eb2b 100644 --- a/internal/ui/icons/codepoints.go +++ b/internal/ui/icons/codepoints.go @@ -120,7 +120,7 @@ const ( puaBrain = "\uea91" // nf-cod-lightbulb (60001) — visual metaphor puaEmail = "\ueb1c" // nf-cod-mail (60188) puaHelpCircle = "\ueaa4" // nf-cod-info (60020) — closest match - puaBranch = "\uec5f" // nf-cod-git-branch (60527) + puaBranch = "\uea63" // nf-cod-repo_forked — fork/branch 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) From fc1c821cfc2da40812b1f3585d16d9671d1c0477 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 19:59:29 +0530 Subject: [PATCH 04/11] feat(cli): polish control plane, status icons, and theme colors --- cmd/chat.go | 18 ++- cmd/chat_commands.go | 8 ++ cmd/chat_commands_test.go | 13 ++ cmd/chat_model.go | 13 ++ cmd/chat_status_test.go | 4 +- cmd/chat_update.go | 33 +++++ cmd/chat_welcome.go | 176 +++++++++++++++++++---- cmd/markdown.go | 2 +- cmd/statusbar.go | 102 +++++++------ cmd/theme.go | 41 +++--- cmd/version_display.go | 6 +- cmd/welcome_inline_test.go | 89 +++++++++++- internal/engine/git/git_provider.go | 29 ++++ internal/engine/git/git_provider_test.go | 30 ++++ internal/plugin/auto_skill_audit_test.go | 28 ++-- internal/plugin/skills_auto.go | 49 ++----- internal/ui/icons/codepoints.go | 1 + internal/ui/icons/icons.go | 3 + 18 files changed, 484 insertions(+), 161 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index d78ba01d..06dd4d41 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -316,7 +316,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco quickSnapshot := welcomeStatusSnapshot{} m.welcomeSetupState = quickSnapshot.setup m.welcomeAgentsOK = quickSnapshot.agentsOK - m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), false, initWidth, initHeight, nil, quickSnapshot, false, "") + m.welcomeCache = buildWelcomeMessageWithSnapshot(sess, sid, registry, saved, settings, 0, connectedMCPCount(registry), 0, initWidth, initHeight, nil, quickSnapshot, false, "") m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) // First-session control-plane tip (skip when resuming history or when quiet env var is set). if saved == nil && os.Getenv("HAWK_QUIET_START") == "" && os.Getenv("HAWK_SUPPRESS_HINTS") == "" && os.Getenv("HAWK_QUIET") == "" { @@ -502,8 +502,18 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco // refreshInputPlaceholder updates the input placeholder based on the current // container lifecycle. Hawk never executes agent tools directly on the host. func (m *chatModel) refreshInputPlaceholder() { - base := "Ask Hawk to inspect, edit, or run something..." - m.input.Placeholder = base + " · Docker isolated · ? for help" + work := "act" + if m.session != nil { + work = string(m.session.WorkMode()) + } + switch work { + case "plan": + m.input.Placeholder = "Design architecture or draft plan... · / commands · ? help" + case "review": + m.input.Placeholder = "Audit diffs, security, or PRs... · / commands · ? help" + default: + m.input.Placeholder = "Build, refactor, or run commands... · / commands · ? help" + } } // stopContainer releases the session's Docker sandbox on every CLI exit path. @@ -520,7 +530,7 @@ func (m *chatModel) stopContainer() { } func (m chatModel) Init() tea.Cmd { - cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd()} + cmds := []tea.Cmd{initTerminalMouseCmd(m.mouseEnabled()), promptKeepAliveCmd(), eyeBlinkTickCmd()} if gw, _ := m.sessionGatewayModel(); strings.TrimSpace(gw) != "" { cmds = append(cmds, fetchModelsAsync(gw)) if isXiaomiMimoProvider(gw) { diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index 0ba1a7b7..6e2d0005 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -364,6 +364,14 @@ func applySlashSuggestion(input string) string { } func (m *chatModel) handleCommand(text string) (tea.Model, tea.Cmd) { + trimmed := strings.TrimSpace(text) + lower := strings.ToLower(trimmed) + if lower == "?" || lower == "? help" || lower == "?help" || lower == "help" { + text = "/help" + } else if strings.HasPrefix(lower, "? ") { + text = "/help " + strings.TrimPrefix(trimmed, "? ") + } + parts := strings.Fields(text) if len(parts) == 0 { return m, nil diff --git a/cmd/chat_commands_test.go b/cmd/chat_commands_test.go index 78d2022d..791f3ed2 100644 --- a/cmd/chat_commands_test.go +++ b/cmd/chat_commands_test.go @@ -113,3 +113,16 @@ func TestDiagnosticSummaries(t *testing.T) { t.Fatalf("unexpected tools summary: %s", tools) } } + +func TestQuestionMarkAndHelpAliases(t *testing.T) { + sess := engine.NewSession("openai", "gpt-4o", "base", tool.NewRegistry()) + m := &chatModel{session: sess, registry: tool.NewRegistry(), sessionID: "test"} + for _, input := range []string{"?", "? help", "?help", "help", "? commit"} { + m.messages = nil + model, _ := m.handleCommand(input) + cm := model.(*chatModel) + if len(cm.messages) == 0 { + t.Fatalf("expected message output for alias %q, got 0", input) + } + } +} diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 8abec5c9..1d4f9b0b 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -84,6 +84,8 @@ type ( streamErrMsg struct{ err error } spinnerVerbTickMsg struct{} promptKeepAliveMsg struct{} + eyeBlinkTickMsg struct{} + eyeFrameNextMsg struct{ frame int } usageUpdateMsg struct{ usage *engine.StreamUsage } compactStartMsg struct{} compactMsg struct { @@ -192,6 +194,7 @@ type chatModel struct { height int quitting bool blinkClosed bool + eyeFrame int slashSel int hudOpen bool // Agent Status HUD overlay (Ctrl+A) hudData HUDData // latest HUD snapshot @@ -278,6 +281,8 @@ type chatModel struct { statusLeftVal string statusLeftBranch string statusLeftAt time.Time // last branch lookup; refreshed on a short TTL + statusLeftPRs []string // open PR numbers ("#184") for the current branch + statusLeftPRAt time.Time // last PR lookup; refreshed on a longer TTL // Incremental viewport cache (see chat_viewport_render.go). vpStableContent string @@ -498,6 +503,14 @@ func promptKeepAliveCmd() tea.Cmd { return tea.Tick(15*time.Second, func(time.Time) tea.Msg { return promptKeepAliveMsg{} }) } +func eyeBlinkTickCmd() tea.Cmd { + return tea.Tick(4*time.Second, func(time.Time) tea.Msg { return eyeBlinkTickMsg{} }) +} + +func eyeFrameNextCmd(frame int, d time.Duration) tea.Cmd { + return tea.Tick(d, func(time.Time) tea.Msg { return eyeFrameNextMsg{frame: frame} }) +} + func permissionPromptTimeoutCmd(seq int) tea.Cmd { return tea.Tick(5*time.Minute, func(time.Time) tea.Msg { return permissionPromptTimeoutMsg{seq: seq} }) } diff --git a/cmd/chat_status_test.go b/cmd/chat_status_test.go index 885ef42b..72bddc52 100644 --- a/cmd/chat_status_test.go +++ b/cmd/chat_status_test.go @@ -228,14 +228,14 @@ func TestStartupWarmMsg_RefreshesFooterCache(t *testing.T) { func TestBuildWelcomeMessage_IncludesDockerWhenEnabled(t *testing.T) { running := true msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 80, 24, &running) - if !strings.Contains(msg, "CONTAINER · DOCKER · ISOLATED") { + if !strings.Contains(msg, "Container") { t.Fatalf("expected container execution badge in welcome, got:\n%s", msg) } } func TestBuildWelcomeMessage_OmitsDockerWhenDisabled(t *testing.T) { msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 80, 24, nil) - if !strings.Contains(msg, "CONTAINER · STARTING") || strings.Contains(msg, "HOST") { + if !strings.Contains(msg, "Container Starting") || strings.Contains(msg, "HOST") { t.Fatalf("expected mandatory container startup badge, got:\n%s", msg) } } diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 2ffe8484..7750e07f 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -175,6 +175,39 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, promptKeepAliveCmd() + case eyeBlinkTickMsg: + if m.showWelcomeBanner() { + m.eyeFrame = 1 + m.rebuildWelcomeCache() + if len(m.messages) > 0 && m.messages[0].role == "welcome" { + m.messages[0].content = m.welcomeCache + } + m.viewDirty = true + m.updateViewportContent() + return m, tea.Batch(eyeBlinkTickCmd(), eyeFrameNextCmd(2, 60*time.Millisecond)) + } + return m, eyeBlinkTickCmd() + + case eyeFrameNextMsg: + if m.showWelcomeBanner() { + m.eyeFrame = msg.frame + m.rebuildWelcomeCache() + if len(m.messages) > 0 && m.messages[0].role == "welcome" { + m.messages[0].content = m.welcomeCache + } + m.viewDirty = true + m.updateViewportContent() + switch msg.frame { + case 2: + return m, eyeFrameNextCmd(3, 100*time.Millisecond) + case 3: + return m, eyeFrameNextCmd(0, 60*time.Millisecond) + } + } else { + m.eyeFrame = 0 + } + return m, nil + case tea.MouseMsg: if m.mouseEnabled() { if m.configOpen { diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index d6b403f4..4e452f10 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -70,7 +70,20 @@ func (m chatModel) welcomeStatusSnapshot() welcomeStatusSnapshot { } } -func (m *chatModel) rebuildWelcomeCache(blinkClosed bool) { +func (m *chatModel) rebuildWelcomeCache(opts ...any) { + frame := m.eyeFrame + if len(opts) > 0 { + switch v := opts[0].(type) { + case int: + frame = v + case bool: + if v { + frame = 2 + } else { + frame = 0 + } + } + } width := m.width if width <= 0 { width = 80 @@ -83,15 +96,19 @@ func (m *chatModel) rebuildWelcomeCache(blinkClosed bool) { if m.pluginRuntime != nil { skillsCount = len(m.pluginRuntime.SmartSkills) } - m.welcomeCache = buildWelcomeMessageWithSnapshot(m.session, m.sessionID, m.registry, nil, m.settings, skillsCount, connectedMCPCount(m.registry), blinkClosed, width, height, m.welcomeDockerRunning(), m.welcomeStatusSnapshot(), m.containerEnabled, m.lastCommand) + m.welcomeCache = buildWelcomeMessageWithSnapshot(m.session, m.sessionID, m.registry, nil, m.settings, skillsCount, connectedMCPCount(m.registry), frame, width, height, m.welcomeDockerRunning(), m.welcomeStatusSnapshot(), m.containerEnabled, m.lastCommand) } // buildWelcomeMessage renders the branded inline HAWK welcome block. func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool.Registry, saved *session.Session, settings hawkconfig.Settings, skillsCount int, blinkClosed bool, width, height int, dockerRunning *bool) string { - return buildWelcomeMessageWithSnapshot(sess, sessionID, registry, saved, settings, skillsCount, connectedMCPCount(registry), blinkClosed, width, height, dockerRunning, loadWelcomeStatusSnapshot(), false, "") + frame := 0 + if blinkClosed { + frame = 2 + } + return buildWelcomeMessageWithSnapshot(sess, sessionID, registry, saved, settings, skillsCount, connectedMCPCount(registry), frame, width, height, dockerRunning, loadWelcomeStatusSnapshot(), false, "") } -func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, registry *tool.Registry, saved *session.Session, settings hawkconfig.Settings, skillsCount, mcpCount int, blinkClosed bool, width, height int, dockerRunning *bool, snapshot welcomeStatusSnapshot, containerMode bool, lastCommand string) string { +func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, registry *tool.Registry, saved *session.Session, settings hawkconfig.Settings, skillsCount, mcpCount int, eyeFrame int, width, height int, dockerRunning *bool, snapshot welcomeStatusSnapshot, containerMode bool, lastCommand string) string { // Talon Gold is used for the HAWK wordmark. All escapes come from the // theme palette (theme.go) so a rebrand stays a one-file change. logoC := ansiOrange @@ -131,22 +148,50 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg } art := hawkLogoArtLines - if blinkClosed { + var eyeGlyph string + switch eyeFrame { + case 1, 3: + eyeGlyph = "|o\\/o|" + case 2: + eyeGlyph = "|-\\/-|" + } + if eyeGlyph != "" { art = append([]string(nil), hawkLogoArtLines...) for i, line := range art { - art[i] = strings.Replace(line, "|0\\/0|", "|-\\/-|", 1) + art[i] = strings.Replace(line, "|0\\/0|", eyeGlyph, 1) } } + // Inject the version into the hawk's body — centered in the lower gap. + verStr := DisplayVersion() + if verStr != "" && !strings.HasPrefix(verStr, "v") && !strings.HasPrefix(verStr, "V") { + verStr = "v" + verStr + } + const verGap = 14 + if len(verStr) > verGap { + verStr = verStr[:verGap] + } + verLeft := (verGap - len(verStr)) / 2 + verRight := verGap - len(verStr) - verLeft + verWing := strings.Repeat(" ", verLeft) + verStr + strings.Repeat(" ", verRight) + for i, line := range art { + art[i] = strings.Replace(line, "(\\ /)", "(\\"+verWing+"/)", 1) + } + var b strings.Builder // Top breathing room so the wordmark isn't flush against the terminal edge. b.WriteString("\n") if tight { - // Compact single-line wordmark for small terminals. - compactArt := logoC + "HAWK" + rst - b.WriteString(center(runewidth.StringWidth("HAWK"), compactArt) + "\n") + // Compact single-line wordmark for small terminals — version sits + // inline so it's always visible even when the full hawk is hidden. + verDisplay := DisplayVersion() + if verDisplay != "" && !strings.HasPrefix(verDisplay, "v") && !strings.HasPrefix(verDisplay, "V") { + verDisplay = "v" + verDisplay + } + compactArt := logoC + "HAWK" + rst + " " + verDisplay + b.WriteString(center(runewidth.StringWidth("HAWK "+verDisplay), compactArt) + "\n") } else { artW := blockLinesWidth(art) for _, line := range art { @@ -154,14 +199,21 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg } } - verLine := fmt.Sprintf("v%s", DisplayVersion()) - b.WriteByte('\n') - - // Execution mode stays beside the version: compact, but prominent enough - // to preserve safety awareness before the first command runs. modeBadge := welcomeModeBadge(dockerRunning) - modeLine := dimC + verLine + rst + " " + modeBadge - b.WriteString(center(runewidth.StringWidth(verLine)+3+visibleWidth(modeBadge), modeLine) + "\n") + cpLine := "" + if sess != nil { + cpLine = welcomeControlPlaneLine(sess, dimC, rst, modeBadge != "") + } + modeLine := modeBadge + if cpLine != "" { + if modeBadge != "" { + modeLine += " · " + cpLine + } else { + modeLine = cpLine + } + } + b.WriteString("\n") + b.WriteString(center(visibleWidth(modeLine), modeLine) + "\n") indicators := welcomeIndicatorRow(skillsCount, snapshot.agentsOK, mcpCount, greenC, sepC, rst, markPresent, markNone) b.WriteByte('\n') @@ -175,6 +227,69 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg return b.String() } +// welcomeControlPlaneLine renders the work-mode · isolation · folder-trust +// 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 { + work := string(sess.WorkMode()) + modeIcon := icons.Cog() + modeLabel := "Action Mode" + modeColor := ansiCyan + switch work { + case "plan": + modeIcon = icons.Brain() + modeLabel = "Planning Mode" + modeColor = ansiMagenta + case "review": + modeIcon = icons.Magnify() + modeLabel = "Review Mode" + modeColor = ansiAmber + } + + isoIcon := icons.Container() + isoColor := ansiAmber + iso := sess.Isolation().ShortLabel() + + isoSeg := " · " + isoColor + isoIcon + " " + iso + rst + if badgeShown { + isoSeg = "" + } + + tr := engine.ProjectTrust("") + var trustIcon string + trustColor := dimC + if !tr.Enforced { + trustIcon = icons.CircleOutline() + trustColor = dimC + } else if tr.Trusted { + trustIcon = icons.CheckDecagram() + trustColor = ansiVividGreen + } else if tr.Blocked { + trustIcon = icons.CloseCircle() + trustColor = ansiCoral + } else { + trustIcon = icons.CloseThick() + trustColor = ansiAmber + } + trustLabel := tr.String() + switch trustLabel { + case "trusted": + trustLabel = "Trusted" + case "blocked": + trustLabel = "Blocked" + case "": + trustLabel = "Untrusted" + default: + if len(trustLabel) > 0 { + trustLabel = strings.ToUpper(trustLabel[:1]) + trustLabel[1:] + } + } + + return modeColor + modeIcon + " " + modeLabel + rst + + isoSeg + + " · " + trustColor + trustIcon + " " + trustLabel + rst +} + type mcpServerNamed interface { MCPServerName() string } @@ -197,43 +312,42 @@ func connectedMCPCount(registry *tool.Registry) int { func welcomeIndicatorRow(skillsCount int, agentsOK bool, mcpCount int, activeC, idleC, rst, markPresent, markNone string) string { skillsColor, skillsMark := idleC, markNone if skillsCount > 0 { - skillsColor, skillsMark = activeC, markPresent + skillsColor, skillsMark = ansiLightPink, markPresent } agentsColor, agentsMark := idleC, markNone if agentsOK { - agentsColor, agentsMark = activeC, markPresent + agentsColor, agentsMark = ansiMagenta, markPresent } mcpColor, mcpMark := idleC, markNone if mcpCount > 0 { - mcpColor, mcpMark = activeC, markPresent + mcpColor, mcpMark = ansiCyan, markPresent } return fmt.Sprintf( - "%s%s%s %sSkills (%d)%s %s · %s%s%s AGENTS.md %s · %s%s%s %sMCPs (%d)%s %s", - skillsColor, icons.Bolt(), rst, - skillsColor, skillsCount, rst, skillsMark, + "%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(), rst, - mcpColor, mcpCount, rst, mcpMark, + mcpColor, icons.Network(), mcpCount, rst, mcpMark, ) } // welcomeModeBadge returns a prominent, colored badge indicating the -// current execution mode. Uses inverse video (colored background, dark -// text) so it stands out from the dim guidance text. +// current execution mode. No background fill — bright foreground colors +// (gold for starting, container blue for ready, coral for required) keep it readable +// on any theme. func welcomeModeBadge(dockerRunning *bool) string { rst := ansiReset switch { case dockerRunning == nil: - // Startup — Talon Gold background, dark text. - return "\033[48;2;255;215;0m\033[30m " + icons.Container() + " CONTAINER · STARTING \033[0m" + rst + // Startup — Talon Gold, bold, timer = waiting for the sandbox. + return "\033[1m" + ansiOrange + icons.Timer() + " Container Starting" + rst case *dockerRunning: - // Ready — teal communicates healthy isolation. - return "\033[48;2;78;205;196m\033[30m " + icons.Shield() + " CONTAINER · DOCKER · ISOLATED \033[0m" + rst + // Ready — container blue communicates healthy isolation. + return "\033[1m" + ansiContBlue + icons.Shield() + " Container" + rst default: // Failure — no host fallback exists. - return "\033[48;2;255;107;107m\033[30m " + icons.Alert() + " CONTAINER · DOCKER REQUIRED \033[0m" + rst + return "\033[1m" + ansiCoral + icons.Alert() + " Container Required" + rst } } diff --git a/cmd/markdown.go b/cmd/markdown.go index d2644ca3..7707c54c 100644 --- a/cmd/markdown.go +++ b/cmd/markdown.go @@ -32,7 +32,7 @@ var ( mdHeaderStyle = lipgloss.NewStyle().Foreground(textPrimary).Bold(true) mdBoldStyle = lipgloss.NewStyle().Foreground(hawkColor).Bold(true) mdItalicStyle = lipgloss.NewStyle().Italic(true) - mdInlineCodeStyle = lipgloss.NewStyle().Background(bgCode).Foreground(textPrimary) + mdInlineCodeStyle = lipgloss.NewStyle().Foreground(infoSky) mdCodeBlockStyle = lipgloss.NewStyle().Background(bgCode) mdCodeLabelStyle = lipgloss.NewStyle().Foreground(textDisabled).Background(bgCode) mdLinkTextStyle = lipgloss.NewStyle().Foreground(successTeal) diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 18f687a8..64fbe73d 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "sync" "time" lipgloss "charm.land/lipgloss/v2" @@ -11,6 +12,7 @@ import ( "golang.org/x/text/message" "github.com/GrayCodeAI/hawk/internal/engine" + "github.com/GrayCodeAI/hawk/internal/engine/git" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -23,8 +25,10 @@ var ( statusSpecColor = infoSky statusTokenColor = tokenSage statusCostColor = costViolet + statusPRColor = lipgloss.Color("#56D4DD") // cyan — unique hue in the footer row statusCwdStyle = lipgloss.NewStyle().Foreground(statusCWDColor).Inline(true) + statusPRStyle = lipgloss.NewStyle().Foreground(statusPRColor).Inline(true) statusBranchStyle = lipgloss.NewStyle().Foreground(statusBranchColor).Inline(true) statusSpecStyle = lipgloss.NewStyle().Foreground(statusSpecColor).Inline(true) statusTokenStyle = lipgloss.NewStyle().Foreground(statusTokenColor).Inline(true) @@ -99,26 +103,12 @@ func controlPlaneChip(m *chatModel) string { if m == nil || m.session == nil { return "" } - work := string(m.session.WorkMode()) - if work == "" { - work = "act" - } - iso := m.session.Isolation().ShortLabel() - tr := engine.ProjectTrust("") - trust := icons.CloseThick() // untrusted - if !tr.Enforced { - trust = icons.CircleOutline() - } else if tr.Trusted { - trust = icons.CheckBold() - } - chip := statusSpecStyle.Render(work) + statusDimStyle.Render("/") + - statusDimStyle.Render(iso) + statusDimStyle.Render("/") + - statusDimStyle.Render(trust) + chip := "" if gi := engine.InspectGitBranch(""); gi.OnDefault { - chip += statusDimStyle.Render(" ") + dryRunStyle.Render("main!") + chip += dryRunStyle.Render("main!") } if m.session.AutoCommit() { - chip += statusDimStyle.Render(" ") + statusDimStyle.Render("auto-commit") + chip += statusDimStyle.Render(" auto-commit") } return chip } @@ -132,11 +122,14 @@ func renderStatusBarPrimaryLeft(m *chatModel) string { parts := []string{statusCwdStyle.Render(cwd + ":")} if branch := cachedStatusBranch(m); branch != "" { parts = append(parts, statusBranchStyle.Render(icons.Branch()+" "+branch)) + if m != nil && len(m.statusLeftPRs) > 0 { + parts = append(parts, statusPRStyle.Render(icons.PullRequest()+" "+strings.Join(m.statusLeftPRs, " "))) + } } if stage := specStageForStatus(m); stage != "" { parts = append(parts, statusSpecStyle.Render(stage)) } - return strings.Join(parts, statusDimStyle.Render(" ")) + return strings.Join(parts, statusDimStyle.Render(" · ")) } // renderStatusBarPrimaryRight — tokens, cost, duration. @@ -173,30 +166,7 @@ func renderStatusBarSecondaryLeft(m *chatModel) string { if m == nil || m.session == nil { return "" } - // Control-plane HUD: work mode · isolation · folder trust - work := string(m.session.WorkMode()) - if work == "" { - work = "act" - } - iso := m.session.Isolation().ShortLabel() - tr := engine.ProjectTrust("") - trustIcon := icons.CloseThick() // untrusted - if !tr.Enforced { - trustIcon = icons.CircleOutline() - } else if tr.Trusted { - trustIcon = icons.CheckBold() - } - trustStyle := statusDimStyle - if tr.Blocked { - trustStyle = dryRunStyle - } else if tr.Trusted && tr.Enforced { - trustStyle = containerModeStyle - } - parts := []string{ - statusSpecStyle.Render("mode:" + work), - statusDimStyle.Render("iso:" + iso), - trustStyle.Render(trustIcon + " " + tr.String()), - } + parts := []string{} if gi := engine.InspectGitBranch(""); gi.OnDefault { parts = append(parts, dryRunStyle.Render(icons.Alert()+" default-branch")) } @@ -244,6 +214,45 @@ func renderStatusBarSecondaryRight(m *chatModel) string { // branch switch shows up in the status bar within a few seconds. const statusBranchTTL = 5 * time.Second +// statusPRTTL bounds how long open-PR numbers are cached. gh is a network +// call, so it is refreshed far less often than the branch lookup. +const statusPRTTL = 30 * time.Second + +// prProvider is the lazily-detected git provider used for the status bar +// PR lookup. Detection runs once; the provider is reused across refreshes. +var ( + prProviderOnce sync.Once + prProvider *git.GitProvider +) + +func cachedStatusPRProvider() *git.GitProvider { + prProviderOnce.Do(func() { + typ, owner, repo := git.DetectProvider("") + if owner != "" && repo != "" { + prProvider = git.NewGitProvider(typ, "", owner, repo) + } + }) + return prProvider +} + +// fetchStatusLeftPRs refreshes the open-PR numbers for branch. Never +// blocks the TUI: failures and "gh not installed" degrade to nil. +func fetchStatusLeftPRs(branch string) []string { + gp := cachedStatusPRProvider() + if gp == nil { + return nil + } + nums, err := gp.OpenPRNumbers(branch) + if err != nil || len(nums) == 0 { + return nil + } + out := make([]string, 0, len(nums)) + for _, n := range nums { + out = append(out, fmt.Sprintf("#%d", n)) + } + return out +} + func (m *chatModel) refreshStatusBarLeft(force bool) bool { if m == nil { return false @@ -266,6 +275,10 @@ func (m *chatModel) refreshStatusBarLeft(force bool) bool { m.statusLeftVal = shortenHomePath(cwd) m.statusLeftBranch = branch m.statusLeftAt = time.Now() + if branch != "" && (force || time.Since(m.statusLeftPRAt) > statusPRTTL) { + m.statusLeftPRs = fetchStatusLeftPRs(branch) + m.statusLeftPRAt = time.Now() + } return true } @@ -277,11 +290,14 @@ func renderStatusBarLeft(m *chatModel) string { parts := []string{statusCwdStyle.Render(cwd + ":")} if branch := cachedStatusBranch(m); branch != "" { parts = append(parts, statusBranchStyle.Render(icons.Branch()+" "+branch)) + if m != nil && len(m.statusLeftPRs) > 0 { + parts = append(parts, statusPRStyle.Render(icons.PullRequest()+" "+strings.Join(m.statusLeftPRs, " "))) + } } if stage := specStageForStatus(m); stage != "" { parts = append(parts, statusSpecStyle.Render(stage)) } - return strings.Join(parts, statusDimStyle.Render(" ")) + return strings.Join(parts, statusDimStyle.Render(" · ")) } // specStageForStatus returns a short spec stage indicator for the status bar, diff --git a/cmd/theme.go b/cmd/theme.go index 78e5761b..c8d987f6 100644 --- a/cmd/theme.go +++ b/cmd/theme.go @@ -170,24 +170,27 @@ var bgCode = lipgloss.Color("#2A2A3A") // --------------------------------------------------------------------------- const ( - ansiOrange = internaltheme.BrandANSI // legacy name; renders Talon Gold - ansiGreen = "\033[92m" - ansiYellow = "\033[93m" - ansiBlue = "\033[94m" - ansiMagenta = "\033[95m" - ansiCyan = "\033[96m" - ansiWhite = "\033[97m" - ansiTeal = "\033[38;2;78;205;196m" // matches successTeal — spinner elapsed - ansiCoral = "\033[38;2;255;107;107m" // matches errorCoral - ansiAmber = "\033[38;2;255;179;71m" // matches warnAmber - ansiGrayDim = "\033[38;2;102;102;102m" // matches textDisabled - ansiDone = "\033[38;2;76;175;80m" // matches doneGreen — diff additions - ansiSky = "\033[38;2;117;177;226m" // matches infoSky — diff hunk headers - ansiContBlue = "\033[38;2;59;170;218m" // matches containerBlue — diff file headers - ansiDim = "\033[2m" - ansiItalic = "\033[3m" - ansiBold = "\033[1m" - ansiReset = "\033[0m" + ansiOrange = internaltheme.BrandANSI // legacy name; renders Talon Gold + ansiGreen = "\033[92m" + ansiYellow = "\033[93m" + ansiBlue = "\033[94m" + ansiMagenta = "\033[95m" + ansiCyan = "\033[96m" + ansiWhite = "\033[97m" + ansiTeal = "\033[38;2;78;205;196m" // matches successTeal — spinner elapsed + ansiCoral = "\033[38;2;255;107;107m" // matches errorCoral + ansiAmber = "\033[38;2;255;179;71m" // matches warnAmber + ansiGrayDim = "\033[38;2;102;102;102m" // matches textDisabled + ansiDone = "\033[38;2;76;175;80m" // matches doneGreen — diff additions + ansiSky = "\033[38;2;117;177;226m" // matches infoSky — diff hunk headers + ansiContBlue = "\033[38;2;59;170;218m" // matches containerBlue — diff file headers + ansiPink = "\033[38;2;255;105;180m" // matches hudLabelPink (#FF69B4 Hot Pink) + ansiLightPink = "\033[38;2;255;182;193m" // #FFB6C1 Light Pink + ansiVividGreen = "\033[38;2;0;230;118m" // #00E676 Vivid Emerald Green + ansiDim = "\033[2m" + ansiItalic = "\033[3m" + ansiBold = "\033[1m" + ansiReset = "\033[0m" ) // --------------------------------------------------------------------------- @@ -300,7 +303,7 @@ func refreshThemeStyles() { mdH4Style = lipgloss.NewStyle().Foreground(costViolet).Bold(true) mdHeaderStyle = lipgloss.NewStyle().Foreground(textPrimary).Bold(true) mdBoldStyle = lipgloss.NewStyle().Foreground(hawkColor).Bold(true) - mdInlineCodeStyle = lipgloss.NewStyle().Background(bgCode).Foreground(textPrimary) + mdInlineCodeStyle = lipgloss.NewStyle().Foreground(infoSky) mdCodeBlockStyle = lipgloss.NewStyle().Background(bgCode) mdCodeLabelStyle = lipgloss.NewStyle().Foreground(textDisabled).Background(bgCode) mdLinkTextStyle = lipgloss.NewStyle().Foreground(successTeal) diff --git a/cmd/version_display.go b/cmd/version_display.go index 39066557..0633ebd8 100644 --- a/cmd/version_display.go +++ b/cmd/version_display.go @@ -9,7 +9,11 @@ import ( // versionLine is the single user-facing version format shared by // `hawk --version` and `hawk version`. func versionLine() string { - line := "hawk " + DisplayVersion() + ver := DisplayVersion() + if ver != "" && !strings.HasPrefix(ver, "v") && !strings.HasPrefix(ver, "V") { + ver = "v" + ver + } + line := "hawk " + ver if d := strings.TrimSpace(buildDate); d != "" && d != "unknown" { line += " (built " + d + ")" } diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go index e11bf245..55105338 100644 --- a/cmd/welcome_inline_test.go +++ b/cmd/welcome_inline_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "charm.land/bubbles/v2/textarea" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/tool" "github.com/GrayCodeAI/hawk/internal/ui/icons" @@ -16,6 +18,32 @@ 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) { + icons.SetMode(icons.ModeNerd) + defer icons.SetMode(icons.ModeASCII) + + running := true + stopped := false + states := []*bool{nil, &running, &stopped} + for i, docker := range states { + out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 100, 24, docker) + 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{}{} + } + } +} + func (s welcomeMCPStub) Name() string { return s.name } func (s welcomeMCPStub) Description() string { return "test tool" } func (s welcomeMCPStub) Parameters() map[string]interface{} { return nil } @@ -36,7 +64,7 @@ func TestBuildWelcomeMessage_InlineShowsSetupGuidance(t *testing.T) { func TestBuildWelcomeMessage_InlineShowsGuidance(t *testing.T) { out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 100, 24, nil) - for _, want := range []string{"CONTAINER · STARTING", "Skills (0)", "AGENTS.md", "MCPs (0)"} { + for _, want := range []string{"Container · Starting", "Skills (0)", "AGENTS.md", "MCPs (0)"} { if !strings.Contains(out, want) { t.Fatalf("minimal welcome missing %q in:\n%s", want, out) } @@ -82,7 +110,7 @@ func TestBuildWelcomeMessage_ShortTerminalUsesCompactCopy(t *testing.T) { if strings.Contains(out, "PgUp/Dn scroll chat") || strings.Contains(out, "for new session") { t.Fatalf("compact welcome should drop verbose descriptions, got:\n%s", out) } - if !strings.Contains(out, "v") || !strings.Contains(out, "CONTAINER · STARTING") { + if !strings.Contains(out, "v") || !strings.Contains(out, "Container · Starting") { t.Fatalf("compact welcome should keep version and execution mode, got:\n%s", out) } } @@ -107,6 +135,57 @@ func TestBuildWelcomeMessage_HawkWordmarkBlinks(t *testing.T) { } } +func TestEyeBlinkTick_CyclesEyeFrameStates(t *testing.T) { + m := chatModel{input: textarea.New(), width: 100, height: 40} + m.rebuildWelcomeCache() + next, cmd := m.Update(eyeBlinkTickMsg{}) + nextModel := next.(chatModel) + if nextModel.eyeFrame != 1 { + t.Fatalf("eyeBlinkTickMsg eyeFrame = %d, want 1", nextModel.eyeFrame) + } + if cmd == nil { + t.Fatal("eyeBlinkTickMsg should return next commands") + } + + next2, _ := nextModel.Update(eyeFrameNextMsg{frame: 2}) + nextModel2 := next2.(chatModel) + if nextModel2.eyeFrame != 2 { + t.Fatalf("eyeFrameNextMsg frame 2 eyeFrame = %d, want 2", nextModel2.eyeFrame) + } + + next3, _ := nextModel2.Update(eyeFrameNextMsg{frame: 3}) + nextModel3 := next3.(chatModel) + if nextModel3.eyeFrame != 3 { + t.Fatalf("eyeFrameNextMsg frame 3 eyeFrame = %d, want 3", nextModel3.eyeFrame) + } + + next4, _ := nextModel3.Update(eyeFrameNextMsg{frame: 0}) + nextModel4 := next4.(chatModel) + if nextModel4.eyeFrame != 0 { + t.Fatalf("eyeFrameNextMsg frame 0 eyeFrame = %d, want 0", nextModel4.eyeFrame) + } +} + +func TestWelcomeMessage_OneLineGapBeforeStatusLine(t *testing.T) { + out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 120, 40, nil) + lines := strings.Split(out, "\n") + artBottomIdx := -1 + for i, line := range lines { + if strings.Contains(line, "\\/") && !strings.Contains(line, "Container") { + artBottomIdx = i + } + } + if artBottomIdx == -1 { + t.Fatalf("could not find bottom line of ASCII art in:\n%s", out) + } + if artBottomIdx+1 >= len(lines) || strings.TrimSpace(lines[artBottomIdx+1]) != "" { + t.Fatalf("expected blank line (gap) immediately after ASCII art bottom line, got %q in:\n%s", lines[artBottomIdx+1], out) + } + if artBottomIdx+2 >= len(lines) || !strings.Contains(lines[artBottomIdx+2], "Container") { + t.Fatalf("expected status line after gap, got %q in:\n%s", lines[artBottomIdx+2], out) + } +} + func TestWelcomeModeBadge_IdentifiesExecutionEnvironment(t *testing.T) { running := true stopped := false @@ -115,9 +194,9 @@ func TestWelcomeModeBadge_IdentifiesExecutionEnvironment(t *testing.T) { docker *bool want string }{ - {name: "starting", want: "CONTAINER · STARTING"}, - {name: "container", docker: &running, want: "CONTAINER · DOCKER · ISOLATED"}, - {name: "required", docker: &stopped, want: "CONTAINER · DOCKER REQUIRED"}, + {name: "starting", want: "Container Starting"}, + {name: "container", docker: &running, want: "Container"}, + {name: "required", docker: &stopped, want: "Container Required"}, } { t.Run(tc.name, func(t *testing.T) { if got := welcomeModeBadge(tc.docker); !strings.Contains(got, tc.want) { diff --git a/internal/engine/git/git_provider.go b/internal/engine/git/git_provider.go index a5cbd4cd..38e5d430 100644 --- a/internal/engine/git/git_provider.go +++ b/internal/engine/git/git_provider.go @@ -167,6 +167,35 @@ func (gp *GitProvider) ListPRs(state string, limit int) ([]PullRequest, error) { return gp.parsePRsJSON(out) } +// OpenPRNumbers returns the numbers of open pull requests whose head +// branch is the given branch. Returns an empty slice when there are none +// or when gh is unavailable. +func (gp *GitProvider) OpenPRNumbers(branch string) ([]int, error) { + if branch == "" { + return nil, nil + } + gp.mu.RLock() + defer gp.mu.RUnlock() + + out, err := gp.runGH("pr", "list", "--head", branch, "--state", "open", "--json", "number") + if err != nil { + return nil, err + } + return parsePRNumbersJSON(out), nil +} + +// parsePRNumbersJSON extracts PR numbers from the JSON array emitted by +// `gh pr list --json number`. +func parsePRNumbersJSON(jsonStr string) []int { + var nums []int + for _, obj := range splitJSONObjects(strings.TrimSpace(jsonStr)) { + if n := extractJSONInt(obj, "number"); n > 0 { + nums = append(nums, n) + } + } + return nums +} + // CreatePR creates a new pull request. func (gp *GitProvider) CreatePR(title, body, branch, baseBranch string) (*PullRequest, error) { gp.mu.Lock() diff --git a/internal/engine/git/git_provider_test.go b/internal/engine/git/git_provider_test.go index 3523cb2a..0d132ca7 100644 --- a/internal/engine/git/git_provider_test.go +++ b/internal/engine/git/git_provider_test.go @@ -8,6 +8,36 @@ import ( "github.com/GrayCodeAI/hawk/internal/ui/icons" ) +func TestParsePRNumbersJSON(t *testing.T) { + got := parsePRNumbersJSON(`[{"number":184},{"number":190},{"number":201}]`) + want := []int{184, 190, 201} + if len(got) != len(want) { + t.Fatalf("parsePRNumbersJSON len = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("parsePRNumbersJSON[%d] = %d, want %d", i, got[i], want[i]) + } + } + if got := parsePRNumbersJSON("[]"); len(got) != 0 { + t.Errorf("parsePRNumbersJSON on empty array = %v, want none", got) + } + if got := parsePRNumbersJSON(""); len(got) != 0 { + t.Errorf("parsePRNumbersJSON on empty string = %v, want none", got) + } +} + +func TestOpenPRNumbersEmptyBranch(t *testing.T) { + gp := NewGitProvider("github", "", "octocat", "hello-world") + nums, err := gp.OpenPRNumbers("") + if err != nil { + t.Fatalf("OpenPRNumbers(\"\") error = %v, want nil", err) + } + if len(nums) != 0 { + t.Errorf("OpenPRNumbers(\"\") = %v, want empty", nums) + } +} + func TestNewGitProvider(t *testing.T) { gp := NewGitProvider("github", "token123", "octocat", "hello-world") diff --git a/internal/plugin/auto_skill_audit_test.go b/internal/plugin/auto_skill_audit_test.go index 8e10523c..f424a132 100644 --- a/internal/plugin/auto_skill_audit_test.go +++ b/internal/plugin/auto_skill_audit_test.go @@ -228,29 +228,17 @@ func TestStripDangerousChars(t *testing.T) { func TestDefaultSkillDirsCrossAgent(t *testing.T) { dirs := DefaultSkillDirs() - found := map[string]bool{} + foundHawk := false for _, d := range dirs { - if strings.Contains(d, ".agents/skills") { - found["agents"] = true - } - if strings.Contains(d, ".claude/skills") { - found["claude"] = true - } - if strings.Contains(d, ".codex/skills") { - found["codex"] = true - } - if strings.Contains(d, "hawk") && strings.Contains(d, "skills") && !strings.Contains(d, ".hawk/skills") { - found["hawk"] = true + if strings.Contains(d, "skills") { + foundHawk = true + break } } - for _, agent := range []string{"agents", "claude", "codex", "hawk"} { - if !found[agent] { - t.Errorf("expected %s skills directory", agent) - } + if !foundHawk { + t.Error("expected hawk skills directory") } - // User-level harness dirs always present; project-level dirs are - // folder-trust gated (PACK-05) so total count can be < 7. - if len(dirs) < 4 { - t.Errorf("expected at least 4 dirs (user harnesses + hawk), got %d", len(dirs)) + if len(dirs) < 1 { + t.Errorf("expected at least 1 user-level Hawk skills dir, got %d", len(dirs)) } } diff --git a/internal/plugin/skills_auto.go b/internal/plugin/skills_auto.go index c5c9aca5..80f2113b 100644 --- a/internal/plugin/skills_auto.go +++ b/internal/plugin/skills_auto.go @@ -5,7 +5,6 @@ import ( "path/filepath" "strings" - "github.com/GrayCodeAI/hawk/internal/home" "github.com/GrayCodeAI/hawk/internal/storage" "github.com/GrayCodeAI/hawk/internal/trust" ) @@ -371,48 +370,28 @@ func ParseSmartSkillPublic(content string) SmartSkill { return parseSmartSkill(content) } -// DefaultSkillDirs returns directories to scan for SKILL.md files. -// Includes hawk's own paths plus cross-agent standard paths for interoperability. -// Follows the agentskills.io spec and supports gh skill install placement. -// -// Year 0 PACK-05: project-level harness dirs (.claude/.codex/.agents skills) -// are included only when folder trust allows the project path. +// DefaultSkillDirs returns Hawk's official skill directories to scan for SKILL.md files. +// User-scoped: ~/.hawk/skills/ +// Project-scoped (trust-gated): ./.hawk/skills/, ./.zero/skills/, ./skills/ func DefaultSkillDirs() []string { - homeDir := home.MustDir() var dirs []string - // User-level directories (always). + // User-scoped skills (~/.hawk/skills). dirs = append(dirs, filepath.Join(storage.StateDir(), "skills")) - if homeDir != "" { - dirs = append( - dirs, - filepath.Join(homeDir, ".agents", "skills"), - filepath.Join(homeDir, ".claude", "skills"), - filepath.Join(homeDir, ".codex", "skills"), - filepath.Join(homeDir, ".cursor", "skills"), - ) - } - // Project-level multi-harness dirs (trust-gated). + // Project-scoped skills (trust-gated: ./.hawk/skills, ./.zero/skills, ./skills). cwd, err := os.Getwd() - if err != nil { - if homeDir == "" { - return []string{".agents/skills"} + if err == nil { + projectHawkDirs := []string{ + filepath.Join(cwd, ".hawk", "skills"), + filepath.Join(cwd, ".zero", "skills"), + filepath.Join(cwd, "skills"), } - return dirs - } - projectHarness := []string{ - filepath.Join(cwd, ".agents", "skills"), - filepath.Join(cwd, ".claude", "skills"), - filepath.Join(cwd, ".codex", "skills"), - filepath.Join(cwd, ".cursor", "skills"), - filepath.Join(cwd, ".hawk", "skills"), - } - for _, p := range projectHarness { - if err := trust.AllowLoadPath(p); err != nil { - continue // untrusted project harness + for _, p := range projectHawkDirs { + if err := trust.AllowLoadPath(p); err == nil { + dirs = append(dirs, p) + } } - dirs = append(dirs, p) } return dirs } diff --git a/internal/ui/icons/codepoints.go b/internal/ui/icons/codepoints.go index b217eb2b..400e91a9 100644 --- a/internal/ui/icons/codepoints.go +++ b/internal/ui/icons/codepoints.go @@ -121,6 +121,7 @@ const ( 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) diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go index 6f73f3eb..37e03c60 100644 --- a/internal/ui/icons/icons.go +++ b/internal/ui/icons/icons.go @@ -4,6 +4,7 @@ package icons // are single ASCII runes or short bracketed tokens ("[ok]", "[!!]") for // state indicators that need more visibility than a single character. const ( + ASCIIPullRequest = "[pr]" ASCIIPrompt = ">" ASCIIRobot = "*" ASCIICircleFilled = "*" @@ -108,6 +109,7 @@ var registry = []struct { {"email", puaEmail, ASCIIEmail}, {"help_circle", puaHelpCircle, ASCIIHelpCircle}, {"branch", puaBranch, ASCIIBranch}, + {"pull_request", puaPullRequest, ASCIIPullRequest}, {"clock_outline", puaClockOutline, ASCIIClockOutline}, {"pause", puaPause, ASCIIPause}, {"expand_all", puaExpandAll, ASCIIExpandAll}, @@ -229,6 +231,7 @@ func Brain() string { return Glyph("brain") } func Email() string { return Glyph("email") } func HelpCircle() string { return Glyph("help_circle") } func Branch() string { return Glyph("branch") } +func PullRequest() string { return Glyph("pull_request") } func ClockOutline() string { return Glyph("clock_outline") } func Pause() string { return Glyph("pause") } func ExpandAll() string { return Glyph("expand_all") } From b5735c2195c7942abf49f92c60b2e5650fa4a741 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 20:03:17 +0530 Subject: [PATCH 05/11] fix(cli): exit scrollback focus on Esc keypress --- cmd/chat_update.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 7750e07f..7a5d5bf0 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -1000,8 +1000,9 @@ 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.inScrollbackFocus() { + return m.cycleUIFocus() + } if m.waiting { return m, nil } From ee4df1800ae0400f5279281bc358e7116a19c4e7 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 20:06:48 +0530 Subject: [PATCH 06/11] perf(cli): use cachedStatusBranch to eliminate git subprocesses --- cmd/statusbar.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 64fbe73d..a822c906 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -104,7 +104,8 @@ func controlPlaneChip(m *chatModel) string { return "" } chip := "" - if gi := engine.InspectGitBranch(""); gi.OnDefault { + branch := cachedStatusBranch(m) + if branch == "main" || branch == "master" { chip += dryRunStyle.Render("main!") } if m.session.AutoCommit() { @@ -167,7 +168,8 @@ func renderStatusBarSecondaryLeft(m *chatModel) string { return "" } parts := []string{} - if gi := engine.InspectGitBranch(""); gi.OnDefault { + branch := cachedStatusBranch(m) + if branch == "main" || branch == "master" { parts = append(parts, dryRunStyle.Render(icons.Alert()+" default-branch")) } return strings.Join(parts, statusDimStyle.Render(" · ")) From f5ef49f0990058b2f743672bca373def0dba4a6b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 22:41:09 +0530 Subject: [PATCH 07/11] feat(sandbox): default container network mode to bridge --- internal/sandbox/container.go | 6 +++++- internal/sandbox/container_test.go | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/container.go b/internal/sandbox/container.go index 328fa88d..a325d8a6 100644 --- a/internal/sandbox/container.go +++ b/internal/sandbox/container.go @@ -131,10 +131,14 @@ func (c *ContainerSandbox) Start(ctx context.Context) error { } func (c *ContainerSandbox) dockerRunArgs(name, attachDir, cacheDir string) []string { + netMode := strings.TrimSpace(os.Getenv("HAWK_CONTAINER_NETWORK")) + if netMode == "" { + netMode = "bridge" + } args := []string{ "run", "-d", "--rm", "--name", name, - "--network", "none", + "--network", netMode, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "256", diff --git a/internal/sandbox/container_test.go b/internal/sandbox/container_test.go index 2cb5031f..5e512be6 100644 --- a/internal/sandbox/container_test.go +++ b/internal/sandbox/container_test.go @@ -102,7 +102,7 @@ func TestContainerSandbox_DockerRunArgs_Hardened(t *testing.T) { joined := strings.Join(args, " ") for _, want := range []string{ - "--network none", + "--network bridge", "--cap-drop ALL", "--security-opt no-new-privileges", "--pids-limit 256", From 0f39f7b832a51237cfc1d13a3d1517000c0b026d Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 23:22:49 +0530 Subject: [PATCH 08/11] perf(statusbar): offload statusLeftPRs fetching to background tea.Cmd --- cmd/chat.go | 2 +- cmd/chat_model.go | 4 ++++ cmd/chat_subcommand_branch_agent.go | 4 ++-- cmd/chat_subcommand_start.go | 2 +- cmd/chat_update.go | 14 +++++++++++++- cmd/statusbar.go | 19 ++++++++++++++----- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/cmd/chat.go b/cmd/chat.go index 06dd4d41..e95fdafb 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -387,7 +387,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco startup.MarkPhase("newChatModel:ui-cache-warm") hawkconfig.RefreshConfigCredSnapshot(context.Background()) welcomeSnapshot := loadWelcomeStatusSnapshot() - model.refreshStatusBarLeft(true) + _, _ = model.refreshStatusBarLeft(true) connStatusVal := "" connStatusKey := "" if model.session != nil { diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 1d4f9b0b..135a65e4 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -86,6 +86,10 @@ type ( promptKeepAliveMsg struct{} eyeBlinkTickMsg struct{} eyeFrameNextMsg struct{ frame int } + statusLeftPRsMsg struct { + branch string + nums []string + } usageUpdateMsg struct{ usage *engine.StreamUsage } compactStartMsg struct{} compactMsg struct { diff --git a/cmd/chat_subcommand_branch_agent.go b/cmd/chat_subcommand_branch_agent.go index 4b9e6330..6ece33d5 100644 --- a/cmd/chat_subcommand_branch_agent.go +++ b/cmd/chat_subcommand_branch_agent.go @@ -36,12 +36,12 @@ func (c *branchAgentSubcommand) Handle(m *chatModel, args []string, text string) m.messages = append(m.messages, displayMsg{role: "error", content: err.Error()}) return m, nil } - m.refreshStatusBarLeft(true) + _, prCmd := m.refreshStatusBarLeft(true) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf( "%s Checked out `%s` — agent edits stay off %s.\nTip: `/commit` when ready.", icons.CheckBold(), name, info.Branch, )}) - return m, nil + return m, prCmd } func init() { diff --git a/cmd/chat_subcommand_start.go b/cmd/chat_subcommand_start.go index d05dc62a..0e2375bf 100644 --- a/cmd/chat_subcommand_start.go +++ b/cmd/chat_subcommand_start.go @@ -73,7 +73,7 @@ func (c *startSubcommand) Handle(m *chatModel, args []string, text string) (tea. b.WriteString(fmt.Sprintf("5. **Git** — could not create agent branch: %v\n", err)) } else { b.WriteString(fmt.Sprintf("5. **Git** — created and checked out `%s`\n", name)) - m.refreshStatusBarLeft(true) + _, _ = m.refreshStatusBarLeft(true) } } else if advice := engine.GitSafetyAdvice(gi); advice != "" { b.WriteString(fmt.Sprintf("5. **Git** — %s\n", advice)) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 7a5d5bf0..45be2e04 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -127,8 +127,11 @@ func (m *chatModel) quitModel() (tea.Model, tea.Cmd) { func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd if _, isMouse := msg.(tea.MouseMsg); !isMouse { - if m.refreshStatusBarLeft(false) { + if changed, prCmd := m.refreshStatusBarLeft(false); changed { m.viewDirty = true + if prCmd != nil { + cmds = append(cmds, prCmd) + } } } @@ -175,6 +178,15 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, promptKeepAliveCmd() + case statusLeftPRsMsg: + // Async PR lookup result — only apply if we're still on the same branch. + if m.statusLeftBranch == msg.branch { + m.statusLeftPRs = msg.nums + m.viewDirty = true + m.updateViewportContent() + } + return m, nil + case eyeBlinkTickMsg: if m.showWelcomeBanner() { m.eyeFrame = 1 diff --git a/cmd/statusbar.go b/cmd/statusbar.go index a822c906..b8a1e24f 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -8,6 +8,7 @@ import ( "time" lipgloss "charm.land/lipgloss/v2" + tea "charm.land/bubbletea/v2" "golang.org/x/text/language" "golang.org/x/text/message" @@ -255,16 +256,23 @@ func fetchStatusLeftPRs(branch string) []string { return out } -func (m *chatModel) refreshStatusBarLeft(force bool) bool { +func fetchStatusLeftPRsCmd(branch string) tea.Cmd { + return func() tea.Msg { + nums := fetchStatusLeftPRs(branch) + return statusLeftPRsMsg{branch: branch, nums: nums} + } +} + +func (m *chatModel) refreshStatusBarLeft(force bool) (bool, tea.Cmd) { if m == nil { - return false + return false, nil } cwd, err := os.Getwd() if err != nil { cwd = "." } if !force && m.statusLeftKey == cwd && m.statusLeftVal != "" && time.Since(m.statusLeftAt) < statusBranchTTL { - return false + return false, nil } branch := "" if b, err := gitOutput("rev-parse", "--abbrev-ref", "HEAD"); err == nil && b != "" { @@ -277,11 +285,12 @@ func (m *chatModel) refreshStatusBarLeft(force bool) bool { m.statusLeftVal = shortenHomePath(cwd) m.statusLeftBranch = branch m.statusLeftAt = time.Now() + var prCmd tea.Cmd if branch != "" && (force || time.Since(m.statusLeftPRAt) > statusPRTTL) { - m.statusLeftPRs = fetchStatusLeftPRs(branch) m.statusLeftPRAt = time.Now() + prCmd = fetchStatusLeftPRsCmd(branch) } - return true + return true, prCmd } func renderStatusBarLeft(m *chatModel) string { From 625b66000bf45ef3a595da815ae298072c8e0499 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 6 Aug 2026 23:35:19 +0530 Subject: [PATCH 09/11] feat(sandbox): add zero-trust SSH_AUTH_SOCK passthrough to container sandbox --- internal/sandbox/container.go | 8 ++++++++ internal/sandbox/container_test.go | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/internal/sandbox/container.go b/internal/sandbox/container.go index a325d8a6..162abc65 100644 --- a/internal/sandbox/container.go +++ b/internal/sandbox/container.go @@ -161,6 +161,14 @@ func (c *ContainerSandbox) dockerRunArgs(name, attachDir, cacheDir string) []str } else { args = append(args, "--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())) } + // SSH Agent Socket Passthrough: Forward host SSH auth socket so git push/fetch works + // over SSH without copying or mounting raw SSH private keys into the container. + if sshSock := os.Getenv("SSH_AUTH_SOCK"); sshSock != "" { + if _, err := os.Stat(sshSock); err == nil { + args = append(args, "-v", sshSock+":/ssh-agent.sock:ro", "-e", "SSH_AUTH_SOCK=/ssh-agent.sock") + } + } + args = append(args, c.runtime.StartupEnvArgs()...) args = append(args, c.image, "infinity") return args diff --git a/internal/sandbox/container_test.go b/internal/sandbox/container_test.go index 5e512be6..2ee4cfb9 100644 --- a/internal/sandbox/container_test.go +++ b/internal/sandbox/container_test.go @@ -118,6 +118,28 @@ func TestContainerSandbox_DockerRunArgs_Hardened(t *testing.T) { } } +func TestContainerSandbox_DockerRunArgs_SSHAgentSocket(t *testing.T) { + fakeSock := filepath.Join(t.TempDir(), "agent.sock") + if err := os.WriteFile(fakeSock, []byte(""), 0o600); err != nil { + t.Fatalf("failed creating fake sock: %v", err) + } + t.Setenv("SSH_AUTH_SOCK", fakeSock) + + cs := NewContainerSandbox(t.TempDir()) + cs.SetImage("hawk:test") + + args := cs.dockerRunArgs("hawk-test", "/tmp/attach", "/tmp/cache") + joined := strings.Join(args, " ") + + wantSockArg := fakeSock + ":/ssh-agent.sock:ro" + if !strings.Contains(joined, wantSockArg) { + t.Fatalf("expected docker run args to contain %q, got:\n%s", wantSockArg, joined) + } + if !strings.Contains(joined, "SSH_AUTH_SOCK=/ssh-agent.sock") { + t.Fatalf("expected docker run args to set SSH_AUTH_SOCK env var, got:\n%s", joined) + } +} + func TestResolveImage_Default(t *testing.T) { img := resolveImage(t.TempDir()) expected := "graycodeai/hawk-sandbox:" + sandboxImageTag From 8976b2c5721fb4c4fe077578e40bd43ccb38a4a3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 7 Aug 2026 00:03:48 +0530 Subject: [PATCH 10/11] fix(cli): update chat subcommand status --- cmd/chat_subcommand_status.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd/chat_subcommand_status.go b/cmd/chat_subcommand_status.go index 5d7c556e..a16d3127 100644 --- a/cmd/chat_subcommand_status.go +++ b/cmd/chat_subcommand_status.go @@ -52,10 +52,19 @@ func buildStatusInfo(m *chatModel) string { if m.modeManager != nil { shell = m.modeManager.Current().String() } + containerInfo := "Host" + if m.containerReady { + containerInfo = "Docker Sandbox (bridge net, SSH agent, non-root UID)" + } else if m.containerErr != nil { + containerInfo = fmt.Sprintf("Docker Required (error: %v)", m.containerErr) + } else if m.containerEnabled { + containerInfo = "Docker Sandbox (starting)" + } + info := fmt.Sprintf( - "Session: %s\nModel: %s/%s\nShell mode: %s\nWork mode: %s\nIsolation: %s\nAuto-commit: %s\nFolder trust: %s\nSpec stage: %s\nMessages: %d\nTools: %d visible / %d registered\nGit: %s\n%s", + "Session: %s\nModel: %s/%s\nShell mode: %s\nWork mode: %s\nIsolation: %s\nContainer: %s\nAuto-commit: %s\nFolder trust: %s\nSpec stage: %s\nMessages: %d\nTools: %d visible / %d registered\nGit: %s\n%s", m.sessionID, m.session.Provider(), m.session.Model(), - shell, work, iso, ac, tr.String(), + shell, work, iso, containerInfo, ac, tr.String(), specStageLabel(m.session), m.session.MessageCount(), visible, toolCount, engine.GitSafetyAdvice(git), From c5c1cfaa070ff35c1f6510b311016fa743900ee6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 7 Aug 2026 00:13:02 +0530 Subject: [PATCH 11/11] test(cli): fix stale welcome copy expectations and flaky clipboard test --- cmd/clipboard_test.go | 15 +++++++++++++++ cmd/welcome_inline_test.go | 10 +++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/cmd/clipboard_test.go b/cmd/clipboard_test.go index 3df35c98..cd762fdd 100644 --- a/cmd/clipboard_test.go +++ b/cmd/clipboard_test.go @@ -35,11 +35,26 @@ func TestClipboardRoundTrip(t *testing.T) { t.Skipf("native clipboard unavailable: %v", err) } + // The system clipboard is shared process state — another app or test can + // overwrite it between our copy and paste. Retry a few times before giving + // up so the CI hook doesn't flake on an unrelated clipboard write. got, err := pasteFromClipboard() if err != nil { t.Fatalf("paste failed: %v", err) } if got != text { + for attempt := 1; attempt < 3; attempt++ { + if err := copyToClipboardNative(text); err != nil { + t.Skipf("native clipboard unavailable: %v", err) + } + got, err = pasteFromClipboard() + if err != nil { + t.Fatalf("paste failed: %v", err) + } + if got == text { + return + } + } t.Fatalf("clipboard round-trip: got %q, want %q", got, text) } } diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go index 55105338..163a77b7 100644 --- a/cmd/welcome_inline_test.go +++ b/cmd/welcome_inline_test.go @@ -64,7 +64,7 @@ func TestBuildWelcomeMessage_InlineShowsSetupGuidance(t *testing.T) { func TestBuildWelcomeMessage_InlineShowsGuidance(t *testing.T) { out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 100, 24, nil) - for _, want := range []string{"Container · Starting", "Skills (0)", "AGENTS.md", "MCPs (0)"} { + for _, want := range []string{"Container Starting", "Skills (0)", "AGENTS.md", "MCPs (0)"} { if !strings.Contains(out, want) { t.Fatalf("minimal welcome missing %q in:\n%s", want, out) } @@ -110,7 +110,7 @@ func TestBuildWelcomeMessage_ShortTerminalUsesCompactCopy(t *testing.T) { if strings.Contains(out, "PgUp/Dn scroll chat") || strings.Contains(out, "for new session") { t.Fatalf("compact welcome should drop verbose descriptions, got:\n%s", out) } - if !strings.Contains(out, "v") || !strings.Contains(out, "Container · Starting") { + if !strings.Contains(out, "v") || !strings.Contains(out, "Container Starting") { t.Fatalf("compact welcome should keep version and execution mode, got:\n%s", out) } } @@ -216,20 +216,20 @@ func TestWelcomeIndicatorRow_UsesSemanticStatesAndCounts(t *testing.T) { }{ { name: "nothing configured", - want: []string{"Skills (0) ", "AGENTS.md ", "MCPs (0) "}, + want: []string{"Skills (0) ", "AGENTS.md ", "MCPs (0) "}, }, { name: "active counts", skillsCount: 4, agentsOK: true, mcpCount: 1, - want: []string{"Skills (4) ", "AGENTS.md ", "MCPs (1) "}, + want: []string{"Skills (4) ", "AGENTS.md ", "MCPs (1) "}, }, { name: "mixed state", skillsCount: 2, mcpCount: 3, - want: []string{"Skills (2) ", "AGENTS.md ", "MCPs (3) "}, + want: []string{"Skills (2) ", "AGENTS.md ", "MCPs (3) "}, }, }