Skip to content

Commit 50b4b5a

Browse files
committed
Polish setup TUI status and gateway rows
1 parent c245472 commit 50b4b5a

10 files changed

Lines changed: 132 additions & 50 deletions

cmd/chat_config_gateways.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,11 @@ func (m chatModel) configGatewaysView() string {
126126
if row.HasKey {
127127
key = "✓"
128128
}
129-
models := ""
130-
if row.ModelCount > 0 {
129+
models := "key required"
130+
if row.HasKey && row.ModelCount > 0 {
131131
models = fmt.Sprintf("%d", row.ModelCount)
132+
} else if row.HasKey {
133+
models = "—"
132134
}
133135
tableData[i] = []string{row.DisplayName, key, models, ""}
134136
layoutData[i] = append([]string(nil), tableData[i]...)
@@ -173,7 +175,7 @@ func (m chatModel) configGatewaysView() string {
173175
ctx := context.Background()
174176
indent := strings.Repeat(" ", configTableIndent)
175177
if !hawkconfig.HasConfiguredDeploymentCached(ctx) {
176-
b.WriteString("\n" + mutedStyle.Render(indent+"Catalog = models in eyrie cache · add key in Keys tab to use them"))
178+
b.WriteString("\n" + mutedStyle.Render(indent+"Catalog count appears after a saved key · key required to use a gateway"))
177179
} else {
178180
b.WriteString("\n" + configTableSelectionFooter(len(rows), m.configScroll, end, mutedStyle, "r refresh · enter select · ↓ refresh row"))
179181
}
@@ -236,6 +238,13 @@ func (m chatModel) focusConfigActiveGateway() chatModel {
236238
rows := m.configGatewayRows()
237239
if i := m.activeGatewayRowIndex(rows); i >= 0 {
238240
m.configGatewayFocus = i
241+
m.configSel = i
242+
if m.configSel < m.configScroll {
243+
m.configScroll = m.configSel
244+
}
245+
if m.configSel >= m.configScroll+configWindowSize {
246+
m.configScroll = m.configSel - configWindowSize + 1
247+
}
239248
}
240249
return m
241250
}

cmd/chat_config_gateways_test.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"github.com/GrayCodeAI/hawk/internal/engine"
1010
)
1111

12-
func TestConfigGatewaysView_CatalogHeaderWithoutKeys(t *testing.T) {
12+
func TestConfigGatewaysView_RequiresKeyForModelCounts(t *testing.T) {
1313
hawkconfig.InvalidateConfigUICache()
1414
store := &credentials.MapStore{}
1515
credentials.SetDefaultStore(store)
@@ -23,8 +23,11 @@ func TestConfigGatewaysView_CatalogHeaderWithoutKeys(t *testing.T) {
2323
if !strings.Contains(view, "Catalog") {
2424
t.Fatalf("expected Catalog column header, got:\n%s", view)
2525
}
26-
if !strings.Contains(view, "add key in Keys tab") {
27-
t.Fatalf("expected keys hint without credentials, got:\n%s", view)
26+
if !strings.Contains(view, "key required") {
27+
t.Fatalf("expected key-required model cells without credentials, got:\n%s", view)
28+
}
29+
if !strings.Contains(view, "Catalog count appears after a saved key") {
30+
t.Fatalf("expected key requirement hint without credentials, got:\n%s", view)
2831
}
2932
}
3033

@@ -77,6 +80,38 @@ func TestConfigGatewayRefreshTargetIndex_UsesFocusOnRefreshRow(t *testing.T) {
7780
}
7881
}
7982

83+
func TestFocusConfigActiveGateway_SelectsActiveRow(t *testing.T) {
84+
hawkconfig.InvalidateConfigUICache()
85+
store := &credentials.MapStore{}
86+
credentials.SetDefaultStore(store)
87+
t.Cleanup(func() {
88+
credentials.SetDefaultStore(nil)
89+
hawkconfig.InvalidateConfigUICache()
90+
})
91+
_ = store.Set(t.Context(), credentials.AccountForEnv("OPENROUTER_API_KEY"), "sk-or-test-key-1234567890")
92+
hawkconfig.InvalidateConfigUICache()
93+
94+
sess := &engine.Session{}
95+
sess.SetProvider("openrouter")
96+
m := chatModel{
97+
configTab: configTabGateways,
98+
session: sess,
99+
configModelProvider: "openrouter",
100+
}
101+
next := m.focusConfigActiveGateway()
102+
rows := next.configGatewayRows()
103+
active := next.activeGatewayRowIndex(rows)
104+
if active < 0 {
105+
t.Fatal("expected active gateway row")
106+
}
107+
if next.configSel != active {
108+
t.Fatalf("configSel = %d, want active row %d", next.configSel, active)
109+
}
110+
if next.configScroll > next.configSel || next.configSel >= next.configScroll+configWindowSize {
111+
t.Fatalf("active row not visible: sel=%d scroll=%d", next.configSel, next.configScroll)
112+
}
113+
}
114+
80115
func TestHandleConfigGatewaysSelect_NoKeyRedirectsToKeys(t *testing.T) {
81116
hawkconfig.InvalidateConfigUICache()
82117
store := &credentials.MapStore{}

cmd/chat_config_panel.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ func (m chatModel) configModelsBody() string {
303303
rowStyle := configRowStyle()
304304
cursorStyle := configSelectedStyle()
305305
activeStyle := configActiveStyle()
306-
freeStyle := lipgloss.NewStyle().Foreground(successTeal)
306+
freeStyle := lipgloss.NewStyle().Foreground(doneGreen)
307307

308308
opts := m.configFilteredModelOptions()
309309
total := len(opts)
@@ -647,8 +647,8 @@ func (m chatModel) selectConfigModelFromOptions(opts []configModelOption) (chatM
647647
next = next.closeConfigPanel()
648648
if !hawkconfig.EvaluateSetupCached(context.Background()).NeedsSetup {
649649
next.messages = append(next.messages, displayMsg{
650-
role: "system",
651-
content: fmt.Sprintf("Setup complete — chatting with %s", next.session.Model()),
650+
role: "setup_complete",
651+
content: next.session.Model(),
652652
})
653653
}
654654
return next, cmd

cmd/chat_config_tabs.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,8 @@ func (m chatModel) openConfigAtTab(tab int) (chatModel, tea.Cmd) {
141141
m.configNotice = "Select Add API key · press enter · paste your key"
142142
m.configSel = 0
143143
}
144+
if tab == configTabGateways {
145+
m = m.focusConfigActiveGateway()
146+
}
144147
return m, nil
145148
}

cmd/chat_config_ui.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@ func configTitleStyle() lipgloss.Style {
1616
}
1717

1818
func configSelectedStyle() lipgloss.Style {
19-
// Brand orange (bold) — the selected item, matching the rest of
20-
// the TUI's hawk voice. The title uses plain orange + bold so the
21-
// selected item is visually heavier than the title, but the hue is
22-
// the same.
23-
return lipgloss.NewStyle().Foreground(hawkColor).Bold(true).Underline(true)
19+
// Brand orange (bold) marks the focused row. Keep it distinct from
20+
// the active/current value, which uses configActiveStyle.
21+
return lipgloss.NewStyle().Foreground(hawkColor).Bold(true)
2422
}
2523

2624
func configAccentStyle() lipgloss.Style {
@@ -81,10 +79,9 @@ func renderConfigStatusLine(m chatModel) string {
8179
}
8280

8381
func configActiveStyle() lipgloss.Style {
84-
// The current gateway/model name in the status line — pink to match
85-
// the active-selection color so the "current" indicator and the
86-
// "focus" indicator share a visual language.
87-
return lipgloss.NewStyle().Foreground(hawkColor)
82+
// Current gateway/model value. Teal keeps "active/current" separate
83+
// from the orange cursor selection.
84+
return lipgloss.NewStyle().Foreground(successTeal).Bold(true)
8885
}
8986

9087
func configRowStyle() lipgloss.Style {
@@ -142,6 +139,10 @@ func renderConfigNotice(notice string) string {
142139

143140
func (m chatModel) configHelpLine() string {
144141
muted := configMutedStyle()
142+
indent := strings.Repeat(" ", configTableIndent)
143+
renderMainHelp := func(text string) string {
144+
return muted.Render(indent + text)
145+
}
145146
if m.configSaving {
146147
return muted.Render(m.spinner.View() + " working…")
147148
}
@@ -156,15 +157,15 @@ func (m chatModel) configHelpLine() string {
156157
}
157158
if m.configTab == configTabKeys && m.configKeysPendingRemove != "" {
158159
if m.configKeysRemoveStep >= 2 {
159-
return muted.Render("enter again to permanently remove · esc cancel")
160+
return renderMainHelp("enter again to permanently remove · esc cancel")
160161
}
161-
return muted.Render("enter continue · esc cancel")
162+
return renderMainHelp("enter continue · esc cancel")
162163
}
163164
if m.configTab == configTabModels {
164165
if m.configModelSearchActive {
165-
return muted.Render("↑/↓ navigate · enter select · esc clear search")
166+
return renderMainHelp("↑/↓ navigate · enter select · esc clear search")
166167
}
167-
return muted.Render("↑/↓ navigate · enter select · / search · esc close")
168+
return renderMainHelp("↑/↓ navigate · enter select · / search · esc close")
168169
}
169-
return muted.Render("←/→ tabs · ↑/↓ · enter · esc close")
170+
return renderMainHelp("←/→ tabs · ↑/↓ · enter · esc close")
170171
}

cmd/chat_status.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,8 @@ func (m chatModel) renderConnectionStatusSplit() (modelRendered string, modelVis
149149

150150
func renderChatConnectionModel(gateway, model string) (string, int) {
151151
muted := configMutedStyle().Inline(true)
152-
// Gateway (provider name) is rendered in agentGold so the user can
153-
// tell it apart from the model, which is the brand orange focus.
154-
gatewayStyle := lipgloss.NewStyle().Foreground(agentGold).Inline(true)
155-
active := configActiveStyle().Inline(true) // model — brand orange
152+
gatewayStyle := configAccentStyle().Inline(true)
153+
active := configActiveStyle().Inline(true)
156154
sep := muted.Render(" · ")
157155
const sepVis = 3
158156

@@ -301,7 +299,7 @@ func contextPercentColor(pct int) lipgloss.Color {
301299
case pct >= 80:
302300
return warnAmber
303301
default:
304-
return successTeal
302+
return doneGreen
305303
}
306304
}
307305

cmd/chat_view.go

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ func (m chatModel) hasChatMessages() bool {
2828
return false
2929
}
3030

31+
func renderSetupCompleteMessage(model string) string {
32+
success := lipgloss.NewStyle().Foreground(doneGreen).Bold(true).Inline(true)
33+
muted := configMutedStyle().Inline(true)
34+
active := configActiveStyle().Inline(true)
35+
model = strings.TrimSpace(model)
36+
if model == "" {
37+
model = "selected model"
38+
}
39+
return lipgloss.JoinHorizontal(
40+
lipgloss.Left,
41+
success.Render("Setup complete"),
42+
muted.Render(" · ready to chat with "),
43+
active.Render(model),
44+
muted.Render(" "),
45+
success.Render("✓"),
46+
)
47+
}
48+
3149
// welcomeHeader returns the full logo before chat, then a one-line banner after.
3250
func (m chatModel) welcomeHeader() string {
3351
if !m.showWelcomeBanner() {
@@ -337,6 +355,8 @@ func (m *chatModel) updateViewportContent() {
337355
case "system":
338356
sysWrapped := wrapText(msg.content, viewWidth-2, 0)
339357
chatContent.WriteString(dimStyle.Render(sysWrapped))
358+
case "setup_complete":
359+
chatContent.WriteString(renderSetupCompleteMessage(msg.content))
340360
case "permission":
341361
chatContent.WriteString(renderPermissionBox(msg.content, viewWidth))
342362
case "question":
@@ -415,28 +435,17 @@ func (m chatModel) View() string {
415435
totalW = 80
416436
}
417437
slashOpen := m.slashMenuOpen()
418-
var leftBold, leftDim string
419-
leftBold, leftDim = containerFooterLeft(m)
438+
leftRendered, leftVisLen := renderContainerFooterLeft(m)
420439
modelRendered, modelVisLen, ctxRendered, ctxVisLen := m.renderConnectionStatusSplit()
421440
const ctxSepVis = 3
422441
rightVisLen := modelVisLen + ctxVisLen
423442
if ctxVisLen > 0 && modelVisLen > 0 {
424443
rightVisLen += ctxSepVis
425444
}
426-
leftPlain := leftBold + leftDim
427-
leftVisLen := runewidth.StringWidth(leftPlain)
428445
gap := totalW - leftVisLen - rightVisLen
429446
if gap < 1 {
430447
gap = 1
431448
}
432-
var leftRendered string
433-
if m.containerEnabled && m.containerErr != nil {
434-
leftRendered = containerErrStyle.Bold(true).Render(leftBold) + containerErrStyle.Render(leftDim)
435-
} else if m.containerEnabled {
436-
leftRendered = containerLabelStyle.Render(leftBold) + dimStyle.Render(leftDim)
437-
} else {
438-
leftRendered = lipgloss.NewStyle().Bold(true).Render(leftBold) + dimStyle.Render(leftDim)
439-
}
440449
rightLine := modelRendered
441450
if ctxVisLen > 0 {
442451
if modelVisLen > 0 {

cmd/model_table.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ func renderModelTableHeader(layout modelTableLayout, headerStyle, metaStyle lipg
188188
func renderModelTableRow(row modelTableRow, cursor, active bool, layout modelTableLayout, _, cursorStyle, activeStyle, metaStyle, freeStyle lipgloss.Style) string {
189189
meta := metaStyle
190190
priceStyle := metaStyle
191-
if row.Free && !cursor && !active {
191+
if row.Free {
192192
priceStyle = freeStyle
193193
}
194194
if active && !cursor {
@@ -266,15 +266,15 @@ func truncateRunes(s string, maxCols int) string {
266266
return b.String() + "…"
267267
}
268268

269-
func modelTableScrollHint(above, below int, muted lipgloss.Style) string {
269+
func modelTableScrollHint(above, below int, style lipgloss.Style) string {
270270
prefix := strings.Repeat(" ", modelTableIndent)
271271
switch {
272272
case above > 0 && below > 0:
273-
return muted.Render(fmt.Sprintf("%s↑ %d above · ↓ %d below", prefix, above, below))
273+
return style.Render(fmt.Sprintf("%s↑ %d above · ↓ %d below", prefix, above, below))
274274
case above > 0:
275-
return muted.Render(fmt.Sprintf("%s↑ %d above", prefix, above))
275+
return style.Render(fmt.Sprintf("%s↑ %d above", prefix, above))
276276
case below > 0:
277-
return muted.Render(fmt.Sprintf("%s↓ %d below", prefix, below))
277+
return style.Render(fmt.Sprintf("%s↓ %d below", prefix, below))
278278
default:
279279
return ""
280280
}

cmd/statusbar.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func renderStatusBarRight(m *chatModel) (rendered string, visLen int) {
6969

7070
tokenStyle := lipgloss.NewStyle().Foreground(statusTokenColor).Inline(true)
7171
costStyle := lipgloss.NewStyle().Foreground(statusCostColor).Inline(true)
72-
timeStyle := lipgloss.NewStyle().Foreground(tealColor).Inline(true)
72+
timeStyle := lipgloss.NewStyle().Foreground(hudLabelPink).Inline(true)
7373
dim := lipgloss.NewStyle().Foreground(dimColor).Inline(true)
7474

7575
tokens := m.session.Cost.PromptTokens + m.session.Cost.CompletionTokens
@@ -146,6 +146,35 @@ func formatTokenCountWithCommas(tokens int) string {
146146
return p.Sprintf("%d tokens", tokens)
147147
}
148148

149+
func renderContainerFooterLeft(m chatModel) (rendered string, visLen int) {
150+
bold, dim := containerFooterLeft(m)
151+
visLen = runewidth.StringWidth(bold + dim)
152+
153+
if m.containerEnabled && m.containerErr != nil {
154+
return containerErrStyle.Bold(true).Render(bold) + containerErrStyle.Render(dim), visLen
155+
}
156+
if m.containerEnabled {
157+
return containerLabelStyle.Render(bold) + renderContainerFooterDetail(dim), visLen
158+
}
159+
160+
labelStyle := lipgloss.NewStyle().Foreground(warnAmber).Bold(true)
161+
return labelStyle.Render(bold) + dimStyle.Render(dim), visLen
162+
}
163+
164+
func renderContainerFooterDetail(detail string) string {
165+
if detail == "" {
166+
return ""
167+
}
168+
statusStyle := lipgloss.NewStyle().Foreground(textPlaceholder).Inline(true)
169+
okStyle := lipgloss.NewStyle().Foreground(doneGreen).Inline(true)
170+
sep := " · "
171+
status, ok, found := strings.Cut(detail, sep)
172+
if !found {
173+
return statusStyle.Render(detail)
174+
}
175+
return statusStyle.Render(status) + configMutedStyle().Inline(true).Render(sep) + okStyle.Render(ok)
176+
}
177+
149178
// containerFooterLeft is the bold + dim text on the top footer row (left side).
150179
func containerFooterLeft(m chatModel) (bold, dim string) {
151180
if !m.containerEnabled {

cmd/theme.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,8 @@ var hawkColor = lipgloss.Color("#FF5E0E")
3535
// 2. UI state
3636
// ---------------------------------------------------------------------------
3737

38-
// (No separate active-selection color — the selected item uses the
39-
// brand hawk orange so the entire TUI stays on one accent. The title
40-
// and the selected item share hawkColor; the selected item is
41-
// distinguished by bold + underline.)
38+
// The selected/focused item uses brand orange. Active/current values use
39+
// configActiveStyle so they remain visually distinct from the cursor.
4240

4341
// ---------------------------------------------------------------------------
4442
// 3. Semantic feedback

0 commit comments

Comments
 (0)