Skip to content
Merged
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
50 changes: 21 additions & 29 deletions cmd/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 71 additions & 1 deletion util/terminal/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/signal"
"strings"

"golang.org/x/term"
)
Expand Down Expand Up @@ -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()
}

Expand All @@ -58,17 +72,73 @@ 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:
return
}
}()

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
}
Loading