Skip to content

Commit 4b4deb8

Browse files
chuongld20claude
andcommitted
feat: add CLI UX polish with spinner, table colors, and --no-color flag
Add internal/ui package with spinner (briandowns/spinner), color-coded status (fatih/color), aligned table output, and formatted success block for devbox up. Wire --no-color persistent flag on rootCmd. Resolves: ISS-35 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 151671e commit 4b4deb8

5 files changed

Lines changed: 231 additions & 16 deletions

File tree

cmd/devbox/main.go

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,22 @@ import (
99
"os"
1010
"path/filepath"
1111
"strings"
12-
"text/tabwriter"
1312
"time"
1413

1514
"github.com/junixlabs/devbox/internal/config"
1615
"github.com/junixlabs/devbox/internal/doctor"
1716
devboxerr "github.com/junixlabs/devbox/internal/errors"
1817
devboxssh "github.com/junixlabs/devbox/internal/ssh"
1918
"github.com/junixlabs/devbox/internal/tailscale"
19+
"github.com/junixlabs/devbox/internal/ui"
2020
"github.com/junixlabs/devbox/internal/workspace"
2121
"github.com/spf13/cobra"
2222
)
2323

2424
var (
2525
version = "0.1.0-dev"
2626
verbose bool
27+
noColor bool
2728
)
2829

2930
func main() {
@@ -41,9 +42,11 @@ func main() {
4142
level = slog.LevelDebug
4243
}
4344
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
45+
ui.SetNoColor(noColor)
4446
},
4547
}
4648
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logging")
49+
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable colored output")
4750

4851
rootCmd.AddCommand(initCmd())
4952
rootCmd.AddCommand(upCmd(wm))
@@ -104,14 +107,17 @@ func upCmd(wm workspace.Manager) *cobra.Command {
104107
cfg.Branch = b
105108
}
106109

110+
spin := ui.StartSpinner("Starting workspace...")
107111
ws, err := wm.Create(cfg.Name, project, cfg.Branch)
108112
if err != nil {
113+
ui.StopSpinner(spin, false)
109114
return fmt.Errorf("devbox up: %w", err)
110115
}
111116

112117
// Expose ports via Tailscale on the remote server
113118
sshExec, err := devboxssh.New()
114119
if err != nil {
120+
ui.StopSpinner(spin, false)
115121
return fmt.Errorf("devbox up: %w", err)
116122
}
117123
defer sshExec.Close()
@@ -122,18 +128,14 @@ func upCmd(wm workspace.Manager) *cobra.Command {
122128
fmt.Fprintf(os.Stderr, "Warning: failed to expose port %s (%d): %v\n", name, port, err)
123129
}
124130
}
131+
ui.StopSpinner(spin, true)
125132

126133
tsStatus, _ := tm.Status()
127-
128-
fmt.Printf("\nWorkspace %q created on %s\n\n", ws.Name, cfg.Server)
129-
fmt.Printf(" SSH: ssh %s\n", cfg.Server)
134+
url := ""
130135
if tsStatus != nil {
131-
fmt.Printf(" URL: %s\n", tailscale.WorkspaceURL(tsStatus.Hostname, tsStatus.TailnetName))
132-
}
133-
for name, port := range cfg.Ports {
134-
fmt.Printf(" Port: %s -> %d\n", name, port)
136+
url = tailscale.WorkspaceURL(tsStatus.Hostname, tsStatus.TailnetName)
135137
}
136-
fmt.Println()
138+
ui.PrintUpSuccess(ws.Name, cfg.Server, url, cfg.Ports)
137139

138140
return nil
139141
},
@@ -157,12 +159,16 @@ func stopCmd(wm workspace.Manager) *cobra.Command {
157159
return fmt.Errorf("devbox stop: %w", err)
158160
}
159161

162+
spin := ui.StartSpinner("Stopping workspace...")
163+
160164
if err := wm.Stop(name); err != nil {
165+
ui.StopSpinner(spin, false)
161166
return fmt.Errorf("devbox stop: %w", err)
162167
}
163168

164169
sshExec, err := devboxssh.New()
165170
if err != nil {
171+
ui.StopSpinner(spin, false)
166172
return fmt.Errorf("devbox stop: %w", err)
167173
}
168174
defer sshExec.Close()
@@ -174,6 +180,7 @@ func stopCmd(wm workspace.Manager) *cobra.Command {
174180
}
175181
}
176182

183+
ui.StopSpinner(spin, true)
177184
fmt.Printf("Workspace %q stopped\n", name)
178185
return nil
179186
},
@@ -197,14 +204,18 @@ func listCmd(wm workspace.Manager) *cobra.Command {
197204
return nil
198205
}
199206

200-
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
201-
fmt.Fprintln(w, "NAME\tSTATUS\tSERVER\tPORTS\tCREATED")
207+
headers := []string{"NAME", "STATUS", "SERVER", "PORTS", "CREATED"}
208+
rows := make([][]string, 0, len(workspaces))
202209
for _, ws := range workspaces {
203-
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
204-
ws.Name, ws.Status, ws.ServerHost,
205-
formatPorts(ws.Ports), timeAgo(ws.CreatedAt))
210+
rows = append(rows, []string{
211+
ws.Name,
212+
ui.StatusColor(ws.Status),
213+
ws.ServerHost,
214+
formatPorts(ws.Ports),
215+
timeAgo(ws.CreatedAt),
216+
})
206217
}
207-
w.Flush()
218+
ui.PrintTable(headers, rows)
208219

209220
return nil
210221
},
@@ -238,12 +249,16 @@ func destroyCmd(wm workspace.Manager) *cobra.Command {
238249
return fmt.Errorf("devbox destroy: %w", err)
239250
}
240251

252+
spin := ui.StartSpinner("Destroying workspace...")
253+
241254
if err := wm.Destroy(name); err != nil {
255+
ui.StopSpinner(spin, false)
242256
return fmt.Errorf("devbox destroy: %w", err)
243257
}
244258

245259
sshExec, err := devboxssh.New()
246260
if err != nil {
261+
ui.StopSpinner(spin, false)
247262
return fmt.Errorf("devbox destroy: %w", err)
248263
}
249264
defer sshExec.Close()
@@ -255,6 +270,7 @@ func destroyCmd(wm workspace.Manager) *cobra.Command {
255270
}
256271
}
257272

273+
ui.StopSpinner(spin, true)
258274
fmt.Printf("Workspace %q destroyed\n", name)
259275
return nil
260276
},

go.mod

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
module github.com/junixlabs/devbox
22

3-
go 1.24.2
3+
go 1.25.0
44

55
require (
66
github.com/spf13/cobra v1.10.2
77
gopkg.in/yaml.v3 v3.0.1
88
)
99

1010
require (
11+
github.com/briandowns/spinner v1.23.2 // indirect
12+
github.com/fatih/color v1.19.0 // indirect
1113
github.com/inconshreveable/mousetrap v1.1.0 // indirect
14+
github.com/mattn/go-colorable v0.1.14 // indirect
15+
github.com/mattn/go-isatty v0.0.20 // indirect
1216
github.com/spf13/pflag v1.0.9 // indirect
17+
golang.org/x/sys v0.42.0 // indirect
18+
golang.org/x/term v0.1.0 // indirect
1319
)

go.sum

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
1+
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
2+
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
13
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4+
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
5+
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
26
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
37
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
8+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
9+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
10+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
11+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
412
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
513
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
614
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
715
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
816
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
917
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
18+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
19+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
20+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
21+
golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
22+
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
1023
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1124
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1225
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

internal/ui/ui.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package ui
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"text/tabwriter"
7+
"time"
8+
9+
"github.com/briandowns/spinner"
10+
"github.com/fatih/color"
11+
"github.com/junixlabs/devbox/internal/workspace"
12+
)
13+
14+
var noColor bool
15+
16+
// SetNoColor disables colored output globally.
17+
func SetNoColor(v bool) {
18+
noColor = v
19+
color.NoColor = v
20+
}
21+
22+
// StatusColor returns the status string colored by state.
23+
func StatusColor(status workspace.Status) string {
24+
switch status {
25+
case workspace.StatusRunning:
26+
return color.GreenString(string(status))
27+
case workspace.StatusStopped:
28+
return color.YellowString(string(status))
29+
case workspace.StatusCreating:
30+
return color.CyanString(string(status))
31+
case workspace.StatusError:
32+
return color.RedString(string(status))
33+
default:
34+
return string(status)
35+
}
36+
}
37+
38+
// StartSpinner creates and starts a spinner with the given message.
39+
func StartSpinner(msg string) *spinner.Spinner {
40+
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
41+
s.Suffix = " " + msg
42+
if noColor {
43+
s.Writer = os.Stderr
44+
} else {
45+
s.Writer = os.Stderr
46+
s.Color("cyan")
47+
}
48+
s.Start()
49+
return s
50+
}
51+
52+
// StopSpinner stops the spinner and prints a final status symbol.
53+
func StopSpinner(s *spinner.Spinner, success bool) {
54+
s.Stop()
55+
if success {
56+
fmt.Fprintln(os.Stderr, color.GreenString("✓")+" "+s.Suffix[1:])
57+
} else {
58+
fmt.Fprintln(os.Stderr, color.RedString("✗")+" "+s.Suffix[1:])
59+
}
60+
}
61+
62+
// PrintTable prints aligned tabular output with colored status column.
63+
func PrintTable(headers []string, rows [][]string) {
64+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
65+
// Print header
66+
for i, h := range headers {
67+
if i > 0 {
68+
fmt.Fprint(w, "\t")
69+
}
70+
fmt.Fprint(w, color.New(color.Bold).Sprint(h))
71+
}
72+
fmt.Fprintln(w)
73+
74+
// Print rows
75+
for _, row := range rows {
76+
for i, cell := range row {
77+
if i > 0 {
78+
fmt.Fprint(w, "\t")
79+
}
80+
fmt.Fprint(w, cell)
81+
}
82+
fmt.Fprintln(w)
83+
}
84+
w.Flush()
85+
}
86+
87+
// PrintUpSuccess prints the success output block for devbox up.
88+
func PrintUpSuccess(name, server, url string, ports map[string]int) {
89+
fmt.Println()
90+
fmt.Printf(" %s Workspace %s created on %s\n\n",
91+
color.GreenString("✓"), color.CyanString(name), server)
92+
fmt.Printf(" %s ssh %s\n", color.New(color.Bold).Sprint("SSH:"), server)
93+
if url != "" {
94+
fmt.Printf(" %s %s\n", color.New(color.Bold).Sprint("URL:"), url)
95+
}
96+
fmt.Printf(" %s zed ssh://%s//workspace\n", color.New(color.Bold).Sprint("Zed:"), server)
97+
for pname, port := range ports {
98+
fmt.Printf(" %s %s -> %d\n", color.New(color.Bold).Sprint("Port:"), pname, port)
99+
}
100+
fmt.Println()
101+
}

internal/ui/ui_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package ui
2+
3+
import (
4+
"testing"
5+
6+
"github.com/fatih/color"
7+
"github.com/junixlabs/devbox/internal/workspace"
8+
)
9+
10+
func TestStatusColor_NoColor(t *testing.T) {
11+
SetNoColor(true)
12+
defer SetNoColor(false)
13+
14+
tests := []struct {
15+
status workspace.Status
16+
want string
17+
}{
18+
{workspace.StatusRunning, "running"},
19+
{workspace.StatusStopped, "stopped"},
20+
{workspace.StatusCreating, "creating"},
21+
{workspace.StatusError, "error"},
22+
{workspace.Status("unknown"), "unknown"},
23+
}
24+
25+
for _, tt := range tests {
26+
got := StatusColor(tt.status)
27+
if got != tt.want {
28+
t.Errorf("StatusColor(%q) with NoColor = %q, want %q", tt.status, got, tt.want)
29+
}
30+
}
31+
}
32+
33+
func TestStatusColor_WithColor(t *testing.T) {
34+
SetNoColor(false)
35+
defer SetNoColor(true)
36+
37+
// With color enabled, output should contain ANSI escape codes
38+
got := StatusColor(workspace.StatusRunning)
39+
if got == "running" {
40+
t.Error("StatusColor(running) with color should contain ANSI codes")
41+
}
42+
if len(got) <= len("running") {
43+
t.Error("StatusColor(running) with color should be longer than plain text")
44+
}
45+
}
46+
47+
func TestSetNoColor(t *testing.T) {
48+
SetNoColor(true)
49+
if !color.NoColor {
50+
t.Error("SetNoColor(true) should set color.NoColor to true")
51+
}
52+
53+
SetNoColor(false)
54+
if color.NoColor {
55+
t.Error("SetNoColor(false) should set color.NoColor to false")
56+
}
57+
}
58+
59+
func TestPrintTable(t *testing.T) {
60+
// Smoke test — just ensure it doesn't panic
61+
SetNoColor(true)
62+
defer SetNoColor(false)
63+
64+
headers := []string{"NAME", "STATUS", "SERVER"}
65+
rows := [][]string{
66+
{"my-ws", StatusColor(workspace.StatusRunning), "server1"},
67+
{"other-ws", StatusColor(workspace.StatusStopped), "server2"},
68+
}
69+
PrintTable(headers, rows)
70+
}
71+
72+
func TestPrintUpSuccess(t *testing.T) {
73+
// Smoke test — just ensure it doesn't panic
74+
SetNoColor(true)
75+
defer SetNoColor(false)
76+
77+
ports := map[string]int{"app": 8080, "db": 3306}
78+
PrintUpSuccess("test-ws", "my-server", "https://example.com", ports)
79+
}

0 commit comments

Comments
 (0)