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
8 changes: 4 additions & 4 deletions experimental/ssh/internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ func Run(ctx context.Context, client *databricks.WorkspaceClient, opts ClientOpt

if opts.ServerMetadata == "" {
cmdio.LogString(ctx, "Uploading binaries...")
sp := cmdio.NewSpinner(ctx)
sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime())
sp.Update("Uploading binaries...")
err := UploadTunnelReleases(ctx, client, version, opts.ReleasesDir)
sp.Close()
Expand Down Expand Up @@ -581,7 +581,7 @@ func runSSHProxy(ctx context.Context, client *databricks.WorkspaceClient, server
}

func checkClusterState(ctx context.Context, client *databricks.WorkspaceClient, clusterID string, autoStart bool) error {
sp := cmdio.NewSpinner(ctx)
sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime())
defer sp.Close()
if autoStart {
sp.Update("Ensuring the cluster is running...")
Expand All @@ -605,7 +605,7 @@ func checkClusterState(ctx context.Context, client *databricks.WorkspaceClient,
// waitForJobToStart polls the task status until the SSH server task is in RUNNING state or terminates.
// Returns an error if the task fails to start or if polling times out.
func waitForJobToStart(ctx context.Context, client *databricks.WorkspaceClient, runID int64, taskStartupTimeout time.Duration) error {
sp := cmdio.NewSpinner(ctx)
sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime())
defer sp.Close()
sp.Update("Starting SSH server...")
var prevState jobs.RunLifecycleStateV2State
Expand Down Expand Up @@ -674,7 +674,7 @@ func ensureSSHServerIsRunning(ctx context.Context, client *databricks.WorkspaceC
return "", 0, "", fmt.Errorf("failed to submit and start ssh server job: %w", err)
}

sp := cmdio.NewSpinner(ctx)
sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime())
defer sp.Close()
sp.Update("Waiting for the SSH server to start...")
maxRetries := 30
Expand Down
4 changes: 2 additions & 2 deletions libs/cmdio/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,9 @@ func Spinner(ctx context.Context) chan string {
//
// The spinner automatically degrades in non-interactive terminals (no output).
// Context cancellation will automatically close the spinner.
func NewSpinner(ctx context.Context) *spinner {
func NewSpinner(ctx context.Context, opts ...SpinnerOption) *spinner {
c := fromContext(ctx)
return c.NewSpinner(ctx)
return c.NewSpinner(ctx, opts...)
}

type cmdIOType int
Expand Down
49 changes: 36 additions & 13 deletions libs/cmdio/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmdio

import (
"context"
"fmt"
"sync"
"time"

Expand All @@ -12,9 +13,10 @@ import (

// spinnerModel is the Bubble Tea model for the spinner.
type spinnerModel struct {
spinner bubblespinner.Model
suffix string
quitting bool
spinner bubblespinner.Model
suffix string
quitting bool
startTime time.Time // non-zero when elapsed time display is enabled
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend composing this as a "time spinner" that wraps this spinner and adds the time when View() is called. Then construct it with NewTimeSpinner.

Copy link
Contributor

@pietern pietern Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggestion is prob overkill.

Re: the feature, I think doing prefixing instead of suffixing makes more sense because it won't jump around as updates happen. Second, the time can be initialized at construction time instead of a separate method, and then the time prefix as an option to the spinner at construction time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! Replaced TrackElapsedTime() with a WithElapsedTime() construction option, so time starts at creation. Elapsed time is now a prefix so it doesn't jump around. Usage: cmdio.NewSpinner(ctx, cmdio.WithElapsedTime())

}

// Message types for spinner updates.
Expand All @@ -23,8 +25,18 @@ type (
quitMsg struct{}
)

// SpinnerOption configures spinner behavior.
type SpinnerOption func(*spinnerModel)

// WithElapsedTime enables an elapsed time prefix (MM:SS) on the spinner.
func WithElapsedTime() SpinnerOption {
return func(m *spinnerModel) {
m.startTime = time.Now()
}
}

// newSpinnerModel creates a new spinner model.
func newSpinnerModel() spinnerModel {
func newSpinnerModel(opts ...SpinnerOption) spinnerModel {
s := bubblespinner.New()
// Braille spinner frames with 200ms timing
s.Spinner = bubblespinner.Spinner{
Expand All @@ -33,11 +45,13 @@ func newSpinnerModel() spinnerModel {
}
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // Green

return spinnerModel{
spinner: s,
suffix: "",
quitting: false,
m := spinnerModel{
spinner: s,
}
for _, opt := range opts {
opt(&m)
}
return m
}

func (m spinnerModel) Init() tea.Cmd {
Expand Down Expand Up @@ -69,11 +83,16 @@ func (m spinnerModel) View() string {
return ""
}

var result string
if !m.startTime.IsZero() {
elapsed := time.Since(m.startTime)
result += fmt.Sprintf("%02d:%02d ", int(elapsed.Minutes()), int(elapsed.Seconds())%60)
}
result += m.spinner.View()
if m.suffix != "" {
return m.spinner.View() + " " + m.suffix
result += " " + m.suffix
}

return m.spinner.View()
return result
}

// spinner provides a structured interface for displaying progress indicators.
Expand Down Expand Up @@ -121,14 +140,18 @@ func (sp *spinner) Close() {
// sp := cmdio.NewSpinner(ctx)
// defer sp.Close()
// sp.Update("processing files")
func (c *cmdIO) NewSpinner(ctx context.Context) *spinner {
//
// Use WithElapsedTime() to show a running MM:SS prefix:
//
// sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime())
func (c *cmdIO) NewSpinner(ctx context.Context, opts ...SpinnerOption) *spinner {
// Don't show spinner if not interactive
if !c.capabilities.SupportsInteractive() {
return &spinner{p: nil, c: c, ctx: ctx}
}

// Create model and program
m := newSpinnerModel()
m := newSpinnerModel(opts...)
p := tea.NewProgram(
m,
tea.WithInput(nil),
Expand Down
Loading