diff --git a/cmd/model.go b/cmd/model.go index 0fe9722c..6e4c86bb 100644 --- a/cmd/model.go +++ b/cmd/model.go @@ -257,47 +257,39 @@ func getLatestRamalamaVersion() (string, error) { func provisionRamalama() error { guest := lima.New(host.New()) - log.Println("Installing AI model runner...") - - // step 1: Install ramalama binary (uses normal scrolling output) - installScript := `set -e + script := `set -e export PATH="$HOME/.local/bin:$PATH" + +# install ramalama curl -fsSL https://ramalama.ai/install.sh | bash -` - if err := guest.Run("sh", "-c", installScript); err != nil { - return fmt.Errorf("error installing AI model runner: %w", err) - } - // step 2: Pull container images (uses alternate screen for progress bars) - pullScript := `set -e +# pull ramalama container images docker pull quay.io/ramalama/ramalama docker pull quay.io/ramalama/ramalama-rag -` - if err := terminal.WithAltScreen(func() error { - log.Println() - log.Println(" Colima - AI Model Runner Setup") - log.Println(" ===============================") - log.Println() - log.Println(" Pulling container images...") - log.Println(" This may take a few minutes depending on your internet connection.") - log.Println() - return guest.RunInteractive("sh", "-c", pullScript) - }); err != nil { - return fmt.Errorf("error pulling container images: %w", err) - } - - log.Println("Configuring AI model runner...") - // step 3: Post-install setup (uses normal scrolling output) - setupScript := `set -e +# fix ownership of persistent data dir and symlink to expected location sudo chown -R $(id -u):$(id -g) /var/lib/ramalama mkdir -p "$HOME/.local/share" ln -sfn /var/lib/ramalama "$HOME/.local/share/ramalama" ` - if err := guest.Run("sh", "-c", setupScript); err != nil { - return fmt.Errorf("error configuring AI model runner: %w", err) + + log.Println("installing AI model runner...") + + if err := terminal.WithAltScreen(func() error { + return guest.RunInteractive("sh", "-c", script) + }, + "", + " Colima - AI Model Runner Setup", + " ===============================", + "", + " Installing AI model runner.", + " This may take several minutes depending on your internet connection speed.", + ); err != nil { + return fmt.Errorf("error setting up AI model runner: %w", err) } + log.Println("AI model runner installed") + // mark as provisioned if err := store.Set(func(s *store.Store) { s.RamalamaProvisioned = true diff --git a/util/terminal/terminal.go b/util/terminal/terminal.go index 2c3e265d..a061128f 100644 --- a/util/terminal/terminal.go +++ b/util/terminal/terminal.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "os/signal" + "strings" "golang.org/x/term" ) @@ -42,8 +43,21 @@ func ExitAltScreen() { // WithAltScreen runs the provided function in the alternate screen buffer. // The main terminal content is preserved and restored after the function completes. // Handles Ctrl-C to ensure the terminal is restored even on interrupt. -func WithAltScreen(fn func() error) error { +// +// If header lines are provided, they are joined with newlines and displayed as a +// fixed header at the top of the screen. The command output scrolls below the header. +// The number of header lines is computed automatically based on newlines and terminal width. +func WithAltScreen(fn func() error, header ...string) error { + hasHeader := len(header) > 0 + var headerText string + if hasHeader { + headerText = strings.Join(header, "\n") + } + if !isTerminal { + if hasHeader { + fmt.Println(headerText) + } return fn() } @@ -58,6 +72,9 @@ func WithAltScreen(fn func() error) error { go func() { select { case <-sigCh: + if hasHeader { + fmt.Print("\033[r") // Reset scroll region + } ExitAltScreen() os.Exit(1) case <-done: @@ -65,10 +82,63 @@ func WithAltScreen(fn func() error) error { } }() + if hasHeader { + // Get terminal dimensions + width, height, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + width = 80 + height = 24 + } + + // Print the header + fmt.Println(headerText) + + // Calculate number of lines used by the header + headerLines := countLines(headerText, width) + 1 // +1 for padding + + // Set scroll region from headerLines+1 to bottom + // This keeps the header fixed while everything below scrolls + fmt.Printf("\033[%d;%dr", headerLines+1, height) + + // Move cursor to the first line of the scroll region + fmt.Printf("\033[%d;1H", headerLines+1) + } + err := fn() + if hasHeader { + // Reset scroll region + fmt.Print("\033[r") + } + close(done) ExitAltScreen() return err } + +// countLines calculates the number of terminal lines a string will occupy, +// accounting for newlines and line wrapping based on terminal width. +func countLines(s string, termWidth int) int { + if termWidth <= 0 { + termWidth = 80 + } + + lines := 1 + currentLineLen := 0 + + for _, ch := range s { + if ch == '\n' { + lines++ + currentLineLen = 0 + } else { + currentLineLen++ + if currentLineLen >= termWidth { + lines++ + currentLineLen = 0 + } + } + } + + return lines +}