diff --git a/cmd/chat.go b/cmd/chat.go index d065befb..754c1a29 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -48,6 +48,8 @@ import ( // Tool-registry construction (essential/optional tools) is in chat_tools.go // The Bubble Tea event loop (Update, applyPromptArrowKey) is in chat_update.go +const workInputPlaceholder = `Ask Hawk to inspect, edit, or run something... (Shift+Enter for newline)` + func genID() string { b := make([]byte, 8) _, _ = cryptorand.Read(b) @@ -181,7 +183,6 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting m.connStatusVal = m.buildConnectionStatusPlain() m.connStatusKey = m.connStatusFingerprint() } - m.phase = initialUIPhase(m.hasChatMessages(), promptFlag != "") m.invalidateInputLayoutCache() (&m).refreshInputLayoutIfNeeded() m = m.syncViewportMouseWheel().withSyncedLayout() @@ -298,13 +299,8 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting ok := sandbox.DockerAvailable() dockerRunning = &ok } - m.welcomeCache = buildWelcomeMessage(sess, sid, registry, saved, settings, false, initWidth, dockerRunning, m.phase == phaseWelcomeGate) - // Welcome scrollback only when skipping the gate (resume / -p). Gate users already saw the splash. - if m.phase == phaseWork { - m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) - } - m.openConfigOnStart = hawkconfig.NeedsFirstRunSetup(context.Background()) && - (saved == nil || len(saved.Messages) == 0) + m.welcomeCache = buildWelcomeMessage(sess, sid, registry, saved, settings, false, initWidth, initHeight, dockerRunning) + m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) // Wire permission system sess.PermSvc().SetPermissionFn(func(req engine.PermissionRequest) { @@ -419,12 +415,7 @@ func (m chatModel) Init() tea.Cmd { cwd, _ := os.Getwd() cmds = append(cmds, bootContainerCmd(cwd)) } - if m.phase == phaseWork { - cmds = append(cmds, m.input.Focus()) - } - if m.phase == phaseWork && m.openConfigOnStart { - cmds = append(cmds, func() tea.Msg { return autoOpenConfigMsg{} }) - } + cmds = append(cmds, m.input.Focus()) return tea.Batch(cmds...) } diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index 7fe70f50..38414b4f 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -137,7 +137,7 @@ var slashDescriptions = map[string]string{ "/explain": "Trace code back to the commit that created it", "/export": "Export session", "/follow": "Toggle stream follow (auto-scroll)", - "/home": "Scroll to welcome screen", + "/home": "Jump to top of chat and welcome header", "/feedback": "Submit feedback about hawk", "/fast": "Toggle fast mode", "/files": "Show modified files", @@ -188,7 +188,7 @@ var slashDescriptions = map[string]string{ "/usage": "Show cost summary", "/version": "Show hawk version", "/vim": "Toggle vim mode", - "/welcome": "Show welcome screen", + "/welcome": "Re-print the welcome header", "/ecosystem": "Show eyrie, yaad, and tok integration status", "/path": "Developer path readiness (setup, security, sandbox)", "/yaad": "Show yaad memory (use /yaad search to search)", diff --git a/cmd/chat_config_hub.go b/cmd/chat_config_hub.go index 64f6a2a5..e35cf61f 100644 --- a/cmd/chat_config_hub.go +++ b/cmd/chat_config_hub.go @@ -8,8 +8,6 @@ import ( hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) -type autoOpenConfigMsg struct{} - func (m chatModel) openConfigPanel() (chatModel, tea.Cmd) { return m.openConfigAtTab(-1) } diff --git a/cmd/chat_focus.go b/cmd/chat_focus.go index 3982ad8b..19701141 100644 --- a/cmd/chat_focus.go +++ b/cmd/chat_focus.go @@ -104,7 +104,7 @@ func formatSessionContextUsage(m *chatModel) string { } else { b.WriteString("Stream follow: off (/follow on)\n") } - b.WriteString("Tab: prompt ↔ scrollback · /home: welcome · /export: save transcript") + b.WriteString("Tab: prompt ↔ scrollback · /home: top of chat · /export: save transcript") if pct >= engine.DefaultAutoCompactThresholdPct { b.WriteString("\n" + icons.Alert() + " Approaching auto-compact threshold — consider /compact") } diff --git a/cmd/chat_layout.go b/cmd/chat_layout.go index 00a79ba7..0cd54ce3 100644 --- a/cmd/chat_layout.go +++ b/cmd/chat_layout.go @@ -8,7 +8,7 @@ const minChatViewportLines = 4 // fixedWelcomeLineCount reserves room for the branded welcome pane above chat. func (m chatModel) fixedWelcomeLineCount() int { - if strings.TrimSpace(m.welcomeCache) == "" || m.onWelcomeGate() { + if strings.TrimSpace(m.welcomeCache) == "" { return 0 } lines := strings.Split(strings.TrimRight(m.welcomeCache, "\n"), "\n") @@ -48,9 +48,6 @@ func (m chatModel) withSyncedLayout() chatModel { welcomeH := m.fixedWelcomeLineCount() // View() draws welcome text then a newline; the next row is the first chat line. vpH := m.height - bottomH - welcomeH - if m.onWelcomeGate() { - vpH = minChatViewportLines - } if vpH < minChatViewportLines { vpH = minChatViewportLines } diff --git a/cmd/chat_layout_mouse_test.go b/cmd/chat_layout_mouse_test.go index 2ac6dc85..5aa92bc8 100644 --- a/cmd/chat_layout_mouse_test.go +++ b/cmd/chat_layout_mouse_test.go @@ -18,7 +18,6 @@ func TestView_LineCountMatchesHeight(t *testing.T) { input: textarea.New(), viewport: viewport.New(80, 8), ghostText: NewGhostText(), - phase: phaseWork, } m = m.withSyncedLayout() got := m.View() @@ -57,7 +56,6 @@ func TestMouseWheelDelta_SGRUsesZeroBasedY(t *testing.T) { height: 24, width: 80, uiFocus: focusPrompt, - phase: phaseWork, } m = m.withSyncedLayout() before := m.viewport.YOffset diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 829545ec..73274cfb 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -215,10 +215,6 @@ type chatModel struct { sessionStartedAt time.Time // whole chat session (footer duration) toolStartTime time.Time welcomeCache string - welcomeDismissed bool - phase uiPhase - sandboxReadyPending bool // defer sandbox system line until after welcome gate - openConfigOnStart bool // first-run: open /config after welcome gate (Enter) viewDirty bool layoutKey int // input lines + slash menu height fingerprint cachedBottomBarLines int // memoized chatBottomBarLines; refresh via refreshInputLayoutIfNeeded diff --git a/cmd/chat_mouse_scroll_test.go b/cmd/chat_mouse_scroll_test.go index dfa8e60e..3cf19831 100644 --- a/cmd/chat_mouse_scroll_test.go +++ b/cmd/chat_mouse_scroll_test.go @@ -24,7 +24,6 @@ func runMouseScrollSplitPanePass(t *testing.T, pass int) { height: 24, width: 80, uiFocus: focusPrompt, - phase: phaseWork, } m = m.syncViewportMouseWheel().withSyncedLayout() before := m.viewport.YOffset @@ -83,7 +82,6 @@ func TestUpdate_MouseMotionDoesNotReflowLayout(t *testing.T) { height: 24, width: 80, uiFocus: focusPrompt, - phase: phaseWork, cachedBottomBarLines: 10, layoutKey: 65536, } @@ -112,7 +110,6 @@ func TestUpdate_InputHistoryWhileWaiting(t *testing.T) { height: 24, width: 80, uiFocus: focusPrompt, - phase: phaseWork, waiting: true, history: []string{"first", "second"}, } diff --git a/cmd/chat_status_test.go b/cmd/chat_status_test.go index f014695c..9e0b93e6 100644 --- a/cmd/chat_status_test.go +++ b/cmd/chat_status_test.go @@ -191,14 +191,14 @@ func TestWelcomeDockerRunning_States(t *testing.T) { func TestBuildWelcomeMessage_IncludesDockerWhenEnabled(t *testing.T) { running := true - msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, &running, false) + msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 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, nil, false) + msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, 24, nil) if strings.Contains(msg, "Docker") { t.Fatal("expected no Docker indicator when container mode disabled") } @@ -237,7 +237,7 @@ func TestShowWelcomeBanner_WithMessages(t *testing.T) { func TestBuildWelcomeMessage_UsesDisplayVersion(t *testing.T) { SetVersion("dev") - msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, nil, false) + msg := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 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_subcommand_welcome.go b/cmd/chat_subcommand_welcome.go index e23913b0..2e490889 100644 --- a/cmd/chat_subcommand_welcome.go +++ b/cmd/chat_subcommand_welcome.go @@ -5,12 +5,12 @@ import ( ) // welcomeSubcommand implements the /welcome slash command. It -// re-prints the startup welcome message. +// re-prints the inline welcome header at the top of chat. type welcomeSubcommand struct{} func (w *welcomeSubcommand) Name() string { return "welcome" } func (w *welcomeSubcommand) Aliases() []string { return nil } -func (w *welcomeSubcommand) Description() string { return "re-print the startup welcome message" } +func (w *welcomeSubcommand) Description() string { return "re-print the welcome header" } func (w *welcomeSubcommand) Usage() string { return "" } func (w *welcomeSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { m.messages = append(m.messages, displayMsg{role: "welcome", content: m.welcomeCache}) diff --git a/cmd/chat_update.go b/cmd/chat_update.go index c993ddab..7f88ee60 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -97,12 +97,6 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) - case autoOpenConfigMsg: - if !m.openConfigOnStart || m.configOpen { - return m, nil - } - m.openConfigOnStart = false - return m.openConfigPanel() case tea.KeyMsg: // Ctrl+\ enters native terminal selection mode. Available in every UI // state (welcome gate, permissions, prompt, scrollback) so users always @@ -128,10 +122,6 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - if next, cmd, handled := m.handleWelcomeGateKey(msg); handled { - return next, cmd - } - // Command palette (Ctrl+K) — intercept all input when open if m.commandPalette != nil && m.commandPalette.IsOpen() { action, handled := m.commandPalette.Update(msg) @@ -693,9 +683,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - if !m.onWelcomeGate() { - m.input.SetWidth(msg.Width - 4) - } + m.input.SetWidth(msg.Width - 4) m.invalidateInputLayoutCache() m.rebuildWelcomeCache(false) m.viewDirty = true @@ -735,11 +723,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.session.PermSvc().Autonomy() == 0 { m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy) } - if m.phase == phaseWelcomeGate { - m.sandboxReadyPending = true - } else { - m.messages = append(m.messages, displayMsg{role: "system", content: formatSandboxReadyAutonomyMessage(m.session.PermSvc().Autonomy())}) - } + m.messages = append(m.messages, displayMsg{role: "system", content: formatSandboxReadyAutonomyMessage(m.session.PermSvc().Autonomy())}) m.invalidateConnStatus() } if msg.err != nil { diff --git a/cmd/chat_view.go b/cmd/chat_view.go index cb48ed2d..1a68473c 100644 --- a/cmd/chat_view.go +++ b/cmd/chat_view.go @@ -19,16 +19,6 @@ func (m chatModel) showWelcomeBanner() bool { return strings.TrimSpace(m.welcomeCache) != "" } -func (m chatModel) hasChatMessages() bool { - for _, msg := range m.messages { - switch msg.role { - case "user", "assistant", "tool_use", "tool_result": - return true - } - } - return false -} - func renderSetupCompleteMessage(model string) string { success := lipgloss.NewStyle().Foreground(doneGreen).Bold(true).Inline(true) muted := configMutedStyle().Inline(true) @@ -202,7 +192,7 @@ func wrapText(text string, width int, prefixWidth int) string { // chatBottomBarLines counts fixed rows below the chat viewport (must stay in sync with View). func (m chatModel) chatBottomBarLines() int { - if m.onWelcomeGate() || m.configOpen { + if m.configOpen { return 0 } if m.cachedBottomBarLines > 0 { @@ -247,11 +237,7 @@ func (m *chatModel) updateViewportContent() { } m.viewDirty = false - if m.onWelcomeGate() { - return - } - - // /config overlay: config panel only (welcome was on the gate, not repeated here). + // /config overlay: config panel only. if m.configOpen { var content strings.Builder content.WriteString(m.configPanelView()) @@ -285,24 +271,13 @@ func (m chatModel) View() string { m = m.withSyncedLayout() viewWidth := m.width - viewHeight := m.height if viewWidth <= 0 { - if w, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { + if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { viewWidth = w - if h > 0 { - viewHeight = h - } } else { viewWidth = 80 } } - if viewHeight <= 0 { - viewHeight = 24 - } - - if m.onWelcomeGate() { - return m.renderWelcomeGate(viewWidth, viewHeight) - } // Build the fixed bottom bar var bottomBar strings.Builder diff --git a/cmd/chat_viewport.go b/cmd/chat_viewport.go index 3fbc3d67..6824982d 100644 --- a/cmd/chat_viewport.go +++ b/cmd/chat_viewport.go @@ -223,7 +223,7 @@ func (m chatModel) shouldRouteMouseToViewport(msg tea.Msg) bool { if !tea.MouseEvent(mouse).IsWheel() { return m.inScrollbackFocus() } - if m.configOpen || m.onWelcomeGate() { + if m.configOpen { return false } if m.inScrollbackFocus() { diff --git a/cmd/chat_viewport_test.go b/cmd/chat_viewport_test.go index f7ecf6d2..b25beda7 100644 --- a/cmd/chat_viewport_test.go +++ b/cmd/chat_viewport_test.go @@ -86,7 +86,7 @@ func TestShouldRouteMouseToViewport_SplitPaneUX(t *testing.T) { func TestSyncViewportMouseWheel_ManualRouting(t *testing.T) { t.Setenv("HAWK_MOUSE", "") vp := viewport.New(80, 10) - m := chatModel{viewport: vp, uiFocus: focusPrompt, phase: phaseWork} + m := chatModel{viewport: vp, uiFocus: focusPrompt} m = m.syncViewportMouseWheel() if m.viewport.MouseWheelEnabled { t.Fatal("viewport auto-wheel must stay off; hawk routes wheel by pane") @@ -97,7 +97,7 @@ func TestSyncViewportMouseWheel_DisabledWithOptOut(t *testing.T) { t.Setenv("HAWK_MOUSE", "0") vp := viewport.New(80, 10) disabled := false - m := chatModel{viewport: vp, uiFocus: focusPrompt, phase: phaseWork, settings: hawkconfig.Settings{TuiMouse: &disabled}} + m := chatModel{viewport: vp, uiFocus: focusPrompt, settings: hawkconfig.Settings{TuiMouse: &disabled}} m = m.syncViewportMouseWheel() if m.viewport.MouseWheelEnabled { t.Fatal("wheel should be disabled when mouse capture is off") diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index 25ea09ce..b620a02b 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -49,26 +49,23 @@ func (m chatModel) welcomeDockerRunning() *bool { } func (m *chatModel) rebuildWelcomeCache(blinkClosed bool) { - if m.welcomeDismissed && !m.onWelcomeGate() { - m.welcomeCache = "" - return - } width := m.width if width <= 0 { width = 80 } - m.welcomeCache = buildWelcomeMessage(m.session, m.sessionID, m.registry, nil, m.settings, blinkClosed, width, m.welcomeDockerRunning(), m.onWelcomeGate()) + height := m.height + if height <= 0 { + height = 24 + } + m.welcomeCache = buildWelcomeMessage(m.session, m.sessionID, m.registry, nil, m.settings, blinkClosed, width, height, m.welcomeDockerRunning()) } -// buildWelcomeMessage renders the branded HAWK welcome block (logo, mascot, tips, status). -// forGate omits the version line and uses a tighter layout for the splash screen. -func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool.Registry, saved *session.Session, settings hawkconfig.Settings, blinkClosed bool, width int, dockerRunning *bool, forGate bool) string { +// 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 { // 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 mascotC := "\033[38;2;255;94;14m" - mutedC := "\033[38;2;158;158;158m" // textMuted — labels - bodyC := "\033[38;2;240;240;240m" // textPrimary — chip counts dimC := "\033[2m" boldC := "\033[1m" // Indicator colors — same as the rest of the TUI palette (success @@ -85,12 +82,17 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. // neutral mark for "none" avoids the alarming all-red look on a fresh repo. markPresent := greenC + "+" + icons.CheckBold() + " " + rst markNone := sepC + "○" + rst - markErr := redC + "×" + rst totalW := width if totalW < 40 { totalW = 80 } + totalH := height + if totalH <= 0 { + totalH = 24 + } + compact := totalH <= 24 || totalW < 88 + tight := totalH <= 20 || totalW < 72 center := func(visW int, styled string) string { if visW <= 0 { @@ -115,32 +117,12 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. mascot[1] = " ▄█ ─ ─ █▄ " } - showMascot := totalW >= 60 + showMascot := totalW >= 60 && !tight var b strings.Builder - if forGate { - if totalW >= welcomeToPhraseMinWidth { - for _, line := range welcomeToPhraseLines { - vis := runewidth.StringWidth(line) - b.WriteString(center(vis, logoC+line+rst) + "\n") - } - } else if totalW >= welcomeToBannerMinWidth { - for _, line := range welcomeWordLines { - vis := runewidth.StringWidth(line) - b.WriteString(center(vis, logoC+line+rst) + "\n") - } - b.WriteString("\n") - for _, line := range welcomeToWordLines { - vis := runewidth.StringWidth(line) - b.WriteString(center(vis, logoC+line+rst) + "\n") - } - } else { - fallback := "WELCOME TO" - b.WriteString(center(len(fallback), logoC+fallback+rst) + "\n") - } - b.WriteString("\n") - } + // Top breathing room so the wordmark isn't flush against the terminal edge. + b.WriteString("\n\n") for i := 0; i < len(art); i++ { line := art[i] @@ -157,43 +139,44 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. b.WriteString(center(visW, combined) + "\n") } - if forGate { - if modelName, providerName := effectiveModelAndProvider(settings); modelName != "" { - var plainParts, styledParts []string - if providerName != "" { - plainParts = append(plainParts, providerName) - styledParts = append(styledParts, mutedC+providerName+rst) - } - short := normalizeModelDisplayName(modelName, modelName) - plainParts = append(plainParts, short) - styledParts = append(styledParts, bodyC+short+rst) - mode := permissionModeLabel(sess) - plainParts = append(plainParts, mode) - styledParts = append(styledParts, mutedC+mode+rst) - - sep := sepC + " · " + rst - plain := strings.Join(plainParts, " · ") - b.WriteString("\n" + center(len(plain), strings.Join(styledParts, sep)) + "\n") - } - } - - if !forGate { - verLine := fmt.Sprintf("v%s", DisplayVersion()) - b.WriteString("\n" + center(len(verLine), dimC+verLine+rst) + "\n") - } + verLine := fmt.Sprintf("v%s", DisplayVersion()) + b.WriteByte('\n') + b.WriteString(center(len(verLine), dimC+verLine+rst) + "\n") setup := hawkconfig.EvaluateSetupCached(context.Background()) needsSetup := setup.NeedsSetup if needsSetup { - if hint := setup.Hint; hint != "" && !forGate { - b.WriteString("\n" + center(len(hint), amberC+hint+rst) + "\n") + if hint := setup.Hint; hint != "" { + b.WriteByte('\n') + b.WriteString(center(len(hint), amberC+hint+rst) + "\n") + } + } + if needsSetup { + quick := "Quick start: /config to connect Hawk · /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" + if tight { + example = "After setup, try: explain this repo · fix the failing test" } + b.WriteString(center(runewidth.StringWidth(example), dimC+example+rst) + "\n") } - if !forGate && !needsSetup { - tip := "TIP: /help commands · /model to switch" - b.WriteString("\n" + center(len(tip), boldC+tip+rst) + "\n") - shortcutsPlain := "PgUp/Dn scroll chat · Up/Dn history · Tab scrollback · /home · /ctx · ctrl+N · ctrl+L" + if !needsSetup { + tip := "TIP: /help commands · /model to switch · /config to adjust setup" + if tight { + tip = "TIP: /help · /model · /config" + } + b.WriteByte('\n') + b.WriteString(center(len(tip), boldC+tip+rst) + "\n") + shortcutsPlain := "Try: explain this repo · fix the failing test · add tests for cmd/eval" + if tight { + shortcutsPlain = "Try: explain this repo · fix the failing test" + } b.WriteString(center(runewidth.StringWidth(shortcutsPlain), dimC+shortcutsPlain+rst) + "\n") + if !compact { + 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") + } } skillsCount := 0 @@ -210,45 +193,14 @@ func buildWelcomeMessage(sess *engine.Session, sessionID string, registry *tool. mcpMark := mark(mcpCount > 0) hawkMark := mark(agentsOK) - gateChip := func(label string, count int) string { - return mark(count > 0) + " " + mutedC + label + rst + " " + mutedC + "(" + rst + bodyC + fmt.Sprintf("%d", count) + rst + mutedC + ")" + rst - } - gateChipPlain := func(label string, count int) string { - return "x " + label + " (" + fmt.Sprintf("%d", count) + ")" - } - if forGate { - chipSep := sepC + " · " + rst - agentsChip := mark(agentsOK) + " " + mutedC + "AGENTS" + rst - parts := []string{ - gateChip("Skills", skillsCount), - gateChip("MCP", mcpCount), - agentsChip, - } - plain := []string{ - gateChipPlain("Skills", skillsCount), - gateChipPlain("MCP", mcpCount), - "x AGENTS", - } - if dockerRunning != nil { - dockerMark := markErr - if *dockerRunning { - dockerMark = markPresent - } - parts = append(parts, dockerMark+" "+mutedC+"Docker"+rst) - plain = append(plain, "x Docker") - } - indicators := strings.Join(parts, chipSep) - indVis := strings.Join(plain, " · ") - b.WriteString("\n" + center(len(indVis), indicators) + "\n") - } else { - indicators := fmt.Sprintf("Skills (%d) %s MCPs (%d) %s AGENTS.md %s", skillsCount, skillMark, mcpCount, mcpMark, hawkMark) - indVis := fmt.Sprintf("Skills (%d) x MCPs (%d) x AGENTS.md x", skillsCount, mcpCount) - if dockerSeg, _ := welcomeDockerSegment(dockerRunning, greenC, redC, rst); dockerSeg != "" { - indicators += dockerSeg - indVis += " Docker x" - } - b.WriteString("\n" + center(len(indVis), indicators) + "\n") + indicators := fmt.Sprintf("Skills (%d) %s MCPs (%d) %s AGENTS.md %s", skillsCount, skillMark, mcpCount, mcpMark, hawkMark) + indVis := fmt.Sprintf("Skills (%d) x MCPs (%d) x AGENTS.md x", skillsCount, mcpCount) + if dockerSeg, _ := welcomeDockerSegment(dockerRunning, greenC, redC, rst); dockerSeg != "" { + indicators += dockerSeg + indVis += " Docker x" } + b.WriteByte('\n') + b.WriteString(center(len(indVis), indicators) + "\n") if resume := actLine(saved, sessionID); resume != "" { b.WriteString("\n") diff --git a/cmd/welcome_banner.go b/cmd/welcome_banner.go index b06b1ac1..067fa687 100644 --- a/cmd/welcome_banner.go +++ b/cmd/welcome_banner.go @@ -9,7 +9,7 @@ import ( // hawkBlockGlyphs — fixed 8-column ██ font (H/A/W/K match hawkLogoArtLines). var hawkBlockGlyphs = map[rune][5]string{ 'H': {"██ ██ ", "██ ██ ", "███████ ", "██ ██ ", "██ ██ "}, - 'A': {" █████ ", "██ ██ ", "███████ ", "██ ██ ", "██ ██ "}, + 'A': {" ███ ", " █████ ", "███████ ", "██ ██ ", "██ ██ "}, 'W': {"██ ██ ", "██ ██ ", "██ █ ██ ", "███ ███ ", "██ ██ "}, 'K': {"██ ██ ", "██ ██ ", "█████ ", "██ ██ ", "██ ██ "}, 'E': {"███████ ", "██ ", "███████ ", "██ ", "███████ "}, @@ -22,8 +22,8 @@ var hawkBlockGlyphs = map[rune][5]string{ // hawkLogoArtLines is the canonical HAWK wordmark. var hawkLogoArtLines = []string{ - "██ ██ █████ ██ ██ ██ ██", - "██ ██ ██ ██ ██ ██ ██ ██ ", + "██ ██ ███ ██ ██ ██ ██", + "██ ██ █████ ██ ██ ██ ██ ", "███████ ███████ ██ █ ██ █████ ", "██ ██ ██ ██ ██ ███ ██ ██ ██ ", "██ ██ ██ ██ ███ ███ ██ ██", diff --git a/cmd/welcome_gate.go b/cmd/welcome_gate.go deleted file mode 100644 index bff112bf..00000000 --- a/cmd/welcome_gate.go +++ /dev/null @@ -1,249 +0,0 @@ -package cmd - -import ( - "context" - "os" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - hawkconfig "github.com/GrayCodeAI/hawk/internal/config" - "github.com/GrayCodeAI/hawk/internal/ui/icons" -) - -type uiPhase int - -const ( - phaseWork uiPhase = iota - phaseWelcomeGate -) - -const workInputPlaceholder = `Try "Create a PR with these changes" (Shift+Enter for newline)` - -const ( - welcomeGateMinTopPad = 2 - welcomeGateMaxTopPad = 8 - welcomeGateClusterGap = 2 // blank lines between status chips and action row -) - -// initialUIPhase picks welcome gate vs work from how hawk was launched. -func initialUIPhase(hasChat bool, oneShotPrompt bool) uiPhase { - if oneShotPrompt || hasChat { - return phaseWork - } - return phaseWelcomeGate -} - -func (m chatModel) onWelcomeGate() bool { - return m.phase == phaseWelcomeGate && !m.configOpen && !m.quitting -} - -func (m chatModel) gateActionHint() (primary string, needsSetup bool) { - if hawkconfig.NeedsFirstRunSetup(context.Background()) { - return "Press Enter to set up and start", true - } - return "Press Enter to start", false -} - -func (m chatModel) renderWelcomeGateActionRow(width int) string { - primary, needsSetup := m.gateActionHint() - textStyle := lipgloss.NewStyle().Foreground(hudLabelPink).Bold(true).Inline(true) - if needsSetup { - textStyle = lipgloss.NewStyle().Foreground(warnAmber).Bold(true).Inline(true) - } - line := textStyle.Render(icons.Return() + " " + primary) - return lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Render(line) -} - -func (m chatModel) renderWelcomeGateChromeRow(width int) string { - cwd, err := os.Getwd() - if err != nil { - cwd = "." - } - display := shortenHomePath(cwd) - if br, gerr := gitOutput("rev-parse", "--abbrev-ref", "HEAD"); gerr == nil && br != "" && br != "HEAD" { - display += ":" + br - } - left := lipgloss.NewStyle().Foreground(statusCWDColor).Inline(true).Render(" " + display) - right := lipgloss.NewStyle().Foreground(textDisabled).Inline(true).Render("ctrl+c " + icons.CircleOutline() + " quit") - return layoutFooterRow(left, right, width) -} - -func (m chatModel) renderWelcomeGateFooter(width int) string { - return m.renderWelcomeGateActionRow(width) -} - -// renderWelcomeGate lays out hero + footer as one cluster (no huge gap between logo and chrome). -func (m chatModel) renderWelcomeGate(width, height int) string { - if width < 40 { - width = 80 - } - if height < 10 { - height = 24 - } - - footer := m.renderWelcomeGateFooter(width) - footerH := lipgloss.Height(footer) - chrome := m.renderWelcomeGateChromeRow(width) - chromeH := lipgloss.Height(chrome) - - hero := strings.Trim(m.welcomeCache, "\n") - heroLines := strings.Split(hero, "\n") - contentH := len(heroLines) - - clusterH := contentH + welcomeGateClusterGap + footerH - slack := height - clusterH - chromeH - topPad := welcomeGateMinTopPad - if slack > welcomeGateMinTopPad { - topPad = slack / 4 - } - if topPad > welcomeGateMaxTopPad { - topPad = welcomeGateMaxTopPad - } - - maxContent := height - topPad - welcomeGateClusterGap - footerH - chromeH - if maxContent < 1 { - maxContent = 1 - topPad = 0 - } - if contentH > maxContent { - heroLines = heroLines[:maxContent] - contentH = maxContent - hero = strings.Join(heroLines, "\n") - } - - var b strings.Builder - if topPad > 0 { - b.WriteString(strings.Repeat("\n", topPad)) - } - if hero != "" { - if !strings.HasSuffix(hero, "\n") { - b.WriteString(hero) - } else { - b.WriteString(strings.TrimRight(hero, "\n")) - } - } - if welcomeGateClusterGap > 0 { - b.WriteString(strings.Repeat("\n", welcomeGateClusterGap)) - } - b.WriteString(footer) - usedH := topPad + contentH + welcomeGateClusterGap + footerH + chromeH - if bottomPad := height - usedH; bottomPad > 0 { - b.WriteString(strings.Repeat("\n", bottomPad)) - } else { - b.WriteByte('\n') - } - b.WriteString(chrome) - return b.String() -} - -// stripWelcomeMessages removes splash copy from chat scrollback after the gate. -func (m chatModel) stripWelcomeMessages() chatModel { - filtered := m.messages[:0] - for _, msg := range m.messages { - if msg.role != "welcome" { - filtered = append(filtered, msg) - } - } - m.messages = filtered - return m -} - -// enterWorkPhase transitions from the welcome gate into the normal work TUI. -func (m chatModel) enterWorkPhase() (chatModel, tea.Cmd) { - m.phase = phaseWork - m.welcomeDismissed = true - m.welcomeCache = "" - m = m.stripWelcomeMessages() - m.viewDirty = true - m.autoScroll = true - m.streamFollow = true - m.uiFocus = focusPrompt - - if m.width > 0 { - m.input.SetWidth(m.width - 4) - } - m.rebuildWelcomeCache(m.blinkClosed) - m.updateViewportContent() - - if m.sandboxReadyPending { - m = m.flushSandboxReadyMessage() - } - - var cmds []tea.Cmd - cmds = append(cmds, m.input.Focus()) - - if m.openConfigOnStart { - m.openConfigOnStart = false - cm, c := m.openConfigPanel() - m = cm - cmds = append(cmds, c) - } - - return m, tea.Batch(cmds...) -} - -func (m chatModel) flushSandboxReadyMessage() chatModel { - if !m.sandboxReadyPending || m.session == nil { - return m - } - m.sandboxReadyPending = false - m.messages = append(m.messages, displayMsg{ - role: "system", - content: formatSandboxReadyAutonomyMessage(m.session.PermSvc().Autonomy()), - }) - m.viewDirty = true - return m -} - -func (m chatModel) handleWelcomeGateKey(msg tea.KeyMsg) (chatModel, tea.Cmd, bool) { - if !m.onWelcomeGate() { - return m, nil, false - } - - if m.commandPalette != nil && m.commandPalette.IsOpen() { - action, handled := m.commandPalette.Update(msg) - if handled { - if action != "" { - m.commandPalette.Close() - result, cmd := m.handleCommand(action) - if cm, ok := result.(chatModel); ok { - m = cm - } - if m.phase == phaseWork { - m.viewDirty = true - m.updateViewportContent() - return m, cmd, true - } - } - m.viewDirty = true - return m, nil, true - } - } - - switch msg.String() { - case "enter": - next, cmd := m.enterWorkPhase() - return next, cmd, true - case "ctrl+c": - if time.Since(m.lastCtrlC) < time.Second { - if m.watcherStop != nil { - m.watcherStop() - } - m.quitting = true - return m, tea.Quit, true - } - m.lastCtrlC = time.Now() - return m, nil, true - case "ctrl+k": - if m.commandPalette == nil { - m.commandPalette = NewCommandPalette(m.width) - } - m.commandPalette.Open() - m.viewDirty = true - return m, nil, true - } - return m, nil, true -} diff --git a/cmd/welcome_gate_test.go b/cmd/welcome_gate_test.go deleted file mode 100644 index f4d6fc21..00000000 --- a/cmd/welcome_gate_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package cmd - -import ( - "strings" - "testing" - - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - - hawkconfig "github.com/GrayCodeAI/hawk/internal/config" - "github.com/GrayCodeAI/hawk/internal/ui/icons" -) - -func TestRenderWelcomeGate_HAWKBrandingAndFooter(t *testing.T) { - m := chatModel{ - width: 100, - height: 30, - phase: phaseWelcomeGate, - welcomeCache: buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 100, nil, true), - } - out := m.renderWelcomeGate(100, 30) - for _, want := range []string{welcomeToPhraseLines[0], "███████", "Press Enter", "ctrl+c " + icons.CircleOutline() + " quit", " · ", icons.Return()} { - if !strings.Contains(out, want) { - t.Fatalf("renderWelcomeGate missing %q", want) - } - } - if strings.Contains(out, "pick model") || strings.Contains(out, "v0.") { - t.Fatalf("welcome gate should not show model picker or version in footer:\n%s", out) - } - if strings.Contains(out, "Almost ready") || strings.Contains(out, "paste your API key") { - t.Fatalf("welcome gate hero should not duplicate setup hints:\n%s", out) - } -} - -func TestBuildWelcomeMessage_GateOmitsSetupBanner(t *testing.T) { - out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 100, nil, true) - if strings.Contains(out, "Almost ready") { - t.Fatalf("gate welcome should not show Almost ready in hero:\n%s", out) - } - if !strings.Contains(out, "Skills") || !strings.Contains(out, " · ") { - t.Fatalf("gate welcome should use chip separators:\n%s", out) - } - if !strings.Contains(out, welcomeToPhraseLines[0]) || !strings.Contains(out, "██ ██ █████") { - t.Fatalf("gate welcome should show WELCOME TO block and HAWK logo:\n%s", out) - } -} - -func TestBuildWelcomeMessage_MediumGateStacksWelcomeTo(t *testing.T) { - out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, welcomeToPhraseMinWidth-1, nil, true) - if !strings.Contains(out, welcomeWordLines[0]) || !strings.Contains(out, welcomeToWordLines[0]) { - t.Fatalf("medium gate should stack WELCOME and TO blocks:\n%s", out) - } -} - -func TestBuildWelcomeMessage_NarrowGateShowsWelcomeFallback(t *testing.T) { - out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 50, nil, true) - if !strings.Contains(out, "WELCOME TO") { - t.Fatalf("narrow gate should show one-line welcome:\n%s", out) - } -} - -func TestRenderWelcomeGate_QuitHintNotTruncated(t *testing.T) { - m := chatModel{ - width: 80, - height: 24, - phase: phaseWelcomeGate, - welcomeCache: buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, nil, true), - input: textarea.New(), - viewport: viewport.New(80, 8), - } - out := m.renderWelcomeGate(80, 24) - if !strings.Contains(out, "ctrl+c "+icons.CircleOutline()+" quit") { - t.Fatalf("quit hint should be fully visible, got:\n%s", out) - } -} - -func TestRenderWelcomeGate_ChromeAnchorsBottom(t *testing.T) { - m := chatModel{ - width: 80, - height: 30, - phase: phaseWelcomeGate, - welcomeCache: buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, nil, true), - } - out := m.renderWelcomeGate(80, 30) - lines := strings.Split(strings.TrimRight(out, "\n"), "\n") - if len(lines) == 0 || !strings.Contains(lines[len(lines)-1], "ctrl+c "+icons.CircleOutline()+" quit") { - t.Fatalf("welcome gate chrome should anchor to bottom, got:\n%s", out) - } -} - -func TestEnterWorkPhase_DismissesWelcomeHeader(t *testing.T) { - ta := textarea.New() - ta.SetHeight(1) - ta.SetWidth(76) - m := chatModel{ - width: 80, - height: 24, - phase: phaseWelcomeGate, - welcomeCache: buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, nil, true), - input: ta, - viewport: viewport.New(80, 8), - } - next, _ := m.enterWorkPhase() - if next.phase != phaseWork { - t.Fatal("enter should move welcome gate to work phase") - } - if next.welcomeCache != "" || !next.welcomeDismissed { - t.Fatalf("enter should dismiss welcome cache, dismissed=%v cache=%q", next.welcomeDismissed, next.welcomeCache) - } - next.rebuildWelcomeCache(false) - if next.welcomeCache != "" { - t.Fatalf("dismissed welcome should not rebuild in work phase:\n%s", next.welcomeCache) - } -} - -func TestRenderWelcomeGate_FitsShortTerminal(t *testing.T) { - m := chatModel{ - width: 80, - height: 18, - phase: phaseWelcomeGate, - welcomeCache: buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 80, nil, true), - } - out := m.renderWelcomeGate(80, 18) - if !strings.Contains(out, "Press Enter") { - t.Fatal("short terminal should still show action row") - } -} - -func TestInitialUIPhase(t *testing.T) { - if initialUIPhase(false, false) != phaseWelcomeGate { - t.Fatal("expected welcome gate for fresh session") - } - if initialUIPhase(true, false) != phaseWork { - t.Fatal("expected work when chat exists") - } - if initialUIPhase(false, true) != phaseWork { - t.Fatal("expected work for one-shot prompt") - } -} diff --git a/cmd/welcome_inline_test.go b/cmd/welcome_inline_test.go new file mode 100644 index 00000000..ff4ac086 --- /dev/null +++ b/cmd/welcome_inline_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + + hawkconfig "github.com/GrayCodeAI/hawk/internal/config" +) + +func TestBuildWelcomeMessage_InlineShowsSetupGuidance(t *testing.T) { + out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 100, 24, nil) + if !strings.Contains(out, "v") { + t.Fatalf("inline welcome should show version, got:\n%s", out) + } + if strings.Contains(out, "WELCOME TO") { + t.Fatalf("inline welcome should not render the old gate banner, got:\n%s", out) + } +} + +func TestBuildWelcomeMessage_InlineShowsStarterPrompts(t *testing.T) { + out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 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{ + "explain this repo", + "fix the failing test", + "/help", + "/config", + } { + if !strings.Contains(out, want) { + t.Fatalf("inline welcome missing %q in:\n%s", want, out) + } + } +} + +func TestFixedWelcomeLineCount_ReservesInlineHeaderSpace(t *testing.T) { + ta := textarea.New() + ta.SetWidth(96) + ta.SetHeight(1) + m := chatModel{ + width: 100, + height: 30, + welcomeCache: buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, false, 100, 24, nil), + input: ta, + viewport: viewport.New(100, 8), + } + if got := m.fixedWelcomeLineCount(); got == 0 { + t.Fatal("expected inline welcome to reserve layout space") + } +} + +func TestBuildWelcomeMessage_ShortTerminalUsesCompactCopy(t *testing.T) { + out := buildWelcomeMessage(nil, "", nil, nil, hawkconfig.Settings{}, 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) + } +}