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
12 changes: 9 additions & 3 deletions cmd/pnf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,8 @@ func tuiCmd(args []string) {
port := listener.Addr().(*net.TCPAddr).Port
listener.Close()

// Start local server in background (no web UI needed for TUI mode)
srv, err := server.New(*logdir, nil)
// Start local server in background, with web UI so 'o' can open it in a browser
srv, err := server.New(*logdir, web.DistFS())
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating server: %v\n", err)
os.Exit(1)
Expand All @@ -389,8 +389,14 @@ func tuiCmd(args []string) {
apiClient = client.NewComposite(localClient, remoteClient)
}

// Determine which dashboard to open with 'o'
dashboardURL := fmt.Sprintf("http://127.0.0.1:%d", port)
if *apiKey != "" {
dashboardURL = "https://p.ninetyfive.gg"
}

// Create and run TUI
app := tui.New(apiClient)
app := tui.New(apiClient, dashboardURL)
p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseAllMotion())

if _, err := p.Run(); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion examples/local_mode_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def simulate_training():
"optimizer": "adam",
"epochs": 50,
},
start_server=True, # Automatically start viewer and open browser
start_tui=True, # Automatically open the TUI in a new terminal window
) as run:
print(f"Run ID: {run.id}")
print(f"Run directory: {run.logdir}")
Expand Down
35 changes: 29 additions & 6 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package tui

import (
"fmt"
"os/exec"
"runtime"
"strings"
"time"

Expand All @@ -17,9 +19,10 @@ import (

// App is the main TUI application model
type App struct {
client client.API
width int
height int
client client.API
dashboardURL string
width int
height int

// Main view (unified lazygit-style layout)
main views.MainModel
Expand All @@ -30,11 +33,12 @@ type App struct {
}

// New creates a new TUI application
func New(apiClient client.API) App {
func New(apiClient client.API, dashboardURL string) App {
zone.NewGlobal()
return App{
client: apiClient,
main: views.NewMain(apiClient),
client: apiClient,
dashboardURL: dashboardURL,
main: views.NewMain(apiClient),
}
}

Expand Down Expand Up @@ -63,6 +67,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c", "q":
return a, tea.Quit
case "o":
if a.dashboardURL != "" {
openURL(a.dashboardURL)
}
case "?":
// Show help (could be implemented)
}
Expand Down Expand Up @@ -122,6 +130,20 @@ func (a App) View() string {
))
}

// openURL opens a URL in the default browser.
func openURL(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
cmd = exec.Command("xdg-open", url)
}
_ = cmd.Start()
}

// renderHeader renders the application header
func (a App) renderHeader() string {
title := styles.Header.Render(" p95 ")
Expand All @@ -145,6 +167,7 @@ func (a App) renderStatusBar() string {
styles.HelpKey.Render("t") + styles.HelpDesc.Render(" switch style"),
styles.HelpKey.Render("space") + styles.HelpDesc.Render(" compare run"),
styles.HelpKey.Render("c") + styles.HelpDesc.Render(" clear compare"),
styles.HelpKey.Render("o") + styles.HelpDesc.Render(" open web"),
}

helpText := strings.Join(help, " ")
Expand Down
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ run = "./bin/pnf serve --logdir {{option(name=\"logdir\", default=\"./logs\")}}

[tasks.tui]
description = "Run the interactive TUI"
depends = ["build-go"]
depends = ["build-go-release"]
raw = true
run = "./bin/pnf tui --logdir {{option(name=\"logdir\", default=\"./logs\")}}"

Expand Down
20 changes: 18 additions & 2 deletions sdk/python/src/p95/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def __init__(
capture_system: Optional[bool] = None,
# Server option (local mode only)
start_server: bool = False,
start_tui: bool = False,
):
"""
Initialize a new run.
Expand All @@ -90,6 +91,8 @@ def __init__(
capture_system: Whether to capture system information
start_server: Automatically start the p95 viewer server and open the
browser (local mode only). The server stops when the run ends.
start_tui: Automatically open the p95 TUI in a new terminal window
(local mode only). The TUI manages its own internal server.

Raises:
ValidationError: If project format is invalid (remote mode)
Expand Down Expand Up @@ -133,6 +136,7 @@ def __init__(
self._remote_batcher: Optional["MetricsBatcher"] = None
self._server_manager: Optional["ServerManager"] = None
self._start_server = start_server
self._start_tui = start_tui

# Capture info before creating run
self._git_info = None
Expand Down Expand Up @@ -181,8 +185,17 @@ def _init_local_mode(self) -> None:
# Print local mode info
print(f"p95: Logging to {self._local_writer.run_dir}")

# Start the viewer server if requested
if self._start_server:
# start_tui launches after training completes (see _finalize)
if self._start_tui:
from p95.server import ServerManager

self._server_manager = ServerManager(
logdir=self._config.logdir,
open_tui=True,
project=self._project,
run_id=self._run_id,
)
elif self._start_server:
from p95.server import ServerManager

self._server_manager = ServerManager(
Expand Down Expand Up @@ -506,6 +519,9 @@ def _finalize(self, status: str, error: Optional[str] = None) -> None:
self._local_writer.close()
# Note: We don't stop the server here - it keeps running (TensorBoard-style)
# so users can view results after training ends
# Launch TUI after the script fully exits
if self._server_manager is not None and self._start_tui:
atexit.register(self._server_manager.start)
else:
# Flush and stop remote batcher
self._remote_batcher.flush()
Expand Down
54 changes: 43 additions & 11 deletions sdk/python/src/p95/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def __init__(
port: int = DEFAULT_PORT,
host: str = DEFAULT_HOST,
open_browser: bool = True,
open_tui: bool = False,
binary_path: Optional[str] = None,
keep_running: bool = False,
project: Optional[str] = None,
Expand All @@ -54,6 +55,9 @@ def __init__(
port: Port to run the server on
host: Host to bind the server to
open_browser: Whether to open the browser automatically
open_tui: Whether to open the TUI in a new terminal window instead of
the browser. When True, open_browser is ignored and no HTTP server
is started (the TUI manages its own internal server).
binary_path: Explicit path to the pnf binary (auto-discovered if not provided)
keep_running: If True, don't stop the server when the manager is garbage collected
project: Project name (used as fallback if run_id not provided)
Expand All @@ -63,6 +67,7 @@ def __init__(
self.port = port
self.host = host
self.open_browser = open_browser
self.open_tui = open_tui
self.binary_path = binary_path
self.keep_running = keep_running
self.project = project
Expand All @@ -74,17 +79,38 @@ def __init__(

def start(self) -> str:
"""
Start the server.
Start the server (or TUI).

When open_tui=True, launches the TUI in a new terminal window and returns
a placeholder URL. The TUI manages its own internal server so no HTTP
server is started.

Returns:
The URL where the server is running
The URL where the HTTP server is running, or a logdir:// URI when
open_tui=True

Raises:
ServerError: If the binary cannot be found or the server fails to start
"""
if self._started:
return self.url

# Find the binary (needed for both serve and tui paths)
binary = self._find_binary()
if not binary:
raise ServerError(
"Could not find 'pnf' binary. Please ensure it's installed and in your PATH, "
"or specify the path explicitly with server_binary='/path/to/pnf'"
)

# TUI path: open a new terminal window running `pnf tui` and return early.
# The TUI starts its own internal server so we don't need to start pnf serve.
if self.open_tui:
self._open_tui(binary)
self._started = True
print(f"p95: Launched TUI for {self.logdir}")
return self.url

# Check if a server is already running on this port
if self._is_port_in_use():
# Server already running - just set the active run so UI navigates
Expand All @@ -95,14 +121,6 @@ def start(self) -> str:
self._started = True
return self.url

# Find the binary
binary = self._find_binary()
if not binary:
raise ServerError(
"Could not find 'pnf' binary. Please ensure it's installed and in your PATH, "
"or specify the path explicitly with server_binary='/path/to/pnf'"
)

# Build the command (we handle browser opening ourselves for project-specific URLs)
cmd = [
binary,
Expand Down Expand Up @@ -192,6 +210,12 @@ def _open_browser(self) -> None:

webbrowser.open(self.run_url)

def _open_tui(self, binary: str) -> None:
"""Launch `pnf tui` in the current terminal (blocks until the user exits)."""
logdir = os.path.abspath(self.logdir)
cmd_parts = [binary, "tui", f"--logdir={logdir}"]
subprocess.run(cmd_parts)

def _set_active_run(self) -> None:
"""Tell the server about the active run so the UI can navigate to it."""
if not self.run_id:
Expand Down Expand Up @@ -258,12 +282,20 @@ def _find_binary(self) -> Optional[str]:
if bundled_binary:
return bundled_binary

# Check system PATH
binary_name = (
f"{self.BINARY_NAME}.exe"
if platform.system() == "Windows"
else self.BINARY_NAME
)

# Check ./bin/<binary> relative to CWD before PATH — in development the Go
# binary is built to ./bin/pnf, and the Python entry-point script of the
# same name would otherwise shadow it via shutil.which.
cwd_bin_binary = os.path.join(os.getcwd(), "bin", binary_name)
if os.path.isfile(cwd_bin_binary) and os.access(cwd_bin_binary, os.X_OK):
return cwd_bin_binary

# Check system PATH
path_binary = shutil.which(binary_name)
if path_binary:
return path_binary
Expand Down
Loading