From 08b3dae588d0349942f109a9199ef026cfbe47ba Mon Sep 17 00:00:00 2001 From: imagineer99 Date: Thu, 19 Mar 2026 19:31:00 +0000 Subject: [PATCH 01/16] refactor(studio): unify setup terminal output style and add verbose setup mode --- studio/backend/run.py | 19 +- studio/backend/startup_banner.py | 107 ++++++++++ studio/install_python_stack.py | 77 ++++--- studio/setup.ps1 | 53 +++-- studio/setup.sh | 335 ++++++++++++------------------- unsloth_cli/commands/studio.py | 17 +- 6 files changed, 348 insertions(+), 260 deletions(-) create mode 100644 studio/backend/startup_banner.py diff --git a/studio/backend/run.py b/studio/backend/run.py index 5388d3148b..92cbb054ae 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -20,6 +20,7 @@ sys.path.insert(0, str(backend_dir)) from loggers import get_logger +from startup_banner import print_studio_access_banner logger = get_logger(__name__) @@ -223,19 +224,11 @@ def _run(): if not silent: display_host = _resolve_external_ip() if host == "0.0.0.0" else host - - print("") - print("=" * 50) - print(f"🦥 Open your web browser, and enter http://localhost:{port}") - print("=" * 50) - print("") - print("=" * 50) - print(f"🦥 Unsloth Studio is running on port {port}") - print(f" Local Access: http://localhost:{port}") - print(f" Worldwide Web Address: http://{display_host}:{port}") - print(f" API: http://{display_host}:{port}/api") - print(f" Health: http://{display_host}:{port}/api/health") - print("=" * 50) + print_studio_access_banner( + port=port, + bind_host=host, + display_host=display_host, + ) return app diff --git a/studio/backend/startup_banner.py b/studio/backend/startup_banner.py new file mode 100644 index 0000000000..62ebd40b14 --- /dev/null +++ b/studio/backend/startup_banner.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Terminal banner for Studio startup. + +Stdlib only — safe to import without the rest of the backend (no structlog/uvicorn). +""" + +from __future__ import annotations + +import os +import sys + + +def stdout_supports_color() -> bool: + """True if we should emit ANSI colors.""" + if os.environ.get("NO_COLOR", "").strip(): + return False + if os.environ.get("FORCE_COLOR", "").strip(): + return True + try: + return sys.stdout.isatty() + except Exception: + return False + + +def print_port_in_use_notice(original_port: int, new_port: int) -> None: + """Message when the requested port is taken and another is chosen.""" + msg = f"Port {original_port} is in use, using port {new_port} instead." + if stdout_supports_color(): + print(f"\033[38;5;245m{msg}\033[0m") + else: + print(msg) + + +def print_studio_access_banner( + *, + port: int, + bind_host: str, + display_host: str, +) -> None: + """Pretty-print URLs after the server is listening (beginner-friendly).""" + use_color = stdout_supports_color() + dim = "\033[38;5;245m" + title = "\033[38;5;150m" + local_url_style = "\033[38;5;108;1m" + secondary = "\033[38;5;109m" + reset = "\033[0m" + + def style(text: str, code: str) -> str: + return f"{code}{text}{reset}" if use_color else text + + local_url = f"http://127.0.0.1:{port}" + alt_local = f"http://localhost:{port}" + external_url = f"http://{display_host}:{port}" + listen_all = bind_host in ("0.0.0.0", "::") + loopback_bind = bind_host in ("127.0.0.1", "localhost", "::1") + api_base = local_url if listen_all or loopback_bind else external_url + + lines: list[str] = [ + "", + style("🦥 Unsloth Studio is running", title), + style("─" * 52, dim), + style(" On this machine — open this in your browser:", dim), + style(f" {local_url}", local_url_style), + style(f" (same as {alt_local})", dim), + ] + + if listen_all and display_host not in ( + "127.0.0.1", + "localhost", + "::1", + "0.0.0.0", + "::", + ): + lines.extend( + [ + "", + style(" From another device on your network / to share:", dim), + style(f" {external_url}", secondary), + ] + ) + elif not listen_all and bind_host not in ("127.0.0.1", "localhost", "::1"): + lines.extend( + [ + "", + style(" Bound address:", dim), + style(f" {external_url}", secondary), + ] + ) + + lines.extend( + [ + "", + style(" API & health:", dim), + style(f" {api_base}/api", secondary), + style(f" {api_base}/api/health", secondary), + style("─" * 52, dim), + style( + " Tip: if you are on the same computer, use the Local link above.", + dim, + ), + "", + ] + ) + + print("\n".join(lines)) diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index a141c64425..4503c26a63 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -25,6 +25,7 @@ # ── Verbosity control ────────────────────────────────────────────────────────── # By default the installer shows a minimal progress bar (one line, in-place). # Set UNSLOTH_VERBOSE=1 in the environment to restore full per-step output: +# CLI: unsloth studio setup --verbose # Linux/Mac: UNSLOTH_VERBOSE=1 ./studio/setup.sh # Windows: $env:UNSLOTH_VERBOSE="1" ; .\studio\setup.ps1 VERBOSE: bool = os.environ.get("UNSLOTH_VERBOSE", "0") == "1" @@ -45,14 +46,17 @@ ) # ── Color support ────────────────────────────────────────────────────── +# Same logic as startup_banner: NO_COLOR disables, FORCE_COLOR or TTY enables. -def _enable_colors() -> bool: - """Try to enable ANSI color support. Returns True if available.""" - if not hasattr(sys.stdout, "fileno"): +def _stdout_supports_color() -> bool: + """True if we should emit ANSI colors (matches startup_banner).""" + if os.environ.get("NO_COLOR", "").strip(): return False + if os.environ.get("FORCE_COLOR", "").strip(): + return True try: - if not os.isatty(sys.stdout.fileno()): + if not sys.stdout.isatty(): return False except Exception: return False @@ -61,24 +65,26 @@ def _enable_colors() -> bool: import ctypes kernel32 = ctypes.windll.kernel32 - # Enable ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x0004) on stdout - handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + handle = kernel32.GetStdHandle(-11) mode = ctypes.c_ulong() kernel32.GetConsoleMode(handle, ctypes.byref(mode)) kernel32.SetConsoleMode(handle, mode.value | 0x0004) - return True except Exception: return False - return True # Unix terminals support ANSI by default + return True + +_HAS_COLOR = _stdout_supports_color() -# Colors disabled — Colab and most CI runners render ANSI fine, but plain output -# is cleaner in the notebook cell. Re-enable by setting _HAS_COLOR = _enable_colors() -_HAS_COLOR = False + +# Column layout — matches setup.sh step() helper: +# 2-space indent, 15-char label (dim), then value. +_LABEL = "deps" +_COL = 15 def _green(msg: str) -> str: - return f"\033[92m{msg}\033[0m" if _HAS_COLOR else msg + return f"\033[38;5;108m{msg}\033[0m" if _HAS_COLOR else msg def _cyan(msg: str) -> str: @@ -89,21 +95,38 @@ def _red(msg: str) -> str: return f"\033[91m{msg}\033[0m" if _HAS_COLOR else msg -def _progress(label: str) -> None: - """Print an in-place progress bar for the current install step. +def _dim(msg: str) -> str: + return f"\033[38;5;245m{msg}\033[0m" if _HAS_COLOR else msg + + +def _title(msg: str) -> str: + return f"\033[38;5;150m{msg}\033[0m" if _HAS_COLOR else msg + + +_RULE = "\u2500" * 52 - Uses only stdlib (sys.stdout) — no extra packages required. - In VERBOSE mode this is a no-op; per-step labels are printed by run() instead. - """ + +def _step(label: str, value: str, color_fn = None) -> None: + """Print a single step line in the column format.""" + if color_fn is None: + color_fn = _green + print(f" {_dim(label)}{' ' * (_COL - len(label))}{color_fn(value)}") + + +def _progress(label: str) -> None: + """Print an in-place progress bar aligned to the step column layout.""" global _STEP _STEP += 1 if VERBOSE: - return # verbose mode: run() already printed the label + return width = 20 filled = int(width * _STEP / _TOTAL) bar = "=" * filled + "-" * (width - filled) - end = "\n" if _STEP >= _TOTAL else "" # newline only on the final step - sys.stdout.write(f"\r[{bar}] {_STEP:2}/{_TOTAL} {label:<40}{end}") + pad = " " * (_COL - len(_LABEL)) + end = "\n" if _STEP >= _TOTAL else "" + sys.stdout.write( + f"\r {_dim(_LABEL)}{pad}[{bar}] {_STEP:2}/{_TOTAL} {label:<20}{end}" + ) sys.stdout.flush() @@ -112,14 +135,14 @@ def run( ) -> subprocess.CompletedProcess[bytes]: """Run a command; on failure print output and exit.""" if VERBOSE: - print(f" {label}...") + _step(_LABEL, f"{label}...", _dim) result = subprocess.run( cmd, stdout = subprocess.PIPE if quiet else None, stderr = subprocess.STDOUT if quiet else None, ) if result.returncode != 0: - print(_red(f"❌ {label} failed (exit code {result.returncode}):")) + _step("error", f"{label} failed (exit code {result.returncode})", _red) if result.stdout: print(result.stdout.decode(errors = "replace")) sys.exit(result.returncode) @@ -267,7 +290,7 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None: text = True, ) if result.returncode != 0: - print(_red(f" ⚠️ Could not find package {package_name}, skipping patch")) + _step(_LABEL, f"package {package_name} not found, skipping patch", _dim) return location = None @@ -277,11 +300,11 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None: break if not location: - print(_red(f" ⚠️ Could not determine location of {package_name}")) + _step(_LABEL, f"could not locate {package_name}", _dim) return dest = Path(location) / relative_path - print(_cyan(f" Patching {dest.name} in {package_name}...")) + _step(_LABEL, f"patching {dest.name} in {package_name}...", _dim) download_file(url, dest) @@ -293,6 +316,8 @@ def install_python_stack() -> int: _STEP = 0 _TOTAL = 10 if IS_WINDOWS else 11 + + # 1. Upgrade pip (needed even with uv as fallback and for bootstrapping) _progress("pip upgrade") run("Upgrading pip", [sys.executable, "-m", "pip", "install", "--upgrade", "pip"]) @@ -422,7 +447,7 @@ def install_python_stack() -> int: stderr = subprocess.DEVNULL, ) - print(_green("✅ Python dependencies installed")) + _step(_LABEL, "installed") return 0 diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 7091920ab9..e73b04ad7c 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -245,12 +245,35 @@ function Find-VsBuildTools { return $null } +# ───────────────────────────────────────────── +# Output style helpers (matches setup.sh visual tone) +# ───────────────────────────────────────────── +$Rule = "────────────────────────────────────────────────────" + +function Write-Step { + param( + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][string]$Value, + [string]$Color = "Green" + ) + Write-Host (" {0,-15}" -f $Label) -NoNewline -ForegroundColor DarkGray + Write-Host $Value -ForegroundColor $Color +} + +function Write-Substep { + param( + [Parameter(Mandatory = $true)][string]$Message, + [string]$Color = "DarkGray" + ) + Write-Host (" {0,-15}{1}" -f "", $Message) -ForegroundColor $Color +} + # ───────────────────────────────────────────── # Banner # ───────────────────────────────────────────── -Write-Host "+==============================================+" -ForegroundColor Green -Write-Host "| Unsloth Studio Setup (Windows) |" -ForegroundColor Green -Write-Host "+==============================================+" -ForegroundColor Green +Write-Host "" +Write-Host " 🦥 Unsloth Studio Setup" -ForegroundColor Green +Write-Host " $Rule" -ForegroundColor DarkGray # ========================================================================== # PHASE 1: System-level prerequisites (winget installs, env vars) @@ -1383,21 +1406,21 @@ if ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { # -- Summary -- Write-Host "" if ($BuildOk -and (Test-Path $LlamaServerBin)) { - Write-Host "[OK] llama-server built at $LlamaServerBin" -ForegroundColor Green + Write-Step "llama.cpp" "built at $LlamaServerBin" $QuantizeBin = Join-Path $BuildDir "bin\Release\llama-quantize.exe" if (Test-Path $QuantizeBin) { - Write-Host "[OK] llama-quantize available for GGUF export" -ForegroundColor Green + Write-Step "llama-quantize" "built" } - Write-Host " Build time: ${totalMin}m ${totalSec}s" -ForegroundColor Cyan + Write-Step "build time" "${totalMin}m ${totalSec}s" "DarkGray" } else { # Check alternate paths (some cmake generators don't use Release subdir) $altBin = Join-Path $BuildDir "bin\llama-server.exe" if ($BuildOk -and (Test-Path $altBin)) { - Write-Host "[OK] llama-server built at $altBin" -ForegroundColor Green - Write-Host " Build time: ${totalMin}m ${totalSec}s" -ForegroundColor Cyan + Write-Step "llama.cpp" "built at $altBin" + Write-Step "build time" "${totalMin}m ${totalSec}s" "DarkGray" } else { - Write-Host "[FAILED] llama.cpp build failed at step: $FailedStep (${totalMin}m ${totalSec}s)" -ForegroundColor Red - Write-Host " To retry: delete $LlamaCppDir and re-run setup." -ForegroundColor Yellow + Write-Step "llama.cpp" "build failed at step: $FailedStep (${totalMin}m ${totalSec}s)" "Red" + Write-Substep "To retry: delete $LlamaCppDir and re-run setup." "Yellow" exit 1 } } @@ -1407,10 +1430,6 @@ if ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { # Done # ============================================ Write-Host "" -Write-Host "+===============================================+" -ForegroundColor Green -Write-Host "| Setup Complete! |" -ForegroundColor Green -Write-Host "| |" -ForegroundColor Green -Write-Host "| Launch with: |" -ForegroundColor Green -Write-Host "| unsloth studio -H 0.0.0.0 -p 8888 |" -ForegroundColor Green -Write-Host "| |" -ForegroundColor Green -Write-Host "+===============================================+" -ForegroundColor Green +Write-Host " $Rule" -ForegroundColor DarkGray +Write-Host " Unsloth Studio Installed" -ForegroundColor Green +Write-Step "launch" "unsloth studio -H 0.0.0.0 -p 8888" diff --git a/studio/setup.sh b/studio/setup.sh index 3e56aebf38..e70face973 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -6,137 +6,152 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +RULE=$(printf '\342\224\200%.0s' {1..52}) + +# ── Colors (same palette as startup_banner / install_python_stack) ── +if [ -n "${NO_COLOR:-}" ]; then + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then + C_TITLE=$'\033[38;5;150m' + C_DIM=$'\033[38;5;245m' + C_OK=$'\033[38;5;108m' + C_WARN=$'\033[38;5;136m' + C_ERR=$'\033[91m' + C_RST=$'\033[0m' +else + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +fi + +# ── Output helpers ── +# Consistent column layout: 2-space indent, 15-char label (fits llama-quantize), then value. +# Usage: step