Skip to content

Commit 9d08b41

Browse files
committed
Add tabbed output pane to dashboard and fix CI integration test
Replace the expand/collapse host output model with a persistent tab bar that provides a diff tab plus per-host output tabs. Add keyboard shortcuts for tab navigation ([/], 1-9, Enter, Esc). Make status bar hints responsive to terminal width. Gate live SSH integration test behind a build tag so CI passes without real hosts.
1 parent e2d0107 commit 9d08b41

7 files changed

Lines changed: 636 additions & 42 deletions

File tree

internal/ui/dashboard/model.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,16 +222,20 @@ func (m Model) handleHostTableKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
222222

223223
switch {
224224
case key.Code == tea.KeyEnter:
225-
// Expand selected host in output pane.
225+
// Switch to the selected host's tab in the output pane.
226226
host := m.hostTable.SelectedHost()
227227
if host != "" && m.lastGrouped != nil {
228-
m.outputPane.ExpandHost(host, m.lastGrouped, m.lastResults)
228+
if m.outputPane.ActivateHostTab(host) {
229+
// Only switch focus when the host tab actually exists.
230+
m.hostTable.Blur()
231+
m.focused = paneOutput
232+
}
229233
}
230234
return m, nil
231235

232236
case key.Code == tea.KeyEscape:
233237
if m.outputPane.IsExpanded() {
234-
m.outputPane.CollapseHost(m.lastGrouped, m.lastResults)
238+
m.outputPane.ActivateDiffTab()
235239
return m, nil
236240
}
237241

@@ -258,11 +262,25 @@ func (m Model) handleOutputKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
258262

259263
if key.Code == tea.KeyEscape {
260264
if m.outputPane.IsExpanded() {
261-
m.outputPane.CollapseHost(m.lastGrouped, m.lastResults)
265+
m.outputPane.ActivateDiffTab()
262266
return m, nil
263267
}
264268
}
265269

270+
// Tab switching with [ and ].
271+
switch msg.String() {
272+
case "[":
273+
m.outputPane.PrevTab()
274+
return m, nil
275+
case "]":
276+
m.outputPane.NextTab()
277+
return m, nil
278+
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
279+
idx := int(msg.String()[0]-'0') - 1 // 1-based to 0-based
280+
m.outputPane.SetTabIndex(idx)
281+
return m, nil
282+
}
283+
266284
// Forward to viewport for scrolling.
267285
cmd := m.outputPane.Update(msg)
268286
return m, cmd

internal/ui/dashboard/model_integration_test.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
//go:build integration
2+
13
package dashboard
24

35
import (
@@ -14,10 +16,6 @@ import (
1416
// TestDashboardWithLiveHosts exercises the dashboard model against real SSH hosts.
1517
// It verifies health checks, command execution, and message routing work end-to-end.
1618
func TestDashboardWithLiveHosts(t *testing.T) {
17-
if testing.Short() {
18-
t.Skip("skipping live SSH test in short mode")
19-
}
20-
2119
rawHosts := []string{"signal@192.168.86.59", "signal@192.168.86.238"}
2220

2321
baseConf := ssh.ClientConfig{

internal/ui/dashboard/output_pane.go

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,32 @@ import (
1313
"github.com/agent462/herd/internal/grouper"
1414
)
1515

16-
// outputPane wraps a bubbles/viewport for displaying grouped command results.
16+
// tabBarHeight is the number of rows consumed by the tab bar.
17+
const tabBarHeight = 2 // 1 row for tabs + 1 row for bottom border
18+
19+
// outputPane wraps a bubbles/viewport for displaying grouped command results,
20+
// with a tab bar for switching between the diff view and per-host output.
1721
type outputPane struct {
18-
viewport viewport.Model
19-
width int
20-
height int
21-
expandedHost string // when non-empty, show only this host's output
22+
viewport viewport.Model
23+
tabBar tabBar
24+
width int
25+
height int
26+
27+
// Cached data for re-rendering when tabs switch.
28+
lastGrouped *grouper.GroupedResults
29+
lastResults []*executor.HostResult
30+
allHosts []string
2231
}
2332

2433
func newOutputPane(width, height int) outputPane {
2534
contentWidth := width - 2 // account for pane border
2635
vp := viewport.New(
2736
viewport.WithWidth(contentWidth),
28-
viewport.WithHeight(height-2), // account for border
37+
viewport.WithHeight(height-2-tabBarHeight), // border + tab bar
2938
)
3039
return outputPane{
3140
viewport: vp,
41+
tabBar: newTabBar(contentWidth),
3242
width: contentWidth,
3343
height: height,
3444
}
@@ -41,13 +51,14 @@ func (o *outputPane) Update(msg tea.Msg) tea.Cmd {
4151
}
4252

4353
func (o *outputPane) View() string {
44-
// Hard-clip the viewport output to prevent any line from exceeding the
45-
// content width. The viewport pads lines but does not truncate, so this
46-
// catches edge cases where styled/ANSI content is wider than expected.
54+
bar := o.tabBar.View()
55+
var content string
4756
if o.width > 0 {
48-
return lipgloss.NewStyle().MaxWidth(o.width).Render(o.viewport.View())
57+
content = lipgloss.NewStyle().MaxWidth(o.width).Render(o.viewport.View())
58+
} else {
59+
content = o.viewport.View()
4960
}
50-
return o.viewport.View()
61+
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
5162
}
5263

5364
// setContent truncates each line to the viewport width (ANSI-aware) before
@@ -68,37 +79,105 @@ func (o *outputPane) Resize(width, height int) {
6879
o.width = width - 2 // content width inside pane border
6980
o.height = height
7081
o.viewport.SetWidth(o.width)
71-
o.viewport.SetHeight(height - 2)
82+
o.viewport.SetHeight(height - 2 - tabBarHeight)
83+
o.tabBar.Resize(o.width)
7284
}
7385

86+
// SetGroupedResults updates the output pane with new execution results.
87+
// Rebuilds the tab bar and re-renders the active tab's content.
7488
func (o *outputPane) SetGroupedResults(grouped *grouper.GroupedResults, results []*executor.HostResult) {
89+
o.lastGrouped = grouped
90+
o.lastResults = results
91+
7592
if grouped == nil {
7693
o.setContent("No results yet. Type a command below.")
7794
return
7895
}
7996

80-
if o.expandedHost != "" {
81-
o.renderHostOutput(o.expandedHost, grouped, results)
82-
return
97+
// Collect all hosts in order from results.
98+
hosts := make([]string, 0, len(results))
99+
for _, r := range results {
100+
hosts = append(hosts, r.Host)
101+
}
102+
o.allHosts = hosts
103+
o.tabBar.SetTabs(hosts)
104+
105+
o.renderActiveTab()
106+
}
107+
108+
// renderActiveTab dispatches to the correct renderer based on the active tab.
109+
func (o *outputPane) renderActiveTab() {
110+
id := o.tabBar.ActiveID()
111+
if id == "diff" {
112+
if o.lastGrouped != nil {
113+
o.renderGrouped(o.lastGrouped)
114+
}
115+
} else {
116+
o.renderHostOutput(id, o.lastGrouped, o.lastResults)
83117
}
118+
}
84119

85-
o.renderGrouped(grouped)
120+
// NextTab switches to the next tab and re-renders.
121+
func (o *outputPane) NextTab() {
122+
o.tabBar.Next()
123+
o.renderActiveTab()
86124
}
87125

126+
// PrevTab switches to the previous tab and re-renders.
127+
func (o *outputPane) PrevTab() {
128+
o.tabBar.Prev()
129+
o.renderActiveTab()
130+
}
131+
132+
// SetTabIndex jumps to a tab by index and re-renders.
133+
func (o *outputPane) SetTabIndex(index int) {
134+
o.tabBar.SetActive(index)
135+
o.renderActiveTab()
136+
}
137+
138+
// ActivateHostTab switches to a specific host's tab.
139+
// Returns true if the host was found in the tab list.
140+
func (o *outputPane) ActivateHostTab(hostname string) bool {
141+
if o.tabBar.SetActiveByID(hostname) {
142+
o.renderActiveTab()
143+
return true
144+
}
145+
return false
146+
}
147+
148+
// ActivateDiffTab switches back to the diff output tab.
149+
func (o *outputPane) ActivateDiffTab() {
150+
o.tabBar.SetActive(0)
151+
o.renderActiveTab()
152+
}
153+
154+
// ExpandHost is a backwards-compatible wrapper that activates the host's tab.
88155
func (o *outputPane) ExpandHost(name string, grouped *grouper.GroupedResults, results []*executor.HostResult) {
89-
o.expandedHost = name
90-
o.renderHostOutput(name, grouped, results)
156+
o.lastGrouped = grouped
157+
o.lastResults = results
158+
// Ensure tabs are populated.
159+
if len(o.tabBar.tabs) <= 1 {
160+
hosts := make([]string, 0, len(results))
161+
for _, r := range results {
162+
hosts = append(hosts, r.Host)
163+
}
164+
o.allHosts = hosts
165+
o.tabBar.SetTabs(hosts)
166+
}
167+
o.tabBar.SetActiveByID(name)
168+
o.renderActiveTab()
91169
}
92170

171+
// CollapseHost is a backwards-compatible wrapper that returns to the diff tab.
93172
func (o *outputPane) CollapseHost(grouped *grouper.GroupedResults, results []*executor.HostResult) {
94-
o.expandedHost = ""
95-
if grouped != nil {
96-
o.renderGrouped(grouped)
97-
}
173+
o.lastGrouped = grouped
174+
o.lastResults = results
175+
o.ActivateDiffTab()
98176
}
99177

178+
// IsExpanded returns true if viewing a specific host (not the diff tab).
100179
func (o *outputPane) IsExpanded() bool {
101-
return o.expandedHost != ""
180+
return o.tabBar.ActiveID() != "diff"
102181
}
103182

104183
func (o *outputPane) renderGrouped(grouped *grouper.GroupedResults) {

internal/ui/dashboard/status_bar.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,35 @@ func renderStatusBar(totalHosts, connectedHosts int, width int, groupName string
2222

2323
left += " │ " + connStr + disconnStr
2424

25-
right := helpKeyStyle.Render("Tab") + helpDescStyle.Render(" focus") +
26-
" " + helpKeyStyle.Render("f") + helpDescStyle.Render(" filter") +
27-
" " + helpKeyStyle.Render("d") + helpDescStyle.Render(" diff") +
28-
" " + helpKeyStyle.Render("?") + helpDescStyle.Render(" help") +
29-
" " + helpKeyStyle.Render("q") + helpDescStyle.Render(" quit") + " "
25+
// Build right-side hints, dropping lowest-priority items (from the end)
26+
// when they don't fit alongside the left side.
27+
type hint struct{ key, desc string }
28+
// Ordered by priority — first items survive at narrow widths.
29+
hints := []hint{
30+
{"q", "quit"},
31+
{"Tab", "focus"},
32+
{"[ ]", "tabs"},
33+
{"?", "help"},
34+
{"f", "filter"},
35+
{"d", "diff"},
36+
}
37+
38+
rightPadding := 1 // trailing space
39+
stylePadding := statusBarStyle.GetHorizontalPadding()
40+
avail := width - lipgloss.Width(left) - rightPadding - stylePadding
41+
right := ""
42+
for _, h := range hints {
43+
item := " " + helpKeyStyle.Render(h.key) + helpDescStyle.Render(" "+h.desc)
44+
if lipgloss.Width(right)+lipgloss.Width(item) > avail {
45+
break
46+
}
47+
right += item
48+
}
49+
right += " "
3050

31-
// Pad middle.
32-
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
51+
// Pad middle to fill the content area (total width minus style padding).
52+
contentWidth := width - stylePadding
53+
gap := contentWidth - lipgloss.Width(left) - lipgloss.Width(right)
3354
if gap < 0 {
3455
gap = 0
3556
}
@@ -47,9 +68,11 @@ func renderHelpOverlay(width, height int) string {
4768
Tab Cycle focus: hosts → output → input
4869
q / Ctrl+C Quit (when not typing)
4970
j / k Navigate host table up/down
50-
Enter Host table: expand host output
71+
Enter Host table: jump to host tab
5172
Command input: execute command
52-
Esc Close overlay / collapse expanded view
73+
Esc Close overlay / back to diff tab
74+
[ / ] Previous / next output tab
75+
1-9 Jump to output tab by number
5376
f Toggle host filter bar
5477
d Show diff for selected divergent host
5578
? Toggle this help
@@ -65,8 +88,8 @@ func renderHelpOverlay(width, height int) string {
6588
`
6689

6790
style := lipgloss.NewStyle().
68-
Width(width - 4).
69-
Height(height - 2).
91+
Width(width-4).
92+
Height(height-2).
7093
Padding(1, 2).
7194
Border(lipgloss.RoundedBorder()).
7295
BorderForeground(colorCyan)

internal/ui/dashboard/styles.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,23 @@ var (
7474

7575
helpDescStyle = lipgloss.NewStyle().
7676
Foreground(colorSubtle)
77+
78+
// Tab bar styles.
79+
tabActiveStyle = lipgloss.NewStyle().
80+
Foreground(colorWhite).
81+
Background(lipgloss.Color("#333333")).
82+
Bold(true).
83+
Padding(0, 1)
84+
85+
tabInactiveStyle = lipgloss.NewStyle().
86+
Foreground(colorSubtle).
87+
Padding(0, 1)
88+
89+
tabBarStyle = lipgloss.NewStyle().
90+
BorderBottom(true).
91+
BorderStyle(lipgloss.NormalBorder()).
92+
BorderForeground(colorSubtle)
93+
94+
tabScrollIndicator = lipgloss.NewStyle().
95+
Foreground(colorCyan)
7796
)

0 commit comments

Comments
 (0)