diff --git a/cmd/pnf/main.go b/cmd/pnf/main.go index f242b15..94e8575 100644 --- a/cmd/pnf/main.go +++ b/cmd/pnf/main.go @@ -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) @@ -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 { diff --git a/examples/local_mode_demo.py b/examples/local_mode_demo.py index ea3e0a9..431f655 100644 --- a/examples/local_mode_demo.py +++ b/examples/local_mode_demo.py @@ -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}") diff --git a/internal/tui/app.go b/internal/tui/app.go index 5cc09f0..3f74b9b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -2,6 +2,8 @@ package tui import ( "fmt" + "os/exec" + "runtime" "strings" "time" @@ -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 @@ -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), } } @@ -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) } @@ -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 ") @@ -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, " ") diff --git a/mise.toml b/mise.toml index f396d78..b1a06a4 100644 --- a/mise.toml +++ b/mise.toml @@ -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\")}}" diff --git a/sdk/python/src/p95/run.py b/sdk/python/src/p95/run.py index d907b37..5a348cd 100644 --- a/sdk/python/src/p95/run.py +++ b/sdk/python/src/p95/run.py @@ -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. @@ -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) @@ -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 @@ -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( @@ -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() diff --git a/sdk/python/src/p95/server.py b/sdk/python/src/p95/server.py index 4b7cbf8..3a93dc1 100644 --- a/sdk/python/src/p95/server.py +++ b/sdk/python/src/p95/server.py @@ -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, @@ -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) @@ -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 @@ -74,10 +79,15 @@ 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 @@ -85,6 +95,22 @@ def start(self) -> str: 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 @@ -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, @@ -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: @@ -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/ 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