Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ hawk_bin
.hawk/
.yaad/
.openclaude/
.pi/
.grok/
.hermes/
.qoder/
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 @@ -248,8 +248,8 @@ func TestContainerFooterLeft_HostModeCopy(t *testing.T) {
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, "local") {
t.Fatalf("dim = %q, want local execution hint", dim)
}
if !strings.Contains(dim, "ask before tools") {
t.Fatalf("dim = %q, want approval hint", dim)
Expand Down
20 changes: 14 additions & 6 deletions cmd/chat_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -651,14 +651,16 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.session != nil {
m.session.SetContainerRequired(true)
}
m.messages = append(m.messages, displayMsg{role: "system", content: "Retrying container…"})
// Silent retry — no chat message spam. The welcome badge
// already reflects container state, and a failure below
// will surface only if the banner is no longer visible.
m.viewDirty = true
m.updateViewportContent()
cwd, _ := os.Getwd()
return m, bootContainerCmd(cwd)
case "h", "H":
m.containerRetryable = false
m.messages = append(m.messages, displayMsg{role: "system", content: "Switched to host mode."})
// Silent switch — welcome badge already shows HOST MODE.
m.viewDirty = true
m.updateViewportContent()
return m, nil
Expand Down Expand Up @@ -1421,10 +1423,16 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.session.SetContainerExecutor(nil)
applyDefaultHostAutonomy(m.session)
}
m.messages = append(m.messages, displayMsg{
role: "warning",
content: "Container unavailable — running on host. " + msg.err.Error() + " · [r]etry container [h]ost mode",
})
// Only surface the error in chat if the welcome banner is
// no longer the sole visible content. Otherwise the badge
// already shows "HOST MODE" and a duplicate message is noise.
welcomeOnly := len(m.messages) == 1 && m.messages[0].role == "welcome"
if !welcomeOnly {
m.messages = append(m.messages, displayMsg{
role: "warning",
content: "Container unavailable — running on host. " + msg.err.Error() + " · [r]etry container [h]ost mode",
})
}
m.input.Focus()
// Auto-submit any queued input now that host mode is active.
if m.pendingSubmit != "" {
Expand Down
73 changes: 20 additions & 53 deletions cmd/chat_welcome.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,27 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg
var b strings.Builder

// 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]
mLine := ""
if showMascot && i < len(mascot) {
mLine = mascot[i]
}
combined := logoC + line + rst
visW := runewidth.StringWidth(line)
if mLine != "" {
combined += " " + mascotC + mLine + rst
visW += 4 + runewidth.StringWidth(mLine)
b.WriteString("\n")

if tight {
// Compact single-line wordmark for small terminals.
compactArt := logoC + "HAWK" + rst
b.WriteString(center(runewidth.StringWidth("HAWK"), compactArt) + "\n")
} else {
for i := 0; i < len(art); i++ {
line := art[i]
mLine := ""
if showMascot && i < len(mascot) {
mLine = mascot[i]
}
combined := logoC + line + rst
visW := runewidth.StringWidth(line)
if mLine != "" {
combined += " " + mascotC + mLine + rst
visW += 4 + runewidth.StringWidth(mLine)
}
b.WriteString(center(visW, combined) + "\n")
}
b.WriteString(center(visW, combined) + "\n")
}

verLine := fmt.Sprintf("v%s", DisplayVersion())
Expand Down Expand Up @@ -217,31 +223,6 @@ func buildWelcomeMessageWithSnapshot(sess *engine.Session, sessionID string, reg
shortcutsRow1 = "ctrl+N new session · ctrl+L autonomy"
shortcutsRow2 = "/help · /config · /autonomy"
}
// Recent sessions — quick resume for returning users.
if recents := recentSessionsList(); len(recents) > 0 {
b.WriteByte('\n')
b.WriteString(center(runewidth.StringWidth("Recent sessions:"), dimC+"Recent sessions:"+rst) + "\n")
shown := 0
for _, e := range recents {
if shown >= 3 {
break
}
preview := e.Preview
if runes := []rune(preview); len(runes) > 50 {
preview = string(runes[:47]) + "…"
}
if preview == "" {
preview = "(no messages)"
}
shortID := e.ID
if len(shortID) > 8 {
shortID = shortID[:8]
}
row := fmt.Sprintf(" /resume %-10s %s", shortID, preview)
b.WriteString(center(runewidth.StringWidth(row), dimC+row+rst) + "\n")
shown++
}
}
b.WriteByte('\n')
b.WriteString(center(runewidth.StringWidth(shortcutsRow1), dimC+shortcutsRow1+rst) + "\n")
b.WriteString(center(runewidth.StringWidth(shortcutsRow2), dimC+shortcutsRow2+rst) + "\n")
Expand Down Expand Up @@ -323,20 +304,6 @@ func actLine(saved *session.Session, sessionID string) string {
return ""
}

// recentSessionsList returns up to 5 saved sessions (newest first) for the
// welcome screen's quick-resume section. Returns nil if no sessions exist or
// if listing fails — the caller should handle either gracefully.
func recentSessionsList() []session.Entry {
entries, err := session.List()
if err != nil || len(entries) == 0 {
return nil
}
if len(entries) > 5 {
entries = entries[:5]
}
return entries
}

func toolListSummary(registry *tool.Registry) string {
if registry == nil {
return "No tools enabled."
Expand Down
81 changes: 12 additions & 69 deletions cmd/statusbar.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ var (

// renderStatusBar renders the session stats footer below the input area.
// Returns 1 line normally, or 2 lines when the terminal is wide enough
// (width >= 120) to show secondary info (autonomy, container mode, session ID)
// on its own row so the primary row stays readable.
// (width >= 120) and secondary operational state is present.
func renderStatusBar(m *chatModel, width int) []string {
if width < 20 {
width = 80
Expand All @@ -61,7 +60,7 @@ func renderStatusBar(m *chatModel, width int) []string {
return []string{layoutFooterRow(left, right, width)}
}

// renderStatusBarPrimaryLeft — cwd, branch, model, spec stage.
// renderStatusBarPrimaryLeft — cwd, branch, spec stage.
func renderStatusBarPrimaryLeft(m *chatModel) string {
cwd, ok := cachedStatusLeftCwd(m)
if !ok {
Expand All @@ -71,9 +70,6 @@ func renderStatusBarPrimaryLeft(m *chatModel) string {
if branch := cachedStatusBranch(m); branch != "" {
parts = append(parts, statusBranchStyle.Render(icons.Branch()+" "+branch))
}
if model := statusModelName(m); model != "" {
parts = append(parts, statusSpecStyle.Render(icons.Robot()+" "+model))
}
if stage := specStageForStatus(m); stage != "" {
parts = append(parts, statusSpecStyle.Render(stage))
}
Expand Down Expand Up @@ -114,21 +110,11 @@ func renderStatusBarPrimaryRight(m *chatModel) string {
return strings.Join(parts, statusDimStyle.Render(" · "))
}

// renderStatusBarSecondaryLeft — session ID, ctrl+K hint.
func renderStatusBarSecondaryLeft(m *chatModel) string {
var parts []string
if m.sessionID != "" {
shortID := m.sessionID
if len(shortID) > 8 {
shortID = shortID[:8]
}
parts = append(parts, statusDimStyle.Render("#"+shortID))
}
parts = append(parts, statusDimStyle.Render("ctrl+K"))
return strings.Join(parts, statusDimStyle.Render(" "))
return ""
}

// renderStatusBarSecondaryRight — autonomy tier, container mode, dry-run, vim, focus/pause.
// renderStatusBarSecondaryRight — errors, dry-run, vim, focus/pause.
func renderStatusBarSecondaryRight(m *chatModel) string {
if m == nil || m.session == nil {
return ""
Expand All @@ -140,20 +126,10 @@ func renderStatusBarSecondaryRight(m *chatModel) string {
if m.waiting && !m.streamFollow {
parts = append(parts, statusDimStyle.Render(icons.Pause()))
}
if m.session != nil && m.session.PermSvc() != nil {
level := effectivePermissionTier(m.session)
parts = append(parts, autonomyTierStyle(level).Render("◈ "+autonomyTierName(level)))
}
if m.containerEnabled {
if m.containerReady {
parts = append(parts, containerModeStyle.Render("▣ container"))
} else if m.containerErr != nil {
parts = append(parts, containerModeErrStyle.Render("▣ container error"))
} else {
parts = append(parts, containerModeMutedStyle.Render("▣ container…"))
}
} else {
parts = append(parts, containerModeStyle.Render("▢ host"))
// Container mode is already shown in the top footer row (containerFooterLeft),
// so we only surface it here when there's an error to report.
if m.containerEnabled && m.containerErr != nil {
parts = append(parts, containerModeErrStyle.Render("▣ container error"))
}
if m.session != nil && m.session.PermSvc() != nil && m.session.PermSvc().DryRun() {
parts = append(parts, dryRunStyle.Render(icons.Pause()+" DRY-RUN"))
Expand Down Expand Up @@ -202,46 +178,12 @@ func renderStatusBarLeft(m *chatModel) string {
if branch := cachedStatusBranch(m); branch != "" {
parts = append(parts, statusBranchStyle.Render(icons.Branch()+" "+branch))
}
// Model name — which LLM is answering. Shortened to the last path segment
// so "anthropic/claude-sonnet-4-20250514" reads as "claude-sonnet-4-20250514".
if model := statusModelName(m); model != "" {
parts = append(parts, statusSpecStyle.Render(icons.Robot()+" "+model))
}
if stage := specStageForStatus(m); stage != "" {
parts = append(parts, statusSpecStyle.Render(stage))
}
// Session ID — short, dim, for support reference and session switching.
if m.sessionID != "" {
shortID := m.sessionID
if len(shortID) > 8 {
shortID = shortID[:8]
}
parts = append(parts, statusDimStyle.Render("#"+shortID))
}
// Command palette hint — persistent discoverability.
parts = append(parts, statusDimStyle.Render("ctrl+K"))
return strings.Join(parts, statusDimStyle.Render(" "))
}

// statusModelName returns a short display name for the active model, or "" if
// unknown. Takes the last "/" segment so fully-qualified model IDs read cleanly.
func statusModelName(m *chatModel) string {
if m == nil || m.session == nil {
return ""
}
modelID := strings.TrimSpace(m.session.Model())
if modelID == "" {
return ""
}
if idx := strings.LastIndex(modelID, "/"); idx >= 0 && idx < len(modelID)-1 {
modelID = modelID[idx+1:]
}
if len(modelID) > 20 {
modelID = modelID[:18] + "…"
}
return modelID
}

// specStageForStatus returns a short spec stage indicator for the status bar,
// or empty string if no spec workflow is active.
func specStageForStatus(m *chatModel) string {
Expand Down Expand Up @@ -490,12 +432,13 @@ func containerFooterLeft(m chatModel) (bold, dim string) {

func hostModeHint(sess *engine.Session) string {
if sess == nil || sess.Perm == nil {
return " commands run on your machine · ask before tools"
return " local · ask before tools"
}
if sess.Perm.Stage != engine.SpecStageNone && sess.Perm.Stage != engine.SpecStageImplementing {
return " commands run on your machine · spec stage active — writes/commands gated"
return " local · spec stage active"
}
return " commands run on your machine · " + autonomyTierDescription(sess.PermSvc().Autonomy())
// Show short tier name instead of full description to keep footer compact.
return " local · " + autonomyTierName(sess.PermSvc().Autonomy())
}

func statusLineSummary(m *chatModel) string {
Expand Down
30 changes: 29 additions & 1 deletion cmd/statusbar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ func TestRenderStatusBar_SignatureExists(t *testing.T) {
}

func TestRenderStatusBar_TwoLineAtWidth120(t *testing.T) {
m := &chatModel{session: &engine.Session{}}
m := &chatModel{session: engine.NewSession("", "test-model", "system", nil)}
m.session.PermSvc().SetDryRun(true)
m.session.CostValue().PromptTokens = 1000
m.session.CostValue().CompletionTokens = 500
m.sessionStartedAt = time.Now().Add(-5 * time.Minute)
Expand All @@ -34,6 +35,33 @@ func TestRenderStatusBar_SingleLineAtWidth80(t *testing.T) {
}
}

func TestRenderStatusBarSecondaryRight_OmitsDuplicatedAutonomy(t *testing.T) {
sess := engine.NewSession("", "test-model", "system", nil)
sess.PermSvc().SetAutonomy(engine.AutonomyBasic)

if got := renderStatusBarSecondaryRight(&chatModel{session: sess}); strings.Contains(got, "Scout") {
t.Fatalf("secondary status = %q, want autonomy shown only in top footer", got)
}
}

func TestRenderStatusBar_HidesModelSessionAndShortcut(t *testing.T) {
m := &chatModel{
session: engine.NewSession("", "test-model", "system", nil),
sessionID: "cc6f7764abcdef",
statusLeftVal: "~/repo",
statusLeftBranch: "main",
}

for _, width := range []int{80, 120} {
got := strings.Join(renderStatusBar(m, width), "\n")
for _, hidden := range []string{"test-model", "#cc6f7764", "ctrl+K"} {
if strings.Contains(got, hidden) {
t.Errorf("width %d status = %q, want %q hidden", width, got, hidden)
}
}
}
}

func TestRenderStatusBarRight_IncludesTokensLabel(t *testing.T) {
m := &chatModel{session: &engine.Session{}}
m.session.CostValue().PromptTokens = 1200
Expand Down
Loading