Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions cmd/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,10 @@ 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).
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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions cmd/chat_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions cmd/chat_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
17 changes: 17 additions & 0 deletions cmd/chat_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ type (
streamErrMsg struct{ err error }
spinnerVerbTickMsg struct{}
promptKeepAliveMsg struct{}
eyeBlinkTickMsg struct{}
eyeFrameNextMsg struct{ frame int }
statusLeftPRsMsg struct {
branch string
nums []string
}
usageUpdateMsg struct{ usage *engine.StreamUsage }
compactStartMsg struct{}
compactMsg struct {
Expand Down Expand Up @@ -192,6 +198,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
Expand Down Expand Up @@ -278,6 +285,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
Expand Down Expand Up @@ -498,6 +507,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} })
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/chat_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/chat_subcommand_branch_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion cmd/chat_subcommand_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
13 changes: 11 additions & 2 deletions cmd/chat_subcommand_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
52 changes: 49 additions & 3 deletions cmd/chat_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -175,6 +178,48 @@ 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
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 {
Expand Down Expand Up @@ -967,8 +1012,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
}
Expand Down
Loading