diff --git a/Tools/Controller/cmd/controller/remote_tui.go b/Tools/Controller/cmd/controller/remote_tui.go index 6951e5278..b9a008fea 100644 --- a/Tools/Controller/cmd/controller/remote_tui.go +++ b/Tools/Controller/cmd/controller/remote_tui.go @@ -1447,9 +1447,6 @@ func runRemoteTUIContext( consoleOptions *tuiConsoleOptions, syncNavigation bool, ) error { - if terminalOutput(stdout) { - fmt.Fprintln(stdout, " Connecting terminal to controller...") - } ctx, cancel := context.WithCancel(parent) defer cancel() client := newRemoteTUIIPC(ctx, address, auth) @@ -1463,11 +1460,8 @@ func runRemoteTUIContext( defer instanceLease.Close() probeContext, probeCancel := context.WithTimeout(ctx, 8*time.Second) - initial, err := client.Snapshot(probeContext) - initialReceivedAt := time.Now() + initial, initialReceivedAt, engine, err := remoteTUIBootstrap(probeContext, client.Snapshot, client.CommandEngine) if err == nil { - var engine *shell.Engine - engine, err = client.CommandEngine(probeContext) if err == nil { if syncNavigation { cursorContext, cursorCancel := context.WithTimeout(ctx, 3*time.Second) @@ -1479,17 +1473,8 @@ func runRemoteTUIContext( } } probeCancel() - remoteHostUI := remoteUISettingsWire{} - haveRemoteHostUI := false - remoteRF := appconfig.DefaultRFConfig() settingsContext, settingsCancel := context.WithTimeout(ctx, 4*time.Second) - if value, settingsErr := client.UISettings(settingsContext); settingsErr == nil { - remoteHostUI = value - haveRemoteHostUI = true - } - if value, rfErr := client.RFPresentation(settingsContext); rfErr == nil { - remoteRF = value - } + remoteHostUI, haveRemoteHostUI, remoteRF := remoteTUIStartupPreferences(settingsContext, client) settingsCancel() clientUI := func() appconfig.UI { diff --git a/Tools/Controller/cmd/controller/remote_tui_startup.go b/Tools/Controller/cmd/controller/remote_tui_startup.go new file mode 100644 index 000000000..f6a32c20f --- /dev/null +++ b/Tools/Controller/cmd/controller/remote_tui_startup.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "errors" + "time" + + "pccontroller.local/controller/internal/appconfig" + "pccontroller.local/controller/internal/control" + "pccontroller.local/controller/internal/shell" +) + +func remoteTUIBootstrap(ctx context.Context, snapshot func(context.Context) (control.Snapshot, error), catalog func(context.Context) (*shell.Engine, error)) (control.Snapshot, time.Time, *shell.Engine, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + type snapshotResult struct { + value control.Snapshot + at time.Time + err error + } + type catalogResult struct { + value *shell.Engine + err error + } + snapshots, catalogs := make(chan snapshotResult, 1), make(chan catalogResult, 1) + go func() { value, err := snapshot(ctx); snapshots <- snapshotResult{value, time.Now(), err} }() + go func() { value, err := catalog(ctx); catalogs <- catalogResult{value, err} }() + var initial snapshotResult + select { + case initial = <-snapshots: + case <-ctx.Done(): + return control.Snapshot{}, time.Time{}, nil, ctx.Err() + } + var commands catalogResult + select { + case commands = <-catalogs: + case <-ctx.Done(): + return initial.value, initial.at, nil, errors.Join(initial.err, ctx.Err()) + } + return initial.value, initial.at, commands.value, errors.Join(initial.err, commands.err) +} + +func remoteTUIStartupPreferences(ctx context.Context, client *remoteTUIIPC) (remoteUISettingsWire, bool, appconfig.RFConfig) { + type uiResult struct { + value remoteUISettingsWire + err error + } + type rfResult struct { + value appconfig.RFConfig + err error + } + uis, rfs := make(chan uiResult, 1), make(chan rfResult, 1) + go func() { value, err := client.UISettings(ctx); uis <- uiResult{value, err} }() + go func() { value, err := client.RFPresentation(ctx); rfs <- rfResult{value, err} }() + var ui uiResult + select { + case ui = <-uis: + case <-ctx.Done(): + return remoteUISettingsWire{}, false, appconfig.DefaultRFConfig() + } + rf := appconfig.DefaultRFConfig() + select { + case result := <-rfs: + if result.err == nil { + rf = result.value + } + case <-ctx.Done(): + } + return ui.value, ui.err == nil, rf +} diff --git a/Tools/Controller/cmd/controller/remote_tui_startup_test.go b/Tools/Controller/cmd/controller/remote_tui_startup_test.go new file mode 100644 index 000000000..fa729d25a --- /dev/null +++ b/Tools/Controller/cmd/controller/remote_tui_startup_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "pccontroller.local/controller/internal/appconfig" + "sync" + "testing" + "time" + + "pccontroller.local/controller/internal/control" + "pccontroller.local/controller/internal/shell" +) + +func TestRemoteTUIBootstrapIndependentReadsOverlap(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var mu sync.Mutex + arrived := 0 + ready := make(chan struct{}) + barrier := func(ctx context.Context) error { + mu.Lock() + arrived++ + if arrived == 2 { + close(ready) + } + mu.Unlock() + select { + case <-ready: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + engine := shell.New(10) + initial, at, commands, err := remoteTUIBootstrap(ctx, func(ctx context.Context) (control.Snapshot, error) { + return control.Snapshot{Connected: true}, barrier(ctx) + }, func(ctx context.Context) (*shell.Engine, error) { return engine, barrier(ctx) }) + if err != nil || !initial.Connected || at.IsZero() || commands != engine { + t.Fatalf("bootstrap did not run both reads concurrently: %v", err) + } +} + +func TestRemoteTUIBootstrapCancellationAndFailuresRemainVisible(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, _, _, err := remoteTUIBootstrap(ctx, func(ctx context.Context) (control.Snapshot, error) { + <-ctx.Done() + return control.Snapshot{}, ctx.Err() + }, func(ctx context.Context) (*shell.Engine, error) { <-ctx.Done(); return nil, ctx.Err() }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation was hidden: %v", err) + } + want := errors.New("catalog unavailable") + _, _, _, err = remoteTUIBootstrap(context.Background(), func(context.Context) (control.Snapshot, error) { return control.Snapshot{}, nil }, func(context.Context) (*shell.Engine, error) { return nil, want }) + if !errors.Is(err, want) { + t.Fatalf("bootstrap failure was hidden: %v", err) + } +} + +func TestRemoteTUIStartupPreferencesReadTogetherWithoutWrites(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var mu sync.Mutex + arrived := 0 + ready := make(chan struct{}) + client := &remoteTUIIPC{} + client.callFn = func(ctx context.Context, method string, _ any, target any) error { + var value any + switch method { + case "controller.ui.config.get": + value = remoteUISettingsWire{AppTitle: "Cinema"} + case "controller.rf.presentation": + config := appconfig.DefaultRFConfig() + config.Learning.TimeoutSeconds = 45 + value = remoteRFPresentationWire{Config: config} + default: + return errors.New("unexpected startup mutation or request") + } + mu.Lock() + arrived++ + if arrived == 2 { + close(ready) + } + mu.Unlock() + select { + case <-ready: + case <-ctx.Done(): + return ctx.Err() + } + encoded, _ := json.Marshal(value) + return json.Unmarshal(encoded, target) + } + ui, known, rf := remoteTUIStartupPreferences(ctx, client) + if !known || ui.AppTitle != "Cinema" || rf.Learning.TimeoutSeconds != 45 { + t.Fatalf("parallel preferences missing: known=%v ui=%s timeout=%d", known, ui.AppTitle, rf.Learning.TimeoutSeconds) + } +} diff --git a/Tools/Controller/cmd/controller/startup_ansi_other.go b/Tools/Controller/cmd/controller/startup_ansi_other.go new file mode 100644 index 000000000..a9910d3e1 --- /dev/null +++ b/Tools/Controller/cmd/controller/startup_ansi_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +import "io" + +func enableStartupANSI(io.Writer) {} diff --git a/Tools/Controller/cmd/controller/startup_ansi_windows.go b/Tools/Controller/cmd/controller/startup_ansi_windows.go new file mode 100644 index 000000000..03435c0c5 --- /dev/null +++ b/Tools/Controller/cmd/controller/startup_ansi_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package main + +import ( + "io" + "os" + + "golang.org/x/sys/windows" +) + +// Bubble Tea normally enables VT after bootstrap. The startup frame precedes +// it, so classic conhost needs the same capability enabled before that frame. +func enableStartupANSI(output io.Writer) { + file, ok := output.(*os.File) + if !ok { + return + } + var mode uint32 + handle := windows.Handle(file.Fd()) + if windows.GetConsoleMode(handle, &mode) == nil { + _ = windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) + } +} diff --git a/Tools/Controller/cmd/controller/startup_screen.go b/Tools/Controller/cmd/controller/startup_screen.go index 48293b18c..548b47092 100644 --- a/Tools/Controller/cmd/controller/startup_screen.go +++ b/Tools/Controller/cmd/controller/startup_screen.go @@ -6,10 +6,12 @@ import ( "os" "time" + "github.com/charmbracelet/x/term" "github.com/mattn/go-isatty" "pccontroller.local/controller/internal/consolewindow" "pccontroller.local/controller/internal/productidentity" "pccontroller.local/controller/internal/startupmode" + "pccontroller.local/controller/internal/tui" ) func terminalOutput(output io.Writer) bool { @@ -21,17 +23,25 @@ func writeTUIStartup(output io.Writer, terminal bool, title string) { if !terminal { return } - fmt.Fprintf(output, "\n %s\n Starting terminal interface...\n\n", title) + width := 80 + if file, ok := output.(*os.File); ok { + if columns, _, err := term.GetSize(file.Fd()); err == nil { + width = columns + } + } + fmt.Fprint(output, tui.StartupFrame(title, "Starting terminal interface…", width)) } func prepareStartupPresentation(args []string, output io.Writer) { if startupmode.TUI(args) { + enableStartupANSI(output) writeTUIStartup(output, terminalOutput(output), productidentity.DefaultAppTitle()) return } if startupmode.Command(args) == "web" { explicit := startupmode.BackgroundWeb(args) if !explicit && terminalOutput(output) && terminalOutput(os.Stdin) { + enableStartupANSI(output) writeTUIStartup(output, true, productidentity.DefaultAppTitle()) return } diff --git a/Tools/Controller/cmd/controller/startup_screen_test.go b/Tools/Controller/cmd/controller/startup_screen_test.go index 6fa93b353..3a02585aa 100644 --- a/Tools/Controller/cmd/controller/startup_screen_test.go +++ b/Tools/Controller/cmd/controller/startup_screen_test.go @@ -19,4 +19,7 @@ func TestTUIStartupScreenIsImmediateAndNeverWrittenToPipes(t *testing.T) { if strings.Contains(output.String(), "%") { t.Fatal("invented startup progress") } + if !strings.Contains(output.String(), "\x1b[") || !strings.Contains(output.String(), "TERMINAL") { + t.Fatal("unstyled startup splash") + } } diff --git a/Tools/Controller/cmd/tui-preview/main.go b/Tools/Controller/cmd/tui-preview/main.go index 3532db6b6..a2f7ead93 100644 --- a/Tools/Controller/cmd/tui-preview/main.go +++ b/Tools/Controller/cmd/tui-preview/main.go @@ -19,6 +19,10 @@ func main() { height := flag.Int("height", 38, "render height") all := flag.Bool("all", false, "render every page separated by a form feed") flag.Parse() + if *pageName == "startup" { + fmt.Print(tui.StartupFrame("PCController", "Starting terminal interface…", *width)) + return + } if *all { for page := tui.PageDashboard; page <= tui.PageConsole; page++ { if page != tui.PageDashboard { diff --git a/Tools/Controller/internal/tui/startup.go b/Tools/Controller/internal/tui/startup.go new file mode 100644 index 000000000..57cd658e6 --- /dev/null +++ b/Tools/Controller/internal/tui/startup.go @@ -0,0 +1,48 @@ +package tui + +import ( + "io" + "strings" + "unicode" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" +) + +// StartupFrame is a pure, immediate presentation: no configuration, transport, +// timers or device work. It shares the full TUI palette without changing the +// global renderer's colour profile or emitting a fictitious progress value. +func StartupFrame(title, phase string, width int) string { + if width < 8 { + return "" + } + renderer := lipgloss.NewRenderer(io.Discard) + renderer.SetColorProfile(termenv.TrueColor) + clean := func(value string) string { + return strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return ' ' + } + return r + }, ansi.Strip(value))) + } + title, phase = clean(title), clean(phase) + cardWidth := min(62, width-2) + contentWidth := max(2, cardWidth-4) + bright := renderer.NewStyle().Foreground(colorAccent).Bold(true) + muted := renderer.NewStyle().Foreground(colorMuted) + accent := renderer.NewStyle().Foreground(colorAccent2) + content := bright.Render(ansi.Truncate(title, contentWidth, "…")) + "\n\n" + muted.Render(ansi.Truncate(phase, contentWidth, "…")) + if contentWidth >= 32 { + mark := strings.Join([]string{accent.Render(" │ │ │ "), accent.Render(" ╭─┴─┴─┴─╮ "), + accent.Render("─┤ ") + bright.Render("PC") + accent.Render(" ├─"), accent.Render(" ╰─┬─┬─┬─╯ "), accent.Render(" │ │ │ ")}, "\n") + captionWidth := contentWidth - 14 + caption := "\n" + bright.Render(ansi.Truncate(title, captionWidth, "…")) + "\n" + + accent.Render("TERMINAL") + "\n\n" + muted.Render(ansi.Truncate(phase, captionWidth, "…")) + content = lipgloss.JoinHorizontal(lipgloss.Top, mark, " ", caption) + } + card := renderer.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colorPanel). + Padding(1).Width(contentWidth).Render(content) + return "\n" + renderer.PlaceHorizontal(width, lipgloss.Center, card) + "\n" +} diff --git a/Tools/Controller/internal/tui/startup_test.go b/Tools/Controller/internal/tui/startup_test.go new file mode 100644 index 000000000..2bb08510d --- /dev/null +++ b/Tools/Controller/internal/tui/startup_test.go @@ -0,0 +1,46 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +func TestStartupFrameUsesSharedColorsAndFitsNarrowTerminals(t *testing.T) { + for _, width := range []int{8, 20, 34, 60, 80, 120} { + frame := StartupFrame("Workshop controller", "Starting terminal interface…", width) + if !strings.Contains(frame, "\x1b[") { + t.Fatal("startup frame lost color") + } + for _, line := range strings.Split(frame, "\n") { + if ansi.StringWidth(line) > width { + t.Fatalf("frame exceeds %d columns: %q", width, line) + } + } + } + frame := ansi.Strip(StartupFrame("Workshop controller", "Starting terminal interface…", 80)) + if !strings.Contains(frame, "─┤ PC ├─") { + t.Fatal("chip monogram escaped its fixed slot") + } + for _, caption := range []string{"Workshop controller", "TERMINAL", "Starting terminal interface"} { + if !strings.Contains(frame, caption) { + t.Fatalf("missing %q", caption) + } + } + if strings.Contains(frame, "%") || strings.Contains(frame, "Connected") { + t.Fatal("startup invents progress or connection") + } +} + +func TestStartupFrameSanitizesCaptionsAndDoesNotChangeGlobalProfile(t *testing.T) { + profile := lipgloss.ColorProfile() + frame := ansi.Strip(StartupFrame("\x1b[31mMy\ncontroller", "Loading\tterminal", 80)) + if !strings.Contains(frame, "My controller") || !strings.Contains(frame, "Loading terminal") { + t.Fatal("unsafe caption retained") + } + if lipgloss.ColorProfile() != profile { + t.Fatal("startup changed shared style") + } +} diff --git a/docs/verification/tui-startup-presentation.md b/docs/verification/tui-startup-presentation.md new file mode 100644 index 000000000..550eaa81c --- /dev/null +++ b/docs/verification/tui-startup-presentation.md @@ -0,0 +1,33 @@ +# Immediate styled TUI startup + +The former splash was literal unstyled text emitted at `main` entry, before +Bubble Tea initialized terminal colors. It did not use the TUI palette, and +classic Windows conhost did not yet have VT processing enabled. The remote +attachment path also printed another unstyled connecting caption and performed +independent snapshot/catalog and UI/RF preference reads sequentially before the +first TUI frame. + +The immediate startup frame now shares the TUI's cyan/violet palette and rounded +panel geometry, with a fixed-slot chip emblem and terminal identity. Rendering is +pure and does not wait for settings, network, UART, welcome audio or TUI setup. +It fits the current terminal width, collapses on narrow terminals, sanitizes +caption controls, and never claims a percentage or connected board. Explicit +interactive presentation uses a private true-color renderer; ambient build-log +`NO_COLOR`/`TERM=dumb` does not make it white. Classic Windows VT processing is +enabled before the first frame. Redirected CLI output, helper processes and +explicit `web --no-tui` remain free of the splash. + +The duplicate remote connecting text is removed. Independent snapshot/catalog +reads run concurrently under the existing eight-second context; UI/RF settings +reads run concurrently under the existing four-second context. Authentication, +ownership, navigation event-cursor handshake, runtime safety, original errors +and cancellation remain required. Board connection is still managed by the TUI's +existing asynchronous lifecycle; startup decoration never opens or actuates it. +There is no animation worker or minimum splash delay to race the TUI or keep it +waiting. Bubble Tea replaces the frame through its normal alternate-screen handoff. + +Focused tests cover width bounds, ANSI colors, fixed chip slots, sanitized copy, +redirected-output silence, parallel read barriers, cancellation and failure +retention. `tui-preview --page startup` renders the actual presentation without +hardware access. A rendered preview is not a screenshot of the production console +or a measured cold-launch timing claim.