diff --git a/cmd/chat.go b/cmd/chat.go index acc4d707..7b569817 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -123,7 +123,8 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting startup.EndPhase("newChatModel:ui-init") startup.MarkPhase("newChatModel:effectiveModelAndProvider") - effectiveModel, effectiveProvider := effectiveModelAndProvider(settings) + selection := resolveSelection(settings) + effectiveModel, effectiveProvider := selection.Model, selection.Provider startup.EndPhase("newChatModel:effectiveModelAndProvider") startup.MarkPhase("newChatModel:defaultRegistry") @@ -134,7 +135,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting startup.EndPhase("newChatModel:defaultRegistry") startup.MarkPhase("newChatModel:newHawkSession") - sess := newHawkSession(settings, effectiveProvider, effectiveModel, systemPrompt, registry) + sess := newHawkSessionFromSelection(selection, systemPrompt, registry) startup.EndPhase("newChatModel:newHawkSession") startup.MarkPhase("newChatModel:configureSession") @@ -299,7 +300,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting ok := sandbox.DockerAvailable() dockerRunning = &ok } - m.welcomeCache = buildWelcomeMessage(sess, sid, registry, saved, settings, false, initWidth, initHeight, dockerRunning) + m.welcomeCache = buildWelcomeMessage(sess, sid, registry, saved, settings, len(pr.SmartSkills), false, initWidth, initHeight, dockerRunning) m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) // Wire permission system @@ -446,6 +447,7 @@ func autoIndexCodegraph() { } func runChat() error { + startup.Reset() startBackgroundCatalogRefresh(context.Background()) // Auto-index codegraph in background if .codegraph exists diff --git a/cmd/chat_journey_e2e_test.go b/cmd/chat_journey_e2e_test.go new file mode 100644 index 00000000..4d974e37 --- /dev/null +++ b/cmd/chat_journey_e2e_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "context" + "strings" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) + +func requireChatModel(t *testing.T, model any) *chatModel { + t.Helper() + switch v := model.(type) { + case *chatModel: + return v + case chatModel: + return &v + default: + t.Fatalf("unexpected model type %T", model) + return nil + } +} + +func TestChatJourney_ConfigPermissionsAndCoreCommands(t *testing.T) { + hawkconfig.InvalidateConfigUICache() + isolateCredentialHome(t) + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { + credentials.SetDefaultStore(nil) + hawkconfig.InvalidateConfigUICache() + }) + + ctx := context.Background() + if err := store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatal(err) + } + hawkconfig.InvalidateConfigUICache() + + m := newTestChatModel() + + result, cmd := m.handleCommand("/config") + if cmd != nil { + t.Fatal("expected /config to open inline panel without tea command") + } + m = requireChatModel(t, result) + if !m.configOpen || m.configTab != configTabModels { + t.Fatalf("/config should open models tab once credentials exist, got open=%v tab=%d", m.configOpen, m.configTab) + } + + result, _ = m.handleCommand("/config set provider openrouter") + m = requireChatModel(t, result) + if got := strings.ToLower(lastSystemMessage(m.messages)); !strings.Contains(got, "openrouter") { + t.Fatalf("unexpected provider update message: %q", got) + } + + if got := m.session.Provider(); got != "openrouter" { + t.Fatalf("session provider = %q, want openrouter", got) + } + + result, _ = m.handleCommand("/permissions allow Bash(git:*)") + m = requireChatModel(t, result) + if got := lastSystemMessage(m.messages); !strings.Contains(got, "Allow rules updated.") { + t.Fatalf("unexpected allow update message: %q", got) + } + + result, _ = m.handleCommand("/permissions rules") + m = requireChatModel(t, result) + if got := lastSystemMessage(m.messages); !strings.Contains(got, "Bash(git:*)") { + t.Fatalf("permission rules summary missing allow rule: %q", got) + } + + for _, slash := range []string{"/help", "/status", "/tools"} { + result, _ = m.handleCommand(slash) + m = requireChatModel(t, result) + if msg := strings.TrimSpace(lastSystemMessage(m.messages)); msg == "" { + t.Fatalf("%s should append a system message", slash) + } + } +} diff --git a/cmd/chat_layout_mouse_test.go b/cmd/chat_layout_mouse_test.go index 5aa92bc8..332c732e 100644 --- a/cmd/chat_layout_mouse_test.go +++ b/cmd/chat_layout_mouse_test.go @@ -31,10 +31,10 @@ func TestView_LineCountMatchesHeight(t *testing.T) { if m.footerTopY() <= m.chatPaneTopY() { t.Fatalf("footerTopY %d must be below chat top %d", m.footerTopY(), m.chatPaneTopY()) } - // Footer must start on the same row View() renders the container/model line. + // Footer must start on the same row View() renders the host/container line. footerIdx := -1 for i, line := range lines { - if strings.Contains(line, "Default") || strings.Contains(line, "Container:") { + if strings.Contains(line, "Host mode:") || strings.Contains(line, "Container:") { footerIdx = i break } diff --git a/cmd/chat_layout_test.go b/cmd/chat_layout_test.go index a4ab7b36..a736a09f 100644 --- a/cmd/chat_layout_test.go +++ b/cmd/chat_layout_test.go @@ -37,7 +37,7 @@ func TestView_PinsWelcomeAboveViewport(t *testing.T) { if !strings.HasPrefix(got, "HAWK LOGO") { t.Fatalf("welcome should be pinned at top, got prefix: %q", got[:min(40, len(got))]) } - if !strings.Contains(got, "Default") && !strings.Contains(got, "Container:") { + if !strings.Contains(got, "Host mode:") && !strings.Contains(got, "Container:") { t.Fatalf("footer should be present at bottom") } } diff --git a/cmd/chat_model_test.go b/cmd/chat_model_test.go index 097f802a..e7c2c57a 100644 --- a/cmd/chat_model_test.go +++ b/cmd/chat_model_test.go @@ -3,11 +3,17 @@ package cmd import ( "strings" "testing" + "time" + "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/hawk/internal/bridge/sessioncapture" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/feature/shellmode" + "github.com/GrayCodeAI/hawk/internal/storage" "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" ) func newTestChatModel() *chatModel { @@ -16,6 +22,8 @@ func newTestChatModel() *chatModel { sess.SetTestClient(engine.NewMockClientForTest()) m := &chatModel{ + input: textarea.New(), + viewport: viewport.New(120, 12), session: sess, registry: tool.NewRegistry(), partial: &strings.Builder{}, @@ -27,10 +35,27 @@ func newTestChatModel() *chatModel { termCtx: sessioncapture.NewTerminalContext(), ghostText: NewGhostText(), inputIndicator: &InputIndicator{}, + hintsLoader: engine.NewHintsLoader(), + selfImprover: engine.NewSelfImprover(), + codingSoul: engine.LoadCodingSoul(), + brailleSpinner: NewBrailleSpinner(SpinnerHawk, "Thinking"), } return m } +func isolateChatCommandSweepEnv(t *testing.T) { + t.Helper() + root := t.TempDir() + storage.SetTestDirs(t, root) + isolateCredentialHome(t) + hawkconfig.InvalidateConfigUICache() + credentials.SetDefaultStore(&credentials.MapStore{}) + t.Cleanup(func() { + credentials.SetDefaultStore(nil) + hawkconfig.InvalidateConfigUICache() + }) +} + func TestChatModel_SlashHelp(t *testing.T) { m := newTestChatModel() result, _ := m.handleCommand("/help") @@ -150,7 +175,6 @@ func TestChatModel_SlashUnknown(t *testing.T) { } func TestChatModel_ManyCommands(t *testing.T) { - t.Skip("flaky: race condition with global state access") // TODO: https://github.com/GrayCodeAI/hawk/issues/26 commands := []string{ "/context", "/env", "/hooks", "/stats", "/compact", "/diff", "/branch", "/vim", @@ -187,11 +211,20 @@ func TestChatModel_ManyCommands(t *testing.T) { for _, cmd := range commands { t.Run(cmd, func(t *testing.T) { + isolateChatCommandSweepEnv(t) m := newTestChatModel() result, _ := m.handleCommand(cmd) if result == nil { t.Errorf("%s returned nil model", cmd) } + cm := requireChatModel(t, result) + if cm.cancel != nil { + cm.cancel() + } + if cm.loopCancel != nil { + cm.loopCancel() + } + time.Sleep(10 * time.Millisecond) }) } } @@ -224,8 +257,7 @@ func TestChatModel_SlashExport(t *testing.T) { } func TestChatModel_StreamingCommands(t *testing.T) { - t.Skip("flaky: race condition with startStream goroutines") // TODO: https://github.com/GrayCodeAI/hawk/issues/27 - // These trigger startStream but progRef is nil-safe so they won't panic + // These trigger startStream; cancel promptly so the test doesn't leak workers. commands := []string{ "/doctor", "/commit", "/review", "/summary", "/security-review", @@ -235,12 +267,18 @@ func TestChatModel_StreamingCommands(t *testing.T) { for _, cmd := range commands { t.Run(cmd, func(t *testing.T) { + isolateChatCommandSweepEnv(t) m := newTestChatModel() m.session.AddUser("some context for the command") result, _ := m.handleCommand(cmd) if result == nil { t.Errorf("%s returned nil model", cmd) } + cm := requireChatModel(t, result) + if cm.cancel != nil { + cm.cancel() + } + time.Sleep(10 * time.Millisecond) }) } } diff --git a/cmd/chat_multiturn_e2e_test.go b/cmd/chat_multiturn_e2e_test.go new file mode 100644 index 00000000..3f7a436b --- /dev/null +++ b/cmd/chat_multiturn_e2e_test.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "context" + "strings" + "testing" + + "github.com/GrayCodeAI/eyrie/credentials" + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/engine" + tea "github.com/charmbracelet/bubbletea" +) + +func configureReadyChatState(t *testing.T) { + t.Helper() + isolateChatCommandSweepEnv(t) + + hawkconfig.InvalidateConfigUICache() + store := &credentials.MapStore{} + credentials.SetDefaultStore(store) + t.Cleanup(func() { + credentials.SetDefaultStore(nil) + hawkconfig.InvalidateConfigUICache() + }) + + ctx := context.Background() + if err := store.Set(ctx, credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890"); err != nil { + t.Fatal(err) + } + if err := hawkconfig.SetActiveProvider(ctx, "openrouter"); err != nil { + t.Fatal(err) + } + if err := hawkconfig.SetActiveModel(ctx, "openrouter/auto"); err != nil { + t.Fatal(err) + } + hawkconfig.InvalidateConfigUICache() + hawkconfig.RefreshConfigCredSnapshot(ctx) +} + +func countMessagesByRole(msgs []displayMsg, role string) int { + count := 0 + for _, msg := range msgs { + if msg.role == role { + count++ + } + } + return count +} + +func lastMessageByRole(msgs []displayMsg, role string) string { + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].role == role { + return msgs[i].content + } + } + return "" +} + +func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { + configureReadyChatState(t) + + m := newTestChatModel() + m.session.SetModel("") + m.session.SetProvider("") + + m.input.SetValue("first question") + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = requireChatModel(t, result) + if !m.waiting { + t.Fatal("first enter should start a waiting chat turn") + } + if got := m.session.Provider(); got != "openrouter" { + t.Fatalf("session provider = %q, want openrouter", got) + } + if got := m.session.Model(); got != "openrouter/auto" { + t.Fatalf("session model = %q, want openrouter/auto", got) + } + if count := countMessagesByRole(m.messages, "user"); count != 1 { + t.Fatalf("user message count after first enter = %d, want 1", count) + } + + m.input.SetValue("second question") + result, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = requireChatModel(t, result) + if len(m.messageQueue) != 1 || m.messageQueue[0] != "second question" { + t.Fatalf("queued messages = %v, want [second question]", m.messageQueue) + } + if got := lastSystemMessage(m.messages); !strings.Contains(got, "Queued: second question") { + t.Fatalf("expected queued message notice, got %q", got) + } + + result, _ = m.Update(streamChunkMsg("I fixed the failing test in auth_test.go")) + m = requireChatModel(t, result) + result, _ = m.Update(usageUpdateMsg{usage: &engine.StreamUsage{PromptTokens: 12, CompletionTokens: 6}}) + m = requireChatModel(t, result) + result, _ = m.Update(streamDoneMsg{}) + m = requireChatModel(t, result) + if !m.waiting { + t.Fatal("queued second message should auto-start after first stream completes") + } + if len(m.messageQueue) != 0 { + t.Fatalf("message queue should be drained, got %v", m.messageQueue) + } + if count := countMessagesByRole(m.messages, "assistant"); count != 1 { + t.Fatalf("assistant message count after first done = %d, want 1", count) + } + if got := lastMessageByRole(m.messages, "user"); got != "second question" { + t.Fatalf("last user message after queue release = %q, want second question", got) + } + if !m.ghostText.Active() { + t.Fatal("assistant completion should populate ghost text suggestion") + } + + result, _ = m.Update(streamChunkMsg("Tests passed after the fix.")) + m = requireChatModel(t, result) + result, _ = m.Update(streamDoneMsg{}) + m = requireChatModel(t, result) + if m.waiting { + t.Fatal("second stream should finish the conversation") + } + if count := countMessagesByRole(m.messages, "user"); count != 2 { + t.Fatalf("final user message count = %d, want 2", count) + } + if count := countMessagesByRole(m.messages, "assistant"); count != 2 { + t.Fatalf("final assistant message count = %d, want 2", count) + } + if m.turnInputTokens != 0 || m.turnOutputTokens != 0 { + t.Fatalf("queued second turn should reset per-turn tokens, got in=%d out=%d", m.turnInputTokens, m.turnOutputTokens) + } + + result, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = requireChatModel(t, result) + if got := m.input.Value(); got != "second question" { + t.Fatalf("first history recall = %q, want second question", got) + } + result, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = requireChatModel(t, result) + if got := m.input.Value(); got != "first question" { + t.Fatalf("second history recall = %q, want first question", got) + } + result, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = requireChatModel(t, result) + if got := m.input.Value(); got != "second question" { + t.Fatalf("history down = %q, want second question", got) + } + result, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = requireChatModel(t, result) + if got := m.input.Value(); got != "" { + t.Fatalf("history should return to draft input, got %q", got) + } + + m.viewDirty = true + m.updateViewportContent() + rendered := m.View() + if !strings.Contains(rendered, "first question") || !strings.Contains(rendered, "Tests passed after the fix.") { + t.Fatalf("rendered chat missing final transcript:\n%s", rendered) + } +} diff --git a/cmd/chat_status_test.go b/cmd/chat_status_test.go index d7b3dc72..7d1c1afc 100644 --- a/cmd/chat_status_test.go +++ b/cmd/chat_status_test.go @@ -194,19 +194,33 @@ func TestWelcomeDockerRunning_States(t *testing.T) { func TestBuildWelcomeMessage_IncludesDockerWhenEnabled(t *testing.T) { running := true - msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, 24, &running) + msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 80, 24, &running) if !strings.Contains(msg, "Docker") { t.Fatalf("expected Docker indicator in welcome, got snippet without it") } } func TestBuildWelcomeMessage_OmitsDockerWhenDisabled(t *testing.T) { - msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, 24, nil) + msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 80, 24, nil) if strings.Contains(msg, "Docker") { t.Fatal("expected no Docker indicator when container mode disabled") } } +func TestContainerFooterLeft_HostModeCopy(t *testing.T) { + sess := &engine.Session{} + bold, dim := containerFooterLeft(chatModel{session: sess, containerEnabled: false}) + if bold != "Host mode:" { + t.Fatalf("bold = %q, want Host mode:", bold) + } + if !strings.Contains(dim, "commands run on your machine") { + t.Fatalf("dim = %q, want host execution hint", dim) + } + if !strings.Contains(dim, "ask before tools") { + t.Fatalf("dim = %q, want approval hint", dim) + } +} + func TestNormalizeModelDisplayName_ShortensSlug(t *testing.T) { got := normalizeModelDisplayName("openrouter/free", "openrouter/free") if got != "free" { @@ -240,7 +254,7 @@ func TestShowWelcomeBanner_WithMessages(t *testing.T) { func TestBuildWelcomeMessage_UsesDisplayVersion(t *testing.T) { SetVersion("dev") - msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, 24, nil) + msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 80, 24, nil) if strings.Contains(msg, "vdev") { t.Fatal("welcome should not show vdev; DisplayVersion should read VERSION file or dev") } diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 7f88ee60..f6b1fd3d 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -260,6 +260,9 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Type == tea.KeyEnter { text := strings.TrimSpace(m.input.Value()) if text != "" { + m.history = append(m.history, text) + m.historyIdx = len(m.history) + m.historyDraft = "" m.messageQueue = append(m.messageQueue, text) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("%s Queued: %s", icons.Mail(), text)}) m.input.Reset() diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index b620a02b..7a928c66 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -57,11 +57,15 @@ func (m *chatModel) rebuildWelcomeCache(blinkClosed bool) { if height <= 0 { height = 24 } - m.welcomeCache = buildWelcomeMessage(m.session, m.sessionID, m.registry, nil, m.settings, blinkClosed, width, height, m.welcomeDockerRunning()) + skillsCount := 0 + if m.pluginRuntime != nil { + skillsCount = len(m.pluginRuntime.SmartSkills) + } + m.welcomeCache = buildWelcomeMessage(m.session, m.sessionID, m.registry, nil, m.settings, skillsCount, blinkClosed, width, height, m.welcomeDockerRunning()) } // buildWelcomeMessage renders the branded inline HAWK welcome block. -func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool.Registry, saved *session.Session, settings hawkconfig.Settings, blinkClosed bool, width, height int, dockerRunning *bool) string { +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 { // Brand orange — used for both the HAWK wordmark and the mascot so // the welcome screen stays on theme. logoC := "\033[38;2;255;94;14m" // brand orange — WELCOME TO + HAWK wordmark @@ -145,6 +149,7 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. setup := hawkconfig.EvaluateSetupCached(context.Background()) needsSetup := setup.NeedsSetup + modeGuidance := welcomeModeGuidance(dockerRunning, tight) if needsSetup { if hint := setup.Hint; hint != "" { b.WriteByte('\n') @@ -152,19 +157,22 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. } } if needsSetup { - quick := "Quick start: /config to connect Hawk · /help for commands" + quick := "Quick start: /config to connect a provider and pick a model · /help for commands" b.WriteByte('\n') b.WriteString(center(len(quick), boldC+quick+rst) + "\n") - example := "After setup, try: explain this repo · fix the failing test · add tests for cmd/eval" + example := "Then ask: explain this repo · fix the failing test · add tests for cmd/eval" if tight { - example = "After setup, try: explain this repo · fix the failing test" + example = "Then ask: explain this repo · fix the failing test" } b.WriteString(center(runewidth.StringWidth(example), dimC+example+rst) + "\n") + if modeGuidance != "" { + b.WriteString(center(runewidth.StringWidth(modeGuidance), dimC+modeGuidance+rst) + "\n") + } } if !needsSetup { - tip := "TIP: /help commands · /model to switch · /config to adjust setup" + tip := "Ready: ask Hawk to inspect, edit, or run code · /config and /help stay available" if tight { - tip = "TIP: /help · /model · /config" + tip = "Ready: ask Hawk to work · /help · /config" } b.WriteByte('\n') b.WriteString(center(len(tip), boldC+tip+rst) + "\n") @@ -177,9 +185,11 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. shortcutsPlain = "PgUp/Dn scroll chat · Up/Dn history · Tab scrollback · /home · /ctx · ctrl+N · ctrl+L" b.WriteString(center(runewidth.StringWidth(shortcutsPlain), dimC+shortcutsPlain+rst) + "\n") } + if modeGuidance != "" { + b.WriteString(center(runewidth.StringWidth(modeGuidance), dimC+modeGuidance+rst) + "\n") + } } - skillsCount := 0 mcpCount := len(settings.MCPServers) + len(mcpServers) agentsOK := hawkconfig.LoadAgentsMD() != "" @@ -210,6 +220,26 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. return b.String() } +func welcomeModeGuidance(dockerRunning *bool, tight bool) string { + switch { + case dockerRunning == nil: + if tight { + return "Host mode runs commands locally · /permissions changes approvals" + } + return "Host mode runs commands on your machine · /permissions changes approvals" + case *dockerRunning: + if tight { + return "Container mode isolates tool execution · /permissions changes approvals" + } + return "Container mode isolates tool execution when available · /permissions changes approvals" + default: + if tight { + return "Docker unavailable, so commands run locally · /permissions changes approvals" + } + return "Docker is unavailable, so Hawk runs commands on your machine · /permissions changes approvals" + } +} + func actLine(saved *session.Session, sessionID string) string { if saved != nil && len(sessionID) >= 8 { return "Resumed session " + sessionID[:8] diff --git a/cmd/container_boot.go b/cmd/container_boot.go index 464d8dba..7175df7f 100644 --- a/cmd/container_boot.go +++ b/cmd/container_boot.go @@ -29,10 +29,13 @@ func shouldUseContainer() bool { if noContainer { return false } + if containerMode { + return true + } if v := strings.TrimSpace(os.Getenv("HAWK_NO_CONTAINER")); v == "1" || strings.EqualFold(v, "true") { return false } - return true + return sandbox.DockerAvailable() } // bootContainerCmd starts the container in the background and sends status diff --git a/cmd/options.go b/cmd/options.go index d8da3cc3..528bb4b7 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -162,20 +162,31 @@ func loadEffectiveSettings() (hawkconfig.Settings, error) { return settings, nil } -func effectiveModelAndProvider(settings hawkconfig.Settings) (string, string) { - selection := runtime.EffectiveSelection(context.Background(), runtime.SelectionOpts{ +func resolveSelection(settings hawkconfig.Settings) runtime.SelectionState { + return runtime.EffectiveSelection(context.Background(), runtime.SelectionOpts{ ProviderOverride: firstNonEmptyTrimmed(provider, settings.Provider), ModelOverride: firstNonEmptyTrimmed(model, settings.Model), }) +} + +func effectiveModelAndProvider(settings hawkconfig.Settings) (string, string) { + selection := resolveSelection(settings) return selection.Model, selection.Provider } +func newHawkSessionFromSelection(selection runtime.SelectionState, systemPrompt string, registry *tool.Registry) *engine.Session { + return engine.NewHawkSession(context.Background(), selection, selection.Provider, selection.Model, systemPrompt, registry) +} + func newHawkSession(settings hawkconfig.Settings, effectiveProvider, effectiveModel, systemPrompt string, registry *tool.Registry) *engine.Session { - selection := runtime.EffectiveSelection(context.Background(), runtime.SelectionOpts{ - ProviderOverride: firstNonEmptyTrimmed(provider, settings.Provider), - ModelOverride: firstNonEmptyTrimmed(model, settings.Model), - }) - return engine.NewHawkSession(context.Background(), selection, effectiveProvider, effectiveModel, systemPrompt, registry) + selection := resolveSelection(settings) + if strings.TrimSpace(selection.Provider) == "" { + selection.Provider = effectiveProvider + } + if strings.TrimSpace(selection.Model) == "" { + selection.Model = effectiveModel + } + return newHawkSessionFromSelection(selection, systemPrompt, registry) } func firstNonEmptyTrimmed(values ...string) string { @@ -212,7 +223,6 @@ func configureSession(sess *engine.Session, settings hawkconfig.Settings, maxTur sess.SetAPIKey(providerName, key) } } - sess.SetAPIKeys(hawkconfig.LoadAPIKeysFromStore()) for _, spec := range settings.AutoAllow { sess.PermSvc().Memory().AllowSpec(spec) diff --git a/cmd/statusbar.go b/cmd/statusbar.go index 9b8aa63e..22d5b4f7 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -183,7 +183,7 @@ func renderContainerFooterDetail(detail string, sess *engine.Session) string { // containerFooterLeft is the bold + dim text on the top footer row (left side). func containerFooterLeft(m chatModel) (bold, dim string) { if !m.containerEnabled { - return permissionModeLabel(m.session), permissionModeHint(m.session) + return "Host mode:", hostModeHint(m.session) } bold = "Container:" if m.containerErr != nil { @@ -203,41 +203,40 @@ func containerFooterLeft(m chatModel) (bold, dim string) { return bold, " starting…" } -// permissionModeLabel returns the display label for the current permission mode. -func permissionModeLabel(sess *engine.Session) string { +func hostModeHint(sess *engine.Session) string { if sess == nil || sess.Perm == nil { - return "Default" + return " commands run on your machine · ask before tools" } switch sess.Perm.Mode { case engine.PermissionModeBypassPermissions: - return "Bypass (All Allowed)" + return " commands run on your machine · tools auto-approved" case engine.PermissionModeAcceptEdits: - return "Auto (Edits Allowed)" + return " commands run on your machine · auto-approve edits" case engine.PermissionModeDontAsk: - return "Deny (All Blocked)" + return " commands run on your machine · tools blocked" case engine.PermissionModePlan: - return "Plan (Read Only)" + return " commands run on your machine · read-only exploration" default: - return "Default" + return " commands run on your machine · ask before tools" } } -// permissionModeHint returns a short description for the current permission mode. -func permissionModeHint(sess *engine.Session) string { +// permissionModeLabel returns the display label for the current permission mode. +func permissionModeLabel(sess *engine.Session) string { if sess == nil || sess.Perm == nil { - return " - tools require approval" + return "Default" } switch sess.Perm.Mode { case engine.PermissionModeBypassPermissions: - return " - all tools auto-approved" + return "Bypass (All Allowed)" case engine.PermissionModeAcceptEdits: - return " - file edits auto-approved" + return "Auto (Edits Allowed)" case engine.PermissionModeDontAsk: - return " - all tools blocked" + return "Deny (All Blocked)" case engine.PermissionModePlan: - return " - read-only exploration" + return "Plan (Read Only)" default: - return " - tools require approval" + return "Default" } } diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go index ff4ac086..73aa4c38 100644 --- a/cmd/welcome_inline_test.go +++ b/cmd/welcome_inline_test.go @@ -11,7 +11,7 @@ import ( ) func TestBuildWelcomeMessage_InlineShowsSetupGuidance(t *testing.T) { - out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 100, 24, nil) + out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 100, 24, nil) if !strings.Contains(out, "v") { t.Fatalf("inline welcome should show version, got:\n%s", out) } @@ -21,7 +21,7 @@ func TestBuildWelcomeMessage_InlineShowsSetupGuidance(t *testing.T) { } func TestBuildWelcomeMessage_InlineShowsStarterPrompts(t *testing.T) { - out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 100, 24, nil) + out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 100, 24, nil) // Assert on copy present in both the needs-setup and ready branches so the // test is deterministic regardless of the host machine's /config state. for _, want := range []string{ @@ -29,6 +29,7 @@ func TestBuildWelcomeMessage_InlineShowsStarterPrompts(t *testing.T) { "fix the failing test", "/help", "/config", + "/permissions", } { if !strings.Contains(out, want) { t.Fatalf("inline welcome missing %q in:\n%s", want, out) @@ -43,7 +44,7 @@ func TestFixedWelcomeLineCount_ReservesInlineHeaderSpace(t *testing.T) { m := chatModel{ width: 100, height: 30, - welcomeCache: buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 100, 24, nil), + welcomeCache: buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 100, 24, nil), input: ta, viewport: viewport.New(100, 8), } @@ -53,11 +54,14 @@ func TestFixedWelcomeLineCount_ReservesInlineHeaderSpace(t *testing.T) { } func TestBuildWelcomeMessage_ShortTerminalUsesCompactCopy(t *testing.T) { - out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 72, 20, nil) + out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 0, false, 72, 20, nil) if strings.Contains(out, "PgUp/Dn scroll chat") { t.Fatalf("compact welcome should drop the long shortcuts row, got:\n%s", out) } if !strings.Contains(out, "explain this repo") { t.Fatalf("compact welcome should keep starter prompt guidance, got:\n%s", out) } + if !strings.Contains(out, "Host mode runs commands locally") { + t.Fatalf("compact welcome should explain host mode, got:\n%s", out) + } } diff --git a/external/eyrie b/external/eyrie index 036043c8..ad4fa364 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit 036043c8e99cd5e398ad8d86275c87ec4085ff55 +Subproject commit ad4fa3648ea15317241b2e8ac1ea7a81e18b5ccd diff --git a/internal/engine/session.go b/internal/engine/session.go index 53ec7bd7..cf396baa 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -246,7 +246,7 @@ func NewSession(provider, model, systemPrompt string, registry *tool.Registry) * // NewSessionWithClient constructs a session with an explicit LLM client (e.g. deployment router). func NewSessionWithClient(chat ChatClient, provider, model, systemPrompt string, registry *tool.Registry, deploymentRouting bool) *Session { if provider == "" || model == "" { - slog.Warn("NewSessionWithClient called with empty provider or model; runtime errors may follow", "provider", provider, "model", model) + slog.Debug("NewSessionWithClient called with empty provider or model", "provider", provider, "model", model) } pe := NewPermissionEngine() log := logger.Default() diff --git a/internal/engine/session_factory.go b/internal/engine/session_factory.go index dd9cbe71..53aa6f40 100644 --- a/internal/engine/session_factory.go +++ b/internal/engine/session_factory.go @@ -12,23 +12,17 @@ import ( "github.com/GrayCodeAI/hawk/internal/types" ) -func transportBoolPtr(v bool) *bool { return &v } - // BuildChatClient returns an LLM client and whether deployment routing is active. func BuildChatClient(ctx context.Context, selection runtime.SelectionState, legacyProvider string) (ChatClient, string, bool, error) { provider := strings.TrimSpace(selection.Provider) if provider == "" { provider = legacyProvider } - transport, err := runtime.ResolveChatTransport(ctx, runtime.ChatTransportOpts{ - Selection: runtime.SelectionOpts{ - ProviderOverride: provider, - ModelOverride: selection.Model, - // Preserve the engine-resolved routing decision. Transport - // construction itself remains in Eyrie. - DeploymentRoutingOverride: transportBoolPtr(selection.DeploymentRouting), - }, - }) + resolvedSelection := selection + if strings.TrimSpace(resolvedSelection.Provider) == "" { + resolvedSelection.Provider = provider + } + transport, err := runtime.ResolveChatTransportFromSelection(ctx, resolvedSelection) if err == nil && transport.Provider != nil { label := strings.TrimSpace(transport.Selection.Provider) if label == "" { diff --git a/internal/sandbox/container_test.go b/internal/sandbox/container_test.go index 43411f1d..660cb6fb 100644 --- a/internal/sandbox/container_test.go +++ b/internal/sandbox/container_test.go @@ -3,6 +3,7 @@ package sandbox import ( "os" "path/filepath" + "sync/atomic" "testing" "github.com/GrayCodeAI/hawk/internal/storage" @@ -13,6 +14,27 @@ func TestDockerAvailable(t *testing.T) { _ = DockerAvailable() } +func TestDockerAvailable_UsesShortLivedCache(t *testing.T) { + resetDockerAvailabilityCache() + t.Cleanup(resetDockerAvailabilityCache) + + var calls atomic.Int32 + dockerAvailabilityProbe = func() bool { + calls.Add(1) + return true + } + + if !DockerAvailable() { + t.Fatal("expected cached probe to return true") + } + if !DockerAvailable() { + t.Fatal("expected second cached probe to return true") + } + if got := calls.Load(); got != 1 { + t.Fatalf("docker availability probe calls = %d, want 1", 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 68a604c3..58ed9ebc 100644 --- a/internal/sandbox/selector.go +++ b/internal/sandbox/selector.go @@ -4,6 +4,8 @@ import ( "context" "os/exec" "runtime" + "sync" + "time" ) // IsolationLevel represents the desired sandbox strength. @@ -94,9 +96,37 @@ func bwrapAvailable() bool { return err == nil } +const dockerAvailabilityTTL = 2 * time.Second + +var ( + dockerAvailabilityMu sync.Mutex + dockerAvailabilityChecked time.Time + dockerAvailabilityCached bool + dockerAvailabilityProbe = probeDockerAvailable +) + func dockerAvailable() bool { + dockerAvailabilityMu.Lock() + defer dockerAvailabilityMu.Unlock() + if !dockerAvailabilityChecked.IsZero() && time.Since(dockerAvailabilityChecked) < dockerAvailabilityTTL { + return dockerAvailabilityCached + } + dockerAvailabilityCached = dockerAvailabilityProbe() + dockerAvailabilityChecked = time.Now() + return dockerAvailabilityCached +} + +func probeDockerAvailable() bool { cmd := exec.CommandContext(context.Background(), "docker", "info") cmd.Stdout = nil cmd.Stderr = nil return cmd.Run() == nil } + +func resetDockerAvailabilityCache() { + dockerAvailabilityMu.Lock() + defer dockerAvailabilityMu.Unlock() + dockerAvailabilityChecked = time.Time{} + dockerAvailabilityCached = false + dockerAvailabilityProbe = probeDockerAvailable +} diff --git a/internal/startup/startup.go b/internal/startup/startup.go index 4d68df1c..188522e6 100644 --- a/internal/startup/startup.go +++ b/internal/startup/startup.go @@ -18,6 +18,13 @@ var ( start = time.Now() ) +func Reset() { + phasesMu.Lock() + defer phasesMu.Unlock() + phases = nil + start = time.Now() +} + func MarkPhase(name string) { phasesMu.Lock() defer phasesMu.Unlock()