n4nAI

Adding progress bars and spinners to a Go LLM CLI

Step-by-step guide to adding progress bars and spinners to a Go CLI for LLM API calls, covering streaming, spinners, and clean interrupts.

n4n Team3 min read666 words

Audio narration

Coming soon — every post will get a voice note here.

A barebones Go CLI that calls an LLM feels broken the moment a request takes more than a second. Adding a go cli progress bar llm users can actually see turns a hanging process into a tool they trust, especially when streaming tokens or waiting on provider fallback.

Step 1: Scaffold a minimal Go CLI that talks to an LLM endpoint

Start with a module and two terminal UI dependencies: briandowns/spinner for indeterminate waits and schollz/progressbar/v3 for bounded progress.

mkdir llmcli && cd llmcli
go mod init llmcli
go get github.com/briandowns/spinner@latest
go get github.com/schollz/progressbar/v3@latest

The CLI should read a prompt and an API base URL from the environment. Keep the HTTP client boring and never log the key. A separated client struct makes testing and later swapping the transport trivial.

package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"net/http"
	"os"
)

type client struct {
	baseURL string
	apiKey  string
	http    *http.Client
}

func main() {
	prompt := flag.String("p", "", "prompt to send")
	flag.Parse()
	if *prompt == "" {
		fmt.Fprintln(os.Stderr, "provide -p")
		os.Exit(1)
	}
	c := client{
		baseURL: os.Getenv("LLM_BASE_URL"),
		apiKey:  os.Getenv("LLM_API_KEY"),
		http:    &http.Client{},
	}
	if c.baseURL == "" || c.apiKey == "" {
		fmt.Fprintln(os.Stderr, "set LLM_BASE_URL and LLM_API_KEY")
		os.Exit(1)
	}
	_ = c // used in later steps
}

The LLM_BASE_URL should be an OpenAI-compatible /v1/chat/completions endpoint. This keeps the go cli progress bar llm code portable across providers and gateways without rewriting request shapes.

Step 2: Add a spinner for the latency window before first byte

Streaming LLMs have a time-to-first-token (TTFT) that can range from 100 ms to several seconds depending on model load. A static cursor makes users think the process hung. Spin the cursor only until the first token arrives or the full response completes.

import (
	"github.com/briandowns/spinner"
	"time"
)

func (c *client) complete(prompt string) (string, error) {
	s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
	s.Suffix = " contacting provider"
	s.Start()
	defer s.Stop()

	body, _ := json.Marshal(map[string]any{
		"model":    "gpt-3.5-turbo",
		"messages": []map[string]string{{"role": "user", "content": prompt}},
		"stream":   false,
	})
	req, _ := http.NewRequest("POST", c.baseURL+"/chat/completions", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")
	resp, err := c.http.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	// decode non-streaming response...
	return "", nil
}

The defer s.Stop() guarantees the spinner clears even on error. Never leave a spinner running after you start printing real output; it corrupts the terminal line and makes logs unreadable.

Why not just print dots

Dots imply progress magnitude that does not exist. A spinner communicates “working, unknown duration” honestly. Use a bar only when you have a real bound, such as a max_tokens ceiling.

Step 3: Stream tokens and drive a progress bar from token estimates

When you enable stream: true, you receive Server-Sent Events. Set max_tokens to a fixed ceiling and treat that as the bar’s total. A rough heuristic: one token ≈ four bytes of English text. Update the bar per chunk.

import (
	"bufio"
	"strings"
)

func (c *client) stream(prompt string) error {
	bar := progressbar.DefaultBytes(200*4, "generating")
	defer bar.Finish()

	body, _ := json.Marshal(map[string]any{
		"model":      "gpt-3.5-turbo",
		"messages":   []map[string]string{{"role": "user", "content": prompt}},
		"stream":     true,
		"max_tokens": 200,
	})
	req, _ := http.NewRequest("POST", c.baseURL+"/chat/completions", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	scanner := bufio.NewScanner(resp.Body)
	scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // SSE lines can be long
	for scanner.Scan() {
		line := scanner.Text()
		if !strings.HasPrefix(line, "data: ") {
			continue
		}
		data := strings.TrimPrefix(line, "data: ")
		if data == "[DONE]" {
			break
		}
		var chunk struct {
			Choices []struct {
				Delta struct{ Content string `json:"content"` }
			} `json:"choices"`
		}
		if json.Unmarshal([]byte(data), &chunk) != nil {
			continue
		}
		if len(chunk.Choices) > 0 {
			content := chunk.Choices[0].Delta.Content
			if content != "" {
				fmt.Print(content)
				bar.Add(len(content))
			}
		}
	}
	return scanner.Err()
}

This go cli progress bar llm technique gives users a visible ceiling. If the model stops early, bar.Finish() snaps to 100% and clears the line. The byte-based bar is approximate, but it is far better than a silent wait.

Step 4: Manage concurrency and signals so the UI never corrupts

A Ctrl-C during a stream must stop the spinner or bar and restore the terminal. Use signal.Notify and a context.Context to cancel the HTTP request.

import (
	"context"
	"os"
	"os/signal"
	"syscall"
)

func (c *client) streamSafe(prompt string) error {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, syscall.SIGINT)
	go func() {
		<-sig
		cancel()
	}()

	body, _ := json.Marshal(map[string]any{
		"model":      "gpt-3.5-turbo",
		"messages":   []map[string]string{{"role": "user", "content": prompt}},
		"stream":     true,
		"max_tokens": 200,
	})
	req, _ := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	bar := progressbar.DefaultBytes(200*4, "generating")
	defer bar.Finish()
	scanner := bufio.NewScanner(resp.Body)
	for scanner.Scan() {
		if ctx.Err() != nil {
			break
		}
		// ... same SSE parsing as Step 3
	}
	if ctx.Err() != nil {
		fmt.Fprintln(os.Stderr, "\ninterrupted")
		return ctx.Err()
	}
	return nil
}

Wrap the spinner and bar creation inside the same context scope. If cancelled, bar.Finish() still writes the final state; call s.Stop() explicitly in the signal handler to avoid a stuck glyph. Restoring the terminal is non-negotiable for a CLI that others will pipe or script.

Step 5: Point the CLI at a gateway with fallback

If you point the CLI at n4n.ai, the OpenAI-compatible endpoint addresses 240+ models and automatic fallback when a provider is degraded means your CLI can skip custom retry loops; the spinner just covers the latency window. Set LLM_BASE_URL=https://api.n4n.ai/v1 and the same code works without branch logic for provider errors.

That single integration point also honors client routing directives and forwards provider cache-control hints, so repeated prompts hit caches and return faster—your spinner spins less. The go cli progress bar llm design stays unchanged; only the environment variable differs.

Step 6: Verify the experience end to end

Build and run against a test prompt:

go build -o llmcli .
LLM_BASE_URL=https://api.openai.com/v1 LLM_API_KEY=sk-... ./llmcli -p "Explain TCP slow start in one paragraph"

Success criteria:

  1. A spinner appears immediately and disappears when the first token prints (or after a non-streaming response returns).
  2. A progress bar labeled generating fills left-to-right as text streams.
  3. Pressing Ctrl-C prints interrupted and returns to a clean prompt with no leftover animation frames.
  4. With max_tokens set to 200, the bar reaches full even if the model emits fewer tokens, then clears.

Test the non-streaming path by flipping stream to false in the request body; the spinner should cover the whole wait and stop before any text prints. If you see garbled output, ensure you call bar.Finish() and s.Stop() on every exit path. Terminal width under 80 columns may truncate the bar; progressbar handles this, but verify in a narrow pane.

The go cli progress bar llm pattern is now complete: honest indeterminate state, bounded progress during streaming, and clean shutdown. Ship it.

Tagsgocliuxllm

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All building cli tools for llm apis posts →