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
19 changes: 2 additions & 17 deletions Tools/Controller/cmd/controller/remote_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions Tools/Controller/cmd/controller/remote_tui_startup.go
Original file line number Diff line number Diff line change
@@ -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
}
99 changes: 99 additions & 0 deletions Tools/Controller/cmd/controller/remote_tui_startup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions Tools/Controller/cmd/controller/startup_ansi_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build !windows

package main

import "io"

func enableStartupANSI(io.Writer) {}
24 changes: 24 additions & 0 deletions Tools/Controller/cmd/controller/startup_ansi_windows.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
12 changes: 11 additions & 1 deletion Tools/Controller/cmd/controller/startup_screen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions Tools/Controller/cmd/controller/startup_screen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
4 changes: 4 additions & 0 deletions Tools/Controller/cmd/tui-preview/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions Tools/Controller/internal/tui/startup.go
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading