diff --git a/.gitattributes b/.gitattributes index 14a2225a7..923ad11da 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,9 @@ pkg/apm/webrtc/**/*.h linguist-vendored pkg/apm/webrtc/**/*.m linguist-vendored pkg/apm/webrtc/**/*.inc linguist-vendored pkg/portaudio/pa_src/** linguist-vendored + +# TUI visual-regression frames are compared byte for byte against what the +# renderers emit, which is always LF. Without this a checkout under +# core.autocrlf — the default on Windows, including the CI runners — rewrites +# them to CRLF and every frame mismatches. +cmd/lk/testdata/vrt/*.txt text eol=lf diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d83a379d3..7e3f51670 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -64,6 +64,27 @@ jobs: exit 1 fi + # The Test step above already fails on a VRT mismatch, but testify's diff of + # a full terminal frame is unreadable and says nothing about how to fix it. + # Re-recording here turns the failure into the diff of what actually moved + # on screen, and catches what the test alone cannot: a frame left behind by + # a fixture that no longer exists (make update-vrt clears the directory, so + # an orphan shows up as a deletion). Runs even when the tests failed, so the + # actionable message is never buried behind them. + - name: Verify TUI reference frames are up to date + if: always() && matrix.os == 'ubuntu-latest' + shell: bash + run: | + set -euo pipefail + make update-vrt + if [ -n "$(git status --porcelain -- cmd/lk/testdata/vrt)" ]; then + git --no-pager diff -- cmd/lk/testdata/vrt + git status --porcelain -- cmd/lk/testdata/vrt + echo "::error::The TUI renders differently than the recorded reference frames." + echo "::error::If the change is intended, run 'make update-vrt' and commit cmd/lk/testdata/vrt." + exit 1 + fi + - name: Upload test log uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: always() diff --git a/Makefile b/Makefile index 651af7355..583b4a58b 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,10 @@ # extract the vendored C/C++ (PortAudio + WebRTC APM). # # `make install` puts it on $GOBIN with a `livekit-cli` alias for the legacy -# binary name. Releases use .goreleaser.yaml, not this file. +# binary name. `make update-vrt` re-records the TUI reference frames. Releases +# use .goreleaser.yaml, not this file. + +.PHONY: all build install update-vrt clean ifeq (,$(shell go env GOBIN)) GOBIN := $(shell go env GOPATH)/bin @@ -16,17 +19,39 @@ ifeq ($(OS),Windows_NT) EXE := .exe endif +all: build + # pa_src holds the PortAudio C source the cgo build links against; the submodule # init makes this work from a fresh clone (and under CodeQL, whose checkout may # skip submodules). ALSA headers (libasound2-dev) come from CodeQL's automatic -# dependency installation on Linux. -./bin/lk$(EXE): +# dependency installation on Linux. Keyed on a file the submodule brings with it +# so an already-populated checkout doesn't pay for a git round trip every build. +pkg/portaudio/pa_src/CMakeLists.txt: git submodule update --init --recursive + +# Phony, rather than a rule for ./bin/lk: as a file target the binary counted as +# up to date the moment it existed, so every source change after the first was +# silently skipped and `make install` shipped a stale binary. go build tracks +# staleness itself — over the Go sources, the vendored C/C++ and the build flags +# alike — and exits immediately when there is nothing to do, which no +# prerequisite list written here could match. +build: pkg/portaudio/pa_src/CMakeLists.txt CGO_ENABLED=1 go build -o ./bin/lk$(EXE) ./cmd/lk -install: ./bin/lk$(EXE) +install: build cp ./bin/lk$(EXE) "$(GOBIN)/lk$(EXE)" ln -sf "$(GOBIN)/lk$(EXE)" "$(GOBIN)/livekit-cli$(EXE)" +# Re-records the TUI visual regression frames in cmd/lk/testdata/vrt from the +# current renderers. Run it after an intentional UI change and commit the diff, +# which is the review artifact: it shows exactly what moved on screen. +# +# The existing frames are cleared first so a fixture that was renamed or removed +# takes its frame with it, instead of leaving one behind that nothing renders any +# more. Nothing is lost if the run then fails: git restore cmd/lk/testdata/vrt. +update-vrt: + rm -f cmd/lk/testdata/vrt/*.txt + UPDATE_TUI_VRT=1 go test ./cmd/lk -run TestVRT -count=1 + clean: rm -rf ./bin diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index 1a5f912ac..a6ec5e6f4 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -28,7 +28,7 @@ import ( "strings" "time" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/crane" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -490,7 +490,7 @@ func initAgent(ctx context.Context, cmd *cli.Command) error { huh.NewOption("Node.js", "node"), ). Value(&lang). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). Run(); err != nil { return err } @@ -580,7 +580,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { Value(&useProject). Affirmative("Yes"). Negative("No, select another..."). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil { return err } @@ -722,7 +722,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { Title("Agent deploying. Would you like to view logs?"). Description("You can view logs later with `lk agent logs`"). Value(&viewLogs). - WithTheme(util.Theme), + WithTheme(util.FormTheme()), ), ).Run(); err != nil { return err @@ -748,7 +748,7 @@ func createAgentConfig(ctx context.Context, cmd *cli.Command) error { fmt.Sprintf("Config file [%s] file already exists. Overwrite?", tomlFilename), ). Value(&overwriteVal). - WithTheme(util.Theme), + WithTheme(util.FormTheme()), ), ). Run(); err != nil { @@ -1256,7 +1256,7 @@ func deleteAgent(ctx context.Context, cmd *cli.Command) error { util.Confirm(). Title(fmt.Sprintf("Are you sure you want to delete agent %s?", agentMsg)). Value(&confirmDelete). - WithTheme(util.Theme), + WithTheme(util.FormTheme()), ), ).Run(); err != nil { return err @@ -1376,7 +1376,7 @@ func resolveDeployAttributes(ctx context.Context, cmd *cli.Command) (map[string] util.Confirm(). Title("Continue deploying anyway?"). Value(&proceed). - WithTheme(util.Theme), + WithTheme(util.FormTheme()), ), ).Run(); err != nil { return nil, err @@ -1658,7 +1658,7 @@ func updateAgentSecrets(ctx context.Context, cmd *cli.Command) error { util.Confirm(). Title(fmt.Sprintf("This will remove all existing secrets. Are you sure you want to proceed [%s]?", agentID)). Value(&confirmOverwrite). - WithTheme(util.Theme), + WithTheme(util.FormTheme()), ), ).Run(); err != nil { return err @@ -1768,7 +1768,7 @@ func selectAgent(ctx context.Context, cmd *cli.Command, excludeEmptyVersion bool Title("Select an agent"). Options(agentNames...). Value(&selectedAgent). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). Run(); err != nil { return "", err } @@ -1991,7 +1991,7 @@ func resolveRegion(cmd *cli.Command, settingsMap map[string]string, title string Title(title). Options(options...). Value(®ion). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). Run(); err != nil { return "", err } @@ -2033,7 +2033,7 @@ func confirmRegionResidency(cmd *cli.Command, region, dataRegion string, warnReg Affirmative("Deploy"). Negative("Cancel"). Value(&confirmed))). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). Run(); err != nil { return err } diff --git a/cmd/lk/app.go b/cmd/lk/app.go index 8a691e2e2..8d1872941 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -25,8 +25,8 @@ import ( "regexp" "strings" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" "github.com/urfave/cli/v3" "github.com/livekit/livekit-cli/v2/pkg/bootstrap" @@ -171,7 +171,7 @@ func requireProjectWithOpts(ctx context.Context, cmd *cli.Command, opts ...loadO Value(&useDefault). Affirmative("Yes"). Negative("No, select another..."). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil { return ctx, fmt.Errorf("failed to confirm project: %w", err) } @@ -213,7 +213,7 @@ func selectProject(ctx context.Context, cmd *cli.Command) (context.Context, erro Description("To use a different project, run `lk cloud auth` to add credentials"). Options(options...). Value(&project). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil { return nil, fmt.Errorf("no project selected: %w", err) } @@ -227,7 +227,7 @@ func selectProject(ctx context.Context, cmd *cli.Command) (context.Context, erro if err = huh.NewForm(huh.NewGroup(util.Confirm(). Title("No local projects found. Authenticate one?"). Value(&shouldAuth). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil { return nil, fmt.Errorf("no project selected: %w", err) } @@ -320,7 +320,7 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { templateSelect := huh.NewSelect[string](). Title("Select Template"). Value(&templateURL). - WithTheme(util.Theme) + WithTheme(util.FormTheme()) var options []huh.Option[string] for _, t := range templateOptions { if t.IsHidden { @@ -376,12 +376,12 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { } return nil }). - WithTheme(util.Theme)) + WithTheme(util.FormTheme())) } if len(preinstallPrompts) > 0 { group := huh.NewGroup(preinstallPrompts...) if err := huh.NewForm(group). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). RunWithContext(ctx); err != nil { return err } @@ -433,7 +433,7 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { if err := huh.NewForm(huh.NewGroup(util.Confirm(). Title("Install dependencies?"). Value(&install). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil { return err } @@ -579,7 +579,7 @@ func instantiateEnv(ctx context.Context, cmd *cli.Command, rootPath string, addl Placeholder(oldValue). Prompt(""). Value(&newValue). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). Run(); err != nil || newValue == "" { return oldValue, err } @@ -702,7 +702,7 @@ func runTask(ctx context.Context, cmd *cli.Command) error { Title("Select Task"). Options(options...). Value(&taskName). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil { return err } diff --git a/cmd/lk/cloud.go b/cmd/lk/cloud.go index afd42adc7..29fd21cfe 100644 --- a/cmd/lk/cloud.go +++ b/cmd/lk/cloud.go @@ -23,7 +23,7 @@ import ( "net/url" "time" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/pkg/browser" "github.com/urfave/cli/v3" @@ -261,7 +261,7 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { Title("What is the name of this device?"). Prompt(""). Value(&cliConfig.DeviceName). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil { return err } @@ -314,7 +314,7 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { if err := huh.NewForm(huh.NewGroup(util.Confirm(). Title("Make this project default?"). Value(&isDefault). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil { return err } @@ -337,7 +337,7 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { } return nil }). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). Run(); err != nil { return err } diff --git a/cmd/lk/console.go b/cmd/lk/console.go index 87cec2177..fdcf1213c 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -25,8 +25,8 @@ import ( "syscall" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/urfave/cli/v3" "github.com/livekit/livekit-cli/v2/pkg/console" diff --git a/cmd/lk/console_tui.go b/cmd/lk/console_tui.go index 9644b9f93..9edbe4448 100644 --- a/cmd/lk/console_tui.go +++ b/cmd/lk/console_tui.go @@ -23,9 +23,9 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" agent "github.com/livekit/protocol/livekit/agent" @@ -125,9 +125,17 @@ func newConsoleModel(pipeline *console.AudioPipeline, pipelineCancel context.Can ti := textinput.New() ti.Placeholder = "Type to talk to your agent" ti.CharLimit = 1000 - ti.Width = 60 + ti.SetWidth(60) ti.Prompt = "❯ " - ti.PromptStyle = boldStyle + // v2 moved per-state styling behind Styles/SetStyles, and renders a real + // terminal cursor unless the virtual one is enabled. The status area is + // composed into a larger view, so keep the inline virtual cursor rather than + // positioning the terminal cursor from here. + tiStyles := ti.Styles() + tiStyles.Focused.Prompt = boldStyle + tiStyles.Blurred.Prompt = boldStyle + ti.SetStyles(tiStyles) + ti.SetVirtualCursor(true) if textMode { ti.Focus() @@ -552,7 +560,11 @@ func formatChatItem(item *agent.ChatContext_ChatItem) string { // Layout matches the old Python console (FrequencyVisualizer + prompt). // ────────────────────────────────────────────────────────────────── -func (m consoleModel) View() string { +func (m consoleModel) View() tea.View { + return tea.NewView(m.render()) +} + +func (m consoleModel) render() string { var b strings.Builder if m.shuttingDown { diff --git a/cmd/lk/console_tui_vrt_test.go b/cmd/lk/console_tui_vrt_test.go new file mode 100644 index 000000000..8809ada7f --- /dev/null +++ b/cmd/lk/console_tui_vrt_test.go @@ -0,0 +1,80 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/livekit/livekit-cli/v2/pkg/console" +) + +// A zero-value pipeline is all the view needs: it reads the FFT bands, the mute +// flag and the playing flag, and each of those is a plain guarded field. +func consoleFixture(textMode bool) consoleModel { + m := newConsoleModel(&console.AudioPipeline{}, func() {}, nil, "MacBook Pro Microphone", "MacBook Pro Speakers", textMode) + m.width = 100 + return m +} + +func TestVRTConsoleFrames(t *testing.T) { + tests := []struct { + name string + model func() consoleModel + }{ + {"console_audio_idle", func() consoleModel { return consoleFixture(false) }}, + {"console_audio_shortcuts", func() consoleModel { + m := consoleFixture(false) + m.showShortcuts = true + return m + }}, + {"console_audio_partial_transcript", func() consoleModel { + m := consoleFixture(false) + m.partialTranscript = "I'd like a table for four" + return m + }}, + {"console_audio_metrics", func() consoleModel { + m := consoleFixture(false) + m.metricsText = "ttft 320ms" + return m + }}, + {"console_text_input", func() consoleModel { return consoleFixture(true) }}, + {"console_text_shortcuts", func() consoleModel { + m := consoleFixture(true) + m.showShortcuts = true + return m + }}, + {"console_text_waiting", func() consoleModel { + m := consoleFixture(true) + m.waitingForAgent = true + return m + }}, + {"console_text_audio_error", func() consoleModel { + m := consoleFixture(true) + m.audioError = "no input device available" + return m + }}, + {"console_shutting_down", func() consoleModel { + m := consoleFixture(false) + m.shuttingDown = true + return m + }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + requireFrame(t, tc.name, renderConsoleFrame(tc.model())) + }) + } +} diff --git a/cmd/lk/main.go b/cmd/lk/main.go index 99c68f668..8c6c43d3f 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -22,7 +22,8 @@ import ( "strings" "syscall" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" "github.com/urfave/cli/v3" "github.com/livekit/protocol/logger" @@ -96,11 +97,15 @@ func main() { if err := app.Run(ctx, os.Args); err != nil { errStyle := lipgloss.NewStyle().Foreground(util.Error()) + // Outside the Printer's reach (it may not be initialized yet), so the + // color profile has to be applied here too — Lip Gloss v2 emits styles + // regardless of whether stderr can render them. + errOut := colorprofile.NewWriter(os.Stderr, os.Environ()) // Render line by line: a multiline Render pads every line with // trailing spaces to match the widest one, which wraps into garbage // on terminals narrower than the longest line. for line := range strings.SplitSeq(err.Error(), "\n") { - fmt.Fprintln(os.Stderr, errStyle.Render(line)) + fmt.Fprintln(errOut, errStyle.Render(line)) } os.Exit(1) } @@ -146,6 +151,7 @@ func initLogger(ctx context.Context, cmd *cli.Command) (context.Context, error) out.Warnf("%v; using default theme", err) } } + util.DetectBackground() return nil, nil } diff --git a/cmd/lk/project.go b/cmd/lk/project.go index d7b6b9551..d09d81367 100644 --- a/cmd/lk/project.go +++ b/cmd/lk/project.go @@ -20,9 +20,9 @@ import ( "net/url" "regexp" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" "github.com/urfave/cli/v3" "github.com/livekit/livekit-cli/v2/pkg/config" @@ -237,7 +237,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { prompts = append(prompts, util.Confirm(). Title("Make this project default?"). Value(&isDefault). - WithTheme(util.Theme)) + WithTheme(util.FormTheme())) } if len(prompts) > 0 { @@ -246,7 +246,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { groups = append(groups, huh.NewGroup(p)) } err = huh.NewForm(groups...). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). RunWithContext(ctx) if err != nil { return err diff --git a/cmd/lk/session_render.go b/cmd/lk/session_render.go index b4a084b81..35aea6723 100644 --- a/cmd/lk/session_render.go +++ b/cmd/lk/session_render.go @@ -17,7 +17,7 @@ package main import ( "strings" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" agent "github.com/livekit/protocol/livekit/agent" ) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index ef1cd6ba4..315345b80 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -26,7 +26,7 @@ import ( "strings" "time" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/mattn/go-isatty" "github.com/urfave/cli/v3" "gopkg.in/yaml.v3" @@ -462,7 +462,7 @@ func confirmSourceUpload(cmd *cli.Command, projectDir string) error { Affirmative("Upload"). Negative("Cancel"). Value(&confirmed))). - WithTheme(util.Theme). + WithTheme(util.FormTheme()). Run() if err != nil { return err diff --git a/cmd/lk/simulate_matrix.go b/cmd/lk/simulate_matrix.go index 3139ab964..bd7210589 100644 --- a/cmd/lk/simulate_matrix.go +++ b/cmd/lk/simulate_matrix.go @@ -19,8 +19,8 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/livekit/livekit-cli/v2/pkg/util" ) diff --git a/cmd/lk/simulate_refs.go b/cmd/lk/simulate_refs.go index 3eba180ad..d8244e6a6 100644 --- a/cmd/lk/simulate_refs.go +++ b/cmd/lk/simulate_refs.go @@ -21,7 +21,7 @@ import ( "unicode" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/livekit/livekit-cli/v2/pkg/util" ) diff --git a/cmd/lk/simulate_refs_test.go b/cmd/lk/simulate_refs_test.go index a07058c71..f474fda8e 100644 --- a/cmd/lk/simulate_refs_test.go +++ b/cmd/lk/simulate_refs_test.go @@ -18,6 +18,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/require" ) @@ -49,7 +50,10 @@ func TestLinkSummaryRefs(t *testing.T) { // both refs link to their own item, whatever the attribute order require.Contains(t, linked, "runs/run?job=SRJ_Bzb9ZaoJFJyp&item=item_dd0ee81187bd") require.Contains(t, linked, "runs/run?job=SRJ_Bzb9ZaoJFJyp&item=item_13b90227fe38") - require.Contains(t, linked, `"I've had a few, sure"`) + // Lip Gloss v2 emits underlined text one grapheme at a time (it styles the + // spaces separately), so the label is only contiguous once the SGR sequences + // are stripped. + require.Contains(t, ansi.Strip(linked), `"I've had a few, sure"`) require.Equal(t, 2, strings.Count(linked, "\x1b]8;;"+dashboardBaseURL())) // the number a label carries selects the citation recorded under it diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 914c5dc1d..ec23cb193 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -24,10 +24,10 @@ import ( "strings" "time" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/livekit/livekit-cli/v2/pkg/util" @@ -40,7 +40,7 @@ func runSimulateTUI(config *simulateConfig) error { // No mouse capture, so the terminal keeps native drag-to-select. The arrow // keys (the wheel maps to them in alt-screen) move the cursor through the // job list and spill into page scrolling at each end. - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tea.NewProgram(m) _, runErr := p.Run() if m.launcher != nil { @@ -273,6 +273,11 @@ type simulateModel struct { width int height int err error + + // altScreen drives tea.View.AltScreen. Bubble Tea v2 has no + // EnterAltScreen/ExitAltScreen commands or WithAltScreen option: the screen + // mode is part of the view the model returns, so it lives in model state. + altScreen bool } func (m *simulateModel) hasDescription() bool { @@ -385,7 +390,11 @@ func newSimulateModel(config *simulateConfig) *simulateModel { ti.Placeholder = "scenarios.yaml" ti.CharLimit = 128 ti.Prompt = "" + // The save prompt is drawn inside a bordered box in the list view, so keep + // v1's inline cursor rather than positioning the terminal's real one. + ti.SetVirtualCursor(true) return &simulateModel{ + altScreen: true, config: config, reporter: newRunReporter(), numSimulations: config.numSimulations, @@ -846,7 +855,8 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if m.quotaModalActive() { switch key { - case "enter", "esc", "q", " ": + // Bubble Tea v2 names the space key "space"; v1 reported it as " ". + case "enter", "esc", "q", "space": m.quotaDismissed = true case "ctrl+c": if m.setupCancel != nil { @@ -1039,7 +1049,13 @@ func (m *simulateModel) findJob(id string) *livekit.SimulationRun_Job { return nil } -func (m *simulateModel) View() string { +func (m *simulateModel) View() tea.View { + v := tea.NewView(m.render()) + v.AltScreen = m.altScreen + return v +} + +func (m *simulateModel) render() string { if !m.setupDone || m.run == nil || m.run.Status == livekit.SimulationRun_STATUS_GENERATING { return m.viewSetup() } @@ -1670,8 +1686,10 @@ func (m *simulateModel) renderDetail() string { // there at once and the terminal scrolls, selects, and searches it. The list // view is untouched: it keeps the alt screen and its own windowing. // -// tea.Println is silently dropped while the alt screen is active (bubbletea -// standard_renderer.go), so leaving it must be sequenced before any print. +// tea.Println has nothing to write into while the alt screen is active, so the +// screen mode has to be off before any print. Bubble Tea v2 renders the view +// returned by Update before it reads the message the returned command produces, +// so clearing altScreen in Update is enough — no ExitAltScreen to sequence. // openDetailCmd leaves the alt screen, clears the scrollback, and prints the // job's view, so the terminal holds one job at a time instead of every job @@ -1681,7 +1699,8 @@ func (m *simulateModel) renderDetail() string { func (m *simulateModel) openDetailCmd() tea.Cmd { m.detailPrinted = "" m.detailWidth = m.width - return tea.Sequence(tea.ExitAltScreen, m.flushDetail()) + m.altScreen = false + return m.flushDetail() } // closeDetailCmd returns to the list view. The printed job stays in the @@ -1690,7 +1709,8 @@ func (m *simulateModel) closeDetailCmd() tea.Cmd { m.detailJobID = "" m.detailPrinted = "" m.refItemID = "" - return tea.EnterAltScreen + m.altScreen = true + return nil } // summaryRef resolves a digit key to the citation whose label carries it. @@ -2225,6 +2245,10 @@ func (m *simulateModel) renderQuitConfirm() string { return indentLines(box, " ") } +// saveDialogBorder is the horizontal space the dialog's border occupies: one +// column on each side. +const saveDialogBorder = 2 + func (m *simulateModel) renderSaveDialog() string { n := len(m.run.GetScenarioGroup().GetScenarios()) noun := "scenarios" @@ -2255,7 +2279,10 @@ func (m *simulateModel) renderSaveDialog() string { Border(lipgloss.RoundedBorder()). BorderForeground(util.Brand()). Padding(0, 1). - Width(width). + // width is the width the padded content needs. Lip Gloss v2 counts the + // border inside Width, where v1 drew it around — so ask for the border + // on top, or the destination path wraps two columns early. + Width(width + saveDialogBorder). Render(b.String()) return indentLines(box, " ") } diff --git a/cmd/lk/simulate_tui_keys_test.go b/cmd/lk/simulate_tui_keys_test.go new file mode 100644 index 000000000..b83c10b49 --- /dev/null +++ b/cmd/lk/simulate_tui_keys_test.go @@ -0,0 +1,177 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" +) + +// keyPress builds the key event Bubble Tea would deliver for a key named the way +// the model's switch statements name it. Key names belong to the terminal +// library, so this mapping is the contract those switches are written against. +func keyPress(name string) tea.KeyMsg { + named := map[string]rune{ + "enter": tea.KeyEnter, + "esc": tea.KeyEscape, + "space": tea.KeySpace, + "tab": tea.KeyTab, + "backspace": tea.KeyBackspace, + "up": tea.KeyUp, + "down": tea.KeyDown, + "left": tea.KeyLeft, + "right": tea.KeyRight, + "pgup": tea.KeyPgUp, + "pgdown": tea.KeyPgDown, + } + if code, ok := named[name]; ok { + return tea.KeyPressMsg{Code: code} + } + if mod, rest, ok := strings.Cut(name, "+"); ok && mod == "ctrl" { + if r := []rune(rest); len(r) == 1 { + return tea.KeyPressMsg{Code: r[0], Mod: tea.ModCtrl} + } + } + if r := []rune(name); len(r) == 1 { + return tea.KeyPressMsg{Code: r[0], Text: name} + } + panic("keyPress: unmapped key name " + name) +} + +// TestSimulateKeyDispatch pins the key bindings to the model state they change. +// Keys are matched by name, and those names are the terminal library's to +// choose — Bubble Tea v2 renamed space from " " to "space", which silently +// unbound the quota dialog until this was noticed. +func TestSimulateKeyDispatch(t *testing.T) { + tests := []struct { + name string + key string + setup func() *simulateModel + check func(t *testing.T, m *simulateModel) + }{ + { + name: "space dismisses the quota dialog", + key: "space", + setup: func() *simulateModel { m := runningFixture(); m.quotaWarning = "aInfo{}; return m }, + check: func(t *testing.T, m *simulateModel) { require.True(t, m.quotaDismissed) }, + }, + { + name: "enter dismisses the quota dialog", + key: "enter", + setup: func() *simulateModel { m := runningFixture(); m.quotaWarning = "aInfo{}; return m }, + check: func(t *testing.T, m *simulateModel) { require.True(t, m.quotaDismissed) }, + }, + { + name: "esc leaves the quit confirmation", + key: "esc", + setup: func() *simulateModel { m := runningFixture(); m.confirmQuit = true; return m }, + check: func(t *testing.T, m *simulateModel) { require.False(t, m.confirmQuit) }, + }, + { + name: "tab moves the quit confirmation selection", + key: "tab", + setup: func() *simulateModel { m := runningFixture(); m.confirmQuit = true; return m }, + check: func(t *testing.T, m *simulateModel) { require.Equal(t, 1, m.confirmQuitSel) }, + }, + { + name: "down moves the cursor", + key: "down", + setup: func() *simulateModel { m := runningFixture(); m.cursor = 0; return m }, + check: func(t *testing.T, m *simulateModel) { require.Equal(t, 1, m.cursor) }, + }, + { + name: "up moves the cursor back", + key: "up", + setup: func() *simulateModel { m := runningFixture(); m.cursor = 2; return m }, + check: func(t *testing.T, m *simulateModel) { require.Equal(t, 1, m.cursor) }, + }, + { + name: "d expands the agent description", + key: "d", + setup: func() *simulateModel { m := runningFixture(); m.run.AgentDescription = "an agent"; return m }, + check: func(t *testing.T, m *simulateModel) { require.True(t, m.showDescription) }, + }, + { + name: "t toggles tool detail inside a job", + key: "t", + setup: func() *simulateModel { + m := runningFixture() + m.detailJobID = "SRJ_aaaaaaaa" + return m + }, + check: func(t *testing.T, m *simulateModel) { require.True(t, m.showToolDetail) }, + }, + { + name: "t is inert in the list view", + key: "t", + setup: runningFixture, + check: func(t *testing.T, m *simulateModel) { require.False(t, m.showToolDetail) }, + }, + { + name: "ctrl+l toggles the log pane", + key: "ctrl+l", + setup: runningFixture, + check: func(t *testing.T, m *simulateModel) { require.True(t, m.showLogs) }, + }, + { + name: "enter opens the job detail", + key: "enter", + setup: runningFixture, + check: func(t *testing.T, m *simulateModel) { require.Equal(t, "SRJ_bbbbbbbb", m.detailJobID) }, + }, + { + name: "esc closes the job detail", + key: "esc", + setup: func() *simulateModel { + m := runningFixture() + m.detailJobID = "SRJ_aaaaaaaa" + return m + }, + check: func(t *testing.T, m *simulateModel) { require.Empty(t, m.detailJobID) }, + }, + { + name: "q asks before quitting a live run", + key: "q", + setup: runningFixture, + check: func(t *testing.T, m *simulateModel) { require.True(t, m.confirmQuit) }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := tc.setup() + m.handleKey(keyPress(tc.key)) + tc.check(t, m) + }) + } +} + +// TestSimulateDetailLeavesAltScreen covers the one piece of screen handling that +// moved into model state in Bubble Tea v2: the job detail prints into the +// terminal's own scrollback, which it can only reach with the alt screen off. +func TestSimulateDetailLeavesAltScreen(t *testing.T) { + m := runningFixture() + require.True(t, m.View().AltScreen, "the list view runs on the alt screen") + + m.detailJobID = "SRJ_aaaaaaaa" + m.openDetailCmd() + require.False(t, m.View().AltScreen, "opening a job must leave the alt screen before it prints") + + m.closeDetailCmd() + require.True(t, m.View().AltScreen, "closing a job returns to the alt screen") +} diff --git a/cmd/lk/simulate_tui_vrt_test.go b/cmd/lk/simulate_tui_vrt_test.go new file mode 100644 index 000000000..ad15aa711 --- /dev/null +++ b/cmd/lk/simulate_tui_vrt_test.go @@ -0,0 +1,208 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "testing" + "time" + + "github.com/livekit/livekit-cli/v2/pkg/config" + "github.com/livekit/protocol/livekit" +) + +// The matrix-rain view is deliberately absent from these fixtures: it seeds +// itself from math/rand and has no frame to pin. + +func simulateFixture() *simulateModel { + m := newSimulateModel(&simulateConfig{ + pc: &config.ProjectConfig{Name: "demo", URL: "wss://demo.livekit.cloud"}, + numSimulations: 3, + concurrency: 2, + mode: modeGenerateFromSource, + }) + m.runID = "SR_fixture0001" + m.width = 100 + m.height = 30 + return m +} + +func fixtureSteps() []step { + return []step{ + {label: "Starting agent", status: "done", elapsed: 1200 * time.Millisecond}, + {label: "Creating simulation", status: "running"}, + {label: "Uploading source", status: "pending"}, + } +} + +func fixtureJob(id, label string, status livekit.SimulationRun_Job_Status) *livekit.SimulationRun_Job { + return &livekit.SimulationRun_Job{ + Id: id, + Label: label, + Status: status, + Instructions: "Ask the agent about " + label, + AgentExpectations: "The agent answers about " + label, + } +} + +func fixtureRun(status livekit.SimulationRun_Status) *livekit.SimulationRun { + return &livekit.SimulationRun{ + Id: "SR_fixture0001", + Status: status, + Jobs: []*livekit.SimulationRun_Job{ + fixtureJob("SRJ_aaaaaaaa", "booking a table", livekit.SimulationRun_Job_STATUS_COMPLETED), + fixtureJob("SRJ_bbbbbbbb", "changing a reservation", livekit.SimulationRun_Job_STATUS_RUNNING), + fixtureJob("SRJ_cccccccc", "cancelling outright", livekit.SimulationRun_Job_STATUS_FAILED), + fixtureJob("SRJ_dddddddd", "asking for the hours", livekit.SimulationRun_Job_STATUS_PENDING), + }, + NumSimulations: 4, + Concurrency: 2, + } +} + +// runningFixture is a model mid-run: setup finished, jobs in every status. +func runningFixture() *simulateModel { + m := simulateFixture() + m.steps = fixtureSteps() + for i := range m.steps { + m.steps[i].status = "done" + m.steps[i].elapsed = time.Duration(i+1) * 500 * time.Millisecond + } + m.setupDone = true + m.currentStep = len(m.steps) - 1 + m.run = fixtureRun(livekit.SimulationRun_STATUS_RUNNING) + m.startTime = time.Now().Add(-90 * time.Second) + m.cursor = 1 + return m +} + +func TestVRTSimulateFrames(t *testing.T) { + tests := []struct { + name string + model func() *simulateModel + }{ + {"simulate_setup_running", func() *simulateModel { + m := simulateFixture() + m.steps = fixtureSteps() + m.currentStep = 1 + m.stepStart = time.Now().Add(-3 * time.Second) + return m + }}, + {"simulate_setup_warnings", func() *simulateModel { + m := simulateFixture() + m.config.warnings = []string{"--concurrency is ignored when running against a live agent"} + m.steps = fixtureSteps() + m.currentStep = 1 + m.stepStart = time.Now() + return m + }}, + {"simulate_setup_error", func() *simulateModel { + m := simulateFixture() + m.steps = fixtureSteps() + m.steps[1].status = "failed" + m.currentStep = 1 + m.err = errors.New("agent worker exited before registering") + return m + }}, + {"simulate_running_list", runningFixture}, + {"simulate_running_description", func() *simulateModel { + m := runningFixture() + m.run.AgentDescription = "A restaurant booking agent. It takes reservations, moves them, and cancels them, and it always confirms the party size before writing anything down." + m.showDescription = true + return m + }}, + {"simulate_running_description_collapsed", func() *simulateModel { + m := runningFixture() + m.run.AgentDescription = "A restaurant booking agent. It takes reservations, moves them, and cancels them." + return m + }}, + {"simulate_confirm_quit", func() *simulateModel { + m := runningFixture() + m.confirmQuit = true + m.confirmQuitSel = 1 + return m + }}, + {"simulate_quota_modal", func() *simulateModel { + m := runningFixture() + m.quotaWarning = "aInfo{category: "MaxConcurrentGatewayLLMTpm"} + m.quotaSuggested = 1 + m.peakRunning = 4 + return m + }}, + {"simulate_saving_prompt", func() *simulateModel { + m := runningFixture() + m.saving = true + m.saveInput.SetValue("scenarios") + return m + }}, + {"simulate_saving_error", func() *simulateModel { + m := runningFixture() + m.saving = true + m.saveInput.SetValue("scenarios") + m.saveErr = "file already exists" + return m + }}, + {"simulate_saving_long_path", func() *simulateModel { + m := runningFixture() + m.config.projectDir = "/Users/agent/src/restaurant-booking-agent/simulations" + m.saving = true + m.saveInput.SetValue("scenarios.yaml") + return m + }}, + {"simulate_toast", func() *simulateModel { + m := runningFixture() + m.toast = "Copied job ID" + m.toastOK = true + return m + }}, + {"simulate_completed", func() *simulateModel { + m := runningFixture() + m.run = fixtureRun(livekit.SimulationRun_STATUS_COMPLETED) + for _, j := range m.run.Jobs { + j.Status = livekit.SimulationRun_Job_STATUS_COMPLETED + } + m.runFinished = true + m.endTime = m.startTime.Add(2 * time.Minute) + m.summary = &livekit.SimulationRunSummary{ + Passed: 4, + Failed: 0, + GoingWell: "The agent confirmed the party size on every booking.", + ToImprove: "It repeated the address more often than callers needed.", + } + return m + }}, + {"simulate_failed_no_jobs", func() *simulateModel { + m := runningFixture() + m.run = &livekit.SimulationRun{ + Id: "SR_fixture0001", + Status: livekit.SimulationRun_STATUS_FAILED, + Error: "scenario generation failed: model returned no scenarios", + } + return m + }}, + {"simulate_detail_live", func() *simulateModel { + m := runningFixture() + m.detailJobID = "SRJ_aaaaaaaa" + m.detailWidth = m.width + return m + }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + requireFrame(t, tc.name, renderSimulateFrame(tc.model())) + }) + } +} diff --git a/cmd/lk/testdata/vrt/console_audio_idle.txt b/cmd/lk/testdata/vrt/console_audio_idle.txt new file mode 100644 index 000000000..ca6297a8b --- /dev/null +++ b/cmd/lk/testdata/vrt/console_audio_idle.txt @@ -0,0 +1,2 @@ + MacBook Pro Microphone ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ + ? for shortcuts diff --git a/cmd/lk/testdata/vrt/console_audio_metrics.txt b/cmd/lk/testdata/vrt/console_audio_metrics.txt new file mode 100644 index 000000000..5bc6eae79 --- /dev/null +++ b/cmd/lk/testdata/vrt/console_audio_metrics.txt @@ -0,0 +1,2 @@ + MacBook Pro Microphone ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ttft + ? for shortcuts diff --git a/cmd/lk/testdata/vrt/console_audio_partial_transcript.txt b/cmd/lk/testdata/vrt/console_audio_partial_transcript.txt new file mode 100644 index 000000000..ac15e01d5 --- /dev/null +++ b/cmd/lk/testdata/vrt/console_audio_partial_transcript.txt @@ -0,0 +1,2 @@ + MacBook Pro Microphone ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ● I'd like a table for four... + ? for shortcuts diff --git a/cmd/lk/testdata/vrt/console_audio_shortcuts.txt b/cmd/lk/testdata/vrt/console_audio_shortcuts.txt new file mode 100644 index 000000000..63c4de58d --- /dev/null +++ b/cmd/lk/testdata/vrt/console_audio_shortcuts.txt @@ -0,0 +1,2 @@ + MacBook Pro Microphone ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ ▁ + m mute/unmute · Ctrl+T text mode · q quit diff --git a/cmd/lk/testdata/vrt/console_shutting_down.txt b/cmd/lk/testdata/vrt/console_shutting_down.txt new file mode 100644 index 000000000..d4e62253a --- /dev/null +++ b/cmd/lk/testdata/vrt/console_shutting_down.txt @@ -0,0 +1,2 @@ + + Shutting down agent... ctrl+C to force diff --git a/cmd/lk/testdata/vrt/console_text_audio_error.txt b/cmd/lk/testdata/vrt/console_text_audio_error.txt new file mode 100644 index 000000000..951ec51c3 --- /dev/null +++ b/cmd/lk/testdata/vrt/console_text_audio_error.txt @@ -0,0 +1,5 @@ +──────────────────────────────────────────────────────────────────────────────── +❯ Type to talk to your agent +──────────────────────────────────────────────────────────────────────────────── + audio: no input device available + ? for shortcuts diff --git a/cmd/lk/testdata/vrt/console_text_input.txt b/cmd/lk/testdata/vrt/console_text_input.txt new file mode 100644 index 000000000..5af189785 --- /dev/null +++ b/cmd/lk/testdata/vrt/console_text_input.txt @@ -0,0 +1,4 @@ +──────────────────────────────────────────────────────────────────────────────── +❯ Type to talk to your agent +──────────────────────────────────────────────────────────────────────────────── + ? for shortcuts diff --git a/cmd/lk/testdata/vrt/console_text_shortcuts.txt b/cmd/lk/testdata/vrt/console_text_shortcuts.txt new file mode 100644 index 000000000..55fe891ee --- /dev/null +++ b/cmd/lk/testdata/vrt/console_text_shortcuts.txt @@ -0,0 +1,4 @@ +──────────────────────────────────────────────────────────────────────────────── +❯ Type to talk to your agent +──────────────────────────────────────────────────────────────────────────────── + Ctrl+T audio mode · Ctrl+C exit diff --git a/cmd/lk/testdata/vrt/console_text_waiting.txt b/cmd/lk/testdata/vrt/console_text_waiting.txt new file mode 100644 index 000000000..bf51abb62 --- /dev/null +++ b/cmd/lk/testdata/vrt/console_text_waiting.txt @@ -0,0 +1,2 @@ + thinking + ? for shortcuts diff --git a/cmd/lk/testdata/vrt/simulate_completed.txt b/cmd/lk/testdata/vrt/simulate_completed.txt new file mode 100644 index 000000000..219f17a3c --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_completed.txt @@ -0,0 +1,21 @@ + + Agent Simulation SR_fixture0001 + + Simulation · Completed + 4/4 4 passed + + ✓ 1. SRJ_aaaaaaaa booking a table + ✓ 2. SRJ_bbbbbbbb changing a reservation + ✓ 3. SRJ_cccccccc cancelling outright + ✓ 4. SRJ_dddddddd asking for the hours + + Summary 4 passed 0 failed + + Going well: + The agent confirmed the party size on every booking. + + To improve: + It repeated the address more often than callers needed. + + + ↑↓ navigate · →/ENTER detail · q quit diff --git a/cmd/lk/testdata/vrt/simulate_confirm_quit.txt b/cmd/lk/testdata/vrt/simulate_confirm_quit.txt new file mode 100644 index 000000000..34e97265f --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_confirm_quit.txt @@ -0,0 +1,19 @@ + + Agent Simulation SR_fixture0001 + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ╭────────────────────────────────────────────────────────╮ + │ Stop simulation? │ + │ The run is still in progress. Quitting will cancel it. │ + │ │ + │ Keep running Stop simulation │ + │ │ + │ ←→ select · enter confirm · esc dismiss │ + ╰────────────────────────────────────────────────────────╯ diff --git a/cmd/lk/testdata/vrt/simulate_detail_live.txt b/cmd/lk/testdata/vrt/simulate_detail_live.txt new file mode 100644 index 000000000..d5af3c949 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_detail_live.txt @@ -0,0 +1,2 @@ + + c copy scenario · ←/ESC back to list · q quit diff --git a/cmd/lk/testdata/vrt/simulate_failed_no_jobs.txt b/cmd/lk/testdata/vrt/simulate_failed_no_jobs.txt new file mode 100644 index 000000000..be3686452 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_failed_no_jobs.txt @@ -0,0 +1,6 @@ + + Agent Simulation SR_fixture0001 + + Failed + + scenario generation failed: model returned no scenarios diff --git a/cmd/lk/testdata/vrt/simulate_quota_modal.txt b/cmd/lk/testdata/vrt/simulate_quota_modal.txt new file mode 100644 index 000000000..9ce0a0bc0 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_quota_modal.txt @@ -0,0 +1,22 @@ + + Agent Simulation SR_fixture0001 + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ╭──────────────────────────────────────────────────────────╮ + │ Inference quota exceeded │ + │ This project is hitting its LLM tokens-per-minute rate │ + │ limit. LLM completions are being rejected (429), and │ + │ simulation jobs are failing with them. │ + │ │ + │ Suggested fix: re-run with --concurrency 1 │ + │ │ + │ Dismiss │ + │ enter/esc dismiss │ + ╰──────────────────────────────────────────────────────────╯ diff --git a/cmd/lk/testdata/vrt/simulate_running_description.txt b/cmd/lk/testdata/vrt/simulate_running_description.txt new file mode 100644 index 000000000..114b6a280 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_running_description.txt @@ -0,0 +1,16 @@ + + Agent Simulation SR_fixture0001 + + Agent Description + A restaurant booking agent. It takes reservations, moves them, and cancels them, and it always + confirms the party size before writing anything down. + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ↑↓ scroll · d collapse description · q quit diff --git a/cmd/lk/testdata/vrt/simulate_running_description_collapsed.txt b/cmd/lk/testdata/vrt/simulate_running_description_collapsed.txt new file mode 100644 index 000000000..87d30f1ab --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_running_description_collapsed.txt @@ -0,0 +1,16 @@ + + Agent Simulation SR_fixture0001 + + Agent Description + A restaurant booking agent. It takes reservations, moves them, and cancels them. + (press d to expand) + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ↑↓ navigate · →/ENTER detail · q quit diff --git a/cmd/lk/testdata/vrt/simulate_running_list.txt b/cmd/lk/testdata/vrt/simulate_running_list.txt new file mode 100644 index 000000000..9dedd1527 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_running_list.txt @@ -0,0 +1,12 @@ + + Agent Simulation SR_fixture0001 + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ↑↓ navigate · →/ENTER detail · q quit diff --git a/cmd/lk/testdata/vrt/simulate_saving_error.txt b/cmd/lk/testdata/vrt/simulate_saving_error.txt new file mode 100644 index 000000000..20bd227ae --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_saving_error.txt @@ -0,0 +1,20 @@ + + Agent Simulation SR_fixture0001 + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ╭──────────────────────────────────────────────────╮ + │ Save scenarios │ + │ 0 generated scenarios → │ + │ │ + │ File: scenarios │ + │ file already exists │ + │ │ + │ enter save · esc cancel │ + ╰──────────────────────────────────────────────────╯ diff --git a/cmd/lk/testdata/vrt/simulate_saving_long_path.txt b/cmd/lk/testdata/vrt/simulate_saving_long_path.txt new file mode 100644 index 000000000..e906c3700 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_saving_long_path.txt @@ -0,0 +1,20 @@ + + Agent Simulation SR_fixture0001 + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ╭─────────────────────────────────────────────────────────────────────────────╮ + │ Save scenarios │ + │ 0 generated scenarios → /Users/agent/src/restaurant-booking- │ + │ agent/simulations │ + │ │ + │ File: scenarios.yaml │ + │ │ + │ enter save · esc cancel │ + ╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/cmd/lk/testdata/vrt/simulate_saving_prompt.txt b/cmd/lk/testdata/vrt/simulate_saving_prompt.txt new file mode 100644 index 000000000..2d0e4e085 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_saving_prompt.txt @@ -0,0 +1,19 @@ + + Agent Simulation SR_fixture0001 + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ╭──────────────────────────────────────────────────╮ + │ Save scenarios │ + │ 0 generated scenarios → │ + │ │ + │ File: scenarios │ + │ │ + │ enter save · esc cancel │ + ╰──────────────────────────────────────────────────╯ diff --git a/cmd/lk/testdata/vrt/simulate_setup_error.txt b/cmd/lk/testdata/vrt/simulate_setup_error.txt new file mode 100644 index 000000000..830de804f --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_setup_error.txt @@ -0,0 +1,12 @@ + + Agent Simulation + + Project: demo + URL: wss://demo.livekit.cloud + Run: SR_fixture0001 + + ✓ Starting agent + ✗ Creating simulation + – Uploading source + + agent worker exited before registering diff --git a/cmd/lk/testdata/vrt/simulate_setup_running.txt b/cmd/lk/testdata/vrt/simulate_setup_running.txt new file mode 100644 index 000000000..290399654 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_setup_running.txt @@ -0,0 +1,10 @@ + + Agent Simulation + + Project: demo + URL: wss://demo.livekit.cloud + Run: SR_fixture0001 + + ✓ Starting agent + ⏺ Creating simulation + – Uploading source diff --git a/cmd/lk/testdata/vrt/simulate_setup_warnings.txt b/cmd/lk/testdata/vrt/simulate_setup_warnings.txt new file mode 100644 index 000000000..aa9dcd544 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_setup_warnings.txt @@ -0,0 +1,11 @@ + + Agent Simulation + + Project: demo + URL: wss://demo.livekit.cloud + Run: SR_fixture0001 + + ✓ Starting agent + ⏺ Creating simulation + – Uploading source + ⚠ --concurrency is ignored when running against a live agent diff --git a/cmd/lk/testdata/vrt/simulate_toast.txt b/cmd/lk/testdata/vrt/simulate_toast.txt new file mode 100644 index 000000000..d7e958d55 --- /dev/null +++ b/cmd/lk/testdata/vrt/simulate_toast.txt @@ -0,0 +1,15 @@ + + Agent Simulation SR_fixture0001 + + Simulation · Running + 2/4 1 passed 1 failed 1 running + + ✓ 1. SRJ_aaaaaaaa booking a table + ⏺ 2. SRJ_bbbbbbbb changing a reservation + ✗ 3. SRJ_cccccccc cancelling outright + ⏺ 4. SRJ_dddddddd asking for the hours + + ╭─────────────────╮ + │ ✓ Copied job ID │ + ╰─────────────────╯ + ↑↓ navigate · →/ENTER detail · q quit diff --git a/cmd/lk/token.go b/cmd/lk/token.go index 59fde819b..ef52cd8cb 100644 --- a/cmd/lk/token.go +++ b/cmd/lk/token.go @@ -25,7 +25,7 @@ import ( "slices" "time" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/urfave/cli/v3" "github.com/livekit/protocol/auth" @@ -374,22 +374,29 @@ func createToken(ctx context.Context, c *cli.Command) error { permissions := make([]permission, 0) + permissionOptions := []huh.Option[permission]{ + huh.NewOption("Create", pCreate), + huh.NewOption("List", pList), + huh.NewOption("Join", pJoin), + huh.NewOption("Admin", pAdmin), + huh.NewOption("Egress", pEgress), + huh.NewOption("Ingress", pIngress), + huh.NewOption("Inference", pInference), + huh.NewOption("Update metadata", pMetadata), + } + if err := huh.NewForm( huh.NewGroup(huh.NewMultiSelect[permission](). - Options( - huh.NewOption("Create", pCreate), - huh.NewOption("List", pList), - huh.NewOption("Join", pJoin), - huh.NewOption("Admin", pAdmin), - huh.NewOption("Egress", pEgress), - huh.NewOption("Ingress", pIngress), - huh.NewOption("Inference", pInference), - huh.NewOption("Update metadata", pMetadata), - ). + Options(permissionOptions...). Title("Token Permissions"). Description("See https://docs.livekit.io/home/get-started/authentication/#Video-grant"). + // A multi-select with no explicit height still subtracts its + // title and description rows from the option list in huh v2, so + // the last two permissions would scroll out of sight. Ask for + // the options plus the two rows they'd otherwise displace. + Height(len(permissionOptions) + 2). Value(&permissions). - WithTheme(util.Theme))). + WithTheme(util.FormTheme()))). Run(); err != nil || len(permissions) == 0 { return errors.New("no permissions were given in this grant, see --help") } else { diff --git a/cmd/lk/tui_vrt_test.go b/cmd/lk/tui_vrt_test.go new file mode 100644 index 000000000..517666cc7 --- /dev/null +++ b/cmd/lk/tui_vrt_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Visual regression tests for the two TUIs. Both models compose their frame in a +// plain render() method, so a frame can be captured from a synthetic model state +// without a terminal, a pty, or a running Bubble Tea program, and compared +// against a reference frame under testdata/vrt. +// +// The references hold the frame with ANSI stripped: layout, wrapping, windowing +// and glyphs are what these tests are for, and they are also the part that has +// to survive a terminal-library upgrade unchanged. The escape sequences +// themselves are deliberately not pinned — Lip Gloss encodes the same styling +// differently between major versions (v2 emits underlined runs one grapheme at a +// time, and truecolor where v1 emitted palette indices), so pinning them would +// fail on every upgrade for reasons that never reach the screen. +// +// Regenerate after an intentional UI change: +// +// UPDATE_TUI_VRT=1 go test ./cmd/lk -run TestVRT + +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/stretchr/testify/require" +) + +// renderSimulateFrame and renderConsoleFrame are the only two places these tests +// touch the Bubble Tea model API, which keeps the fixtures portable: pointing +// them at an older revision's View() is all it takes to diff two versions of the +// TUI against the same states. +func renderSimulateFrame(m *simulateModel) string { return m.render() } + +func renderConsoleFrame(m consoleModel) string { return m.render() } + +// Elapsed times are read from the wall clock at render, so they are masked. The +// patterns are anchored to Go's Duration formatting and to the one hand-rolled +// "%dm%02ds" in the run header; fixtures avoid duration-shaped prose so nothing +// else can match. +var ( + durationRe = regexp.MustCompile(`\b\d+(\.\d+)?(ns|µs|ms|s)\b|\b\d+m\d+(\.\d+)?s\b`) + // The console's "thinking" spinner picks its frame from time.Now(). + brailleRe = regexp.MustCompile(`[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]`) +) + +// normalizeFrame reduces a rendered frame to the part that must not change: +// styling is stripped, wall-clock-derived text is masked, and trailing padding — +// which a width-setting style adds and which no terminal shows — is dropped. +func normalizeFrame(s string) string { + s = ansi.Strip(s) + s = durationRe.ReplaceAllString(s, "") + s = brailleRe.ReplaceAllString(s, "") + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + return strings.TrimRight(strings.Join(lines, "\n"), "\n") + "\n" +} + +// requireFrame compares a normalized frame against testdata/vrt/.txt, +// writing the reference instead when UPDATE_TUI_VRT is set. +func requireFrame(t *testing.T, name, frame string) { + t.Helper() + got := normalizeFrame(frame) + path := filepath.Join("testdata", "vrt", name+".txt") + + if os.Getenv("UPDATE_TUI_VRT") != "" { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(got), 0o644)) + return + } + + want, err := os.ReadFile(path) + require.NoError(t, err, "no reference frame for %q; record one with `make update-vrt`", name) + require.Equal(t, string(want), got, + "%s renders differently than its reference frame.\n"+ + "If the change is intended, re-record with `make update-vrt` and commit the diff.", name) +} diff --git a/go.mod b/go.mod index 74c63e1c7..21b10c287 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,15 @@ module github.com/livekit/livekit-cli/v2 go 1.26.3 require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/huh/v2 v2.0.3 + charm.land/lipgloss/v2 v2.0.6 github.com/BurntSushi/toml v1.6.0 github.com/Masterminds/semver/v3 v3.5.0 github.com/atotto/clipboard v0.1.4 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/huh v1.0.0 - github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b - github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/ansi v0.11.7 + github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.8 github.com/frostbyte73/core v0.1.1 github.com/fsnotify/fsnotify v1.10.1 github.com/go-logr/logr v1.4.4 @@ -26,7 +26,7 @@ require ( github.com/moby/moby/client v0.4.1 github.com/moby/patternmatcher v0.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1 - github.com/pelletier/go-toml v1.9.5 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/pion/rtcp v1.2.17 github.com/pion/rtp v1.10.5 github.com/pion/webrtc/v4 v4.2.20 @@ -48,9 +48,6 @@ require ( buf.build/go/protoyaml v0.7.0 // indirect cel.dev/cel-go v0.32.0 // indirect cel.dev/expr v0.25.3 // indirect - charm.land/bubbles/v2 v2.1.0 // indirect - charm.land/bubbletea/v2 v2.0.6 // indirect - charm.land/lipgloss/v2 v2.0.3 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -83,7 +80,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 // indirect github.com/aws/smithy-go v1.27.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bep/debounce v1.2.1 // indirect @@ -91,9 +87,8 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chainguard-dev/git-urls v1.0.2 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -124,7 +119,6 @@ require ( github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -157,11 +151,10 @@ require ( github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 // indirect github.com/livekit/psrpc v0.7.5 // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/magefile/mage v1.17.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/buildkit v0.32.2 // indirect @@ -170,9 +163,7 @@ require ( github.com/moby/moby/api v1.54.2 // indirect github.com/moby/sys/signal v0.7.1 // indirect github.com/morikuni/aec v1.1.0 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nats.go v1.53.1 // indirect github.com/nats-io/nkeys v0.4.16 // indirect diff --git a/go.sum b/go.sum index 7220c54e7..c2286cd75 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,14 @@ cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= -charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= -charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= -charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= -charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= @@ -106,8 +108,6 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.45.0 h1:Pd6PNlp4t8PTXxqzstICl52Wsy78 github.com/aws/aws-sdk-go-v2/service/sts v1.45.0/go.mod h1:rmQ0TnHzuLPmabgjPcsywhsSOmaBDgzR4zvDxSPsGdg= github.com/aws/smithy-go v1.27.4 h1:JQcphmBN4f0q/sPqXqROIItRNV/hy10cgu7CsFy616M= github.com/aws/smithy-go v1.27.4/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= @@ -132,30 +132,20 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ= github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= -github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= -github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b h1:deQbW7eR/gYwkXonGX6a1now6H6f8v4kfv0OIKECu0I= -github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b/go.mod h1:Y68nuKJuC/Q2lmiq18EkHWkVWi2VGLrwaOfOyPKLkkE= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= -github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= -github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= -github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/strings v0.1.0 h1:i69S2XI7uG1u4NLGeJPSYU++Nmjvpo9nwd6aoEm7gkA= github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -164,8 +154,8 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -241,8 +231,6 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -350,18 +338,16 @@ github.com/livekit/psrpc v0.7.5 h1:WxfJIQ41X1b+48A1uzc8Gy9FhYEMxBJNCpCYqPdO/Ds= github.com/livekit/psrpc v0.7.5/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/livekit/server-sdk-go/v2 v2.18.2-0.20260904062056-1da58cd7b795 h1:0gljvZ5rt8vSLgoyaQl3ocD9D6RQt4Iyn4kXjVr4un0= github.com/livekit/server-sdk-go/v2 v2.18.2-0.20260904062056-1da58cd7b795/go.mod h1:XjoifafCULiHBrmrefkzHX+YEhgraLykU4UAuT8Cf3U= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -394,12 +380,8 @@ github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/u github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo= @@ -418,10 +400,8 @@ github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoP github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= github.com/package-url/packageurl-go v0.1.1 h1:KTRE0bK3sKbFKAk3yy63DpeskU7Cvs/x/Da5l+RtzyU= github.com/package-url/packageurl-go v0.1.1/go.mod h1:uQd4a7Rh3ZsVg5j0lNyAfyxIeGde9yrlhjF78GzeW0c= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= -github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= @@ -610,7 +590,6 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/pkg/agentfs/detect.go b/pkg/agentfs/detect.go index 4d8d1c053..59995f0d4 100644 --- a/pkg/agentfs/detect.go +++ b/pkg/agentfs/detect.go @@ -22,7 +22,7 @@ import ( "path/filepath" "github.com/livekit/livekit-cli/v2/pkg/util" - "github.com/pelletier/go-toml" + "github.com/pelletier/go-toml/v2" ) type ProjectType string diff --git a/pkg/agentfs/docker.go b/pkg/agentfs/docker.go index 7f1f2e6b3..392845194 100644 --- a/pkg/agentfs/docker.go +++ b/pkg/agentfs/docker.go @@ -25,7 +25,7 @@ import ( "strings" "text/template" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/moby/patternmatcher" "github.com/moby/patternmatcher/ignorefile" @@ -193,7 +193,7 @@ func validateEntrypoint(dir string, dockerfileContent []byte, dockerignoreConten Title(fmt.Sprintf("Select the %s file which contains your agent's entrypoint", projectType.Lang())). Options(huh.NewOptions(fileList...)...). Value(&selected). - WithTheme(util.Theme), + WithTheme(util.FormTheme()), ), ) if err := form.Run(); err != nil { diff --git a/pkg/agentfs/secrets-file.go b/pkg/agentfs/secrets-file.go index bbb13e686..100e3a289 100644 --- a/pkg/agentfs/secrets-file.go +++ b/pkg/agentfs/secrets-file.go @@ -18,7 +18,7 @@ import ( "errors" "os" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/joho/godotenv" "github.com/livekit/livekit-cli/v2/pkg/util" ) @@ -82,7 +82,7 @@ func DetectEnvFile(maybeFile string, skipPrompts bool) (string, map[string]strin }, nil). Value(&selectedFile). WithHeight(5). - WithTheme(util.Theme), + WithTheme(util.FormTheme()), ), ). Run(); err != nil { diff --git a/pkg/loadtester/agentloadtester.go b/pkg/loadtester/agentloadtester.go index 2abf6c8d7..beb2cff53 100644 --- a/pkg/loadtester/agentloadtester.go +++ b/pkg/loadtester/agentloadtester.go @@ -23,7 +23,7 @@ import ( "sync" "time" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" "github.com/livekit/livekit-cli/v2/pkg/util" "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" diff --git a/pkg/loadtester/loadtest.go b/pkg/loadtester/loadtest.go index 8c86ac2e5..12ce96249 100644 --- a/pkg/loadtester/loadtest.go +++ b/pkg/loadtester/loadtest.go @@ -26,8 +26,8 @@ import ( "sync" "time" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" "github.com/livekit/livekit-cli/v2/pkg/util" "golang.org/x/sync/errgroup" "golang.org/x/sync/syncmap" diff --git a/pkg/util/printer.go b/pkg/util/printer.go index 58cb14bfc..de72bd629 100644 --- a/pkg/util/printer.go +++ b/pkg/util/printer.go @@ -26,7 +26,9 @@ import ( "io" "os" - "github.com/charmbracelet/huh/spinner" + "charm.land/huh/v2/spinner" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" "github.com/mattn/go-isatty" ) @@ -41,6 +43,19 @@ type Printer struct { // interactive reports whether Err is a real terminal. It gates decoration // (spinners) — never content — so redirected or piped runs stay clean. interactive bool + + // rawErr is Err before the color-profile wrapper. Bubble Tea needs the + // underlying file to drive the terminal, so the spinner gets this one. + rawErr io.Writer +} + +// colorWriter adapts w to the color support of its destination. Lip Gloss v2 +// renders styles unconditionally — unlike v1, whose renderer consulted the +// terminal's capabilities — so the downsampling has to happen on the way out: +// truecolor degrades to ANSI256/ANSI where that's all the terminal has, and +// color is dropped entirely for non-terminals and under NO_COLOR. +func colorWriter(w io.Writer) io.Writer { + return colorprofile.NewWriter(w, os.Environ()) } // NewPrinter builds a Printer targeting the given writers. Pass nil to default @@ -52,7 +67,13 @@ func NewPrinter(out, err io.Writer, quiet bool) *Printer { if err == nil { err = os.Stderr } - return &Printer{Out: out, Err: err, Quiet: quiet, interactive: isTerminal(err)} + return &Printer{ + Out: colorWriter(out), + Err: colorWriter(err), + Quiet: quiet, + interactive: isTerminal(err), + rawErr: err, + } } // Interactive reports whether status output is going to a real terminal, so @@ -69,6 +90,11 @@ func isTerminal(w io.Writer) bool { if !ok { return false } + return isTerminalFile(f) +} + +// isTerminalFile reports whether f is attached to a terminal. +func isTerminalFile(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } @@ -162,12 +188,17 @@ func (p *Printer) Await(title string, ctx context.Context, action func(ctx conte p.Status(title) return action(ctx) } + // v2 replaced the individual Style/Output setters with a theme and WithOutput. + // Only the spinner glyph is themed; the title stays unstyled, as before. + spinnerTheme := spinner.ThemeFunc(func(bool) *spinner.Styles { + return &spinner.Styles{Spinner: Theme.Focused.Title, Title: lipgloss.NewStyle()} + }) return spinner.New(). Title(" " + title). ActionWithErr(action). Type(spinner.Pulse). - Style(Theme.Focused.Title). - Output(p.Err). + WithTheme(spinnerTheme). + WithOutput(p.rawErr). Context(ctx). Run() } diff --git a/pkg/util/table.go b/pkg/util/table.go index 7422cf1b2..2225f115e 100644 --- a/pkg/util/table.go +++ b/pkg/util/table.go @@ -15,8 +15,8 @@ package util import ( - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" ) func CreateTable() *table.Table { diff --git a/pkg/util/theme.go b/pkg/util/theme.go index e45008f00..3d8f767f3 100644 --- a/pkg/util/theme.go +++ b/pkg/util/theme.go @@ -16,9 +16,12 @@ package util import ( "fmt" + "image/color" + "os" + "sync" - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" ) // ThemeName identifies a color theme. The active theme is selected once at startup @@ -39,15 +42,59 @@ var ValidThemes = []ThemeName{ThemeDefault, ThemeLiveKit} // palette holds a theme's semantic colors. Each is adaptive (light/dark) so it renders // legibly on either terminal background. type palette struct { - Brand lipgloss.TerminalColor - Accent lipgloss.TerminalColor - Success lipgloss.TerminalColor - Warning lipgloss.TerminalColor - Error lipgloss.TerminalColor + Brand color.Color + Accent color.Color + Success color.Color + Warning color.Color + Error color.Color } -// palettes defines the semantic colors per theme. AdaptiveColor{Light, Dark}: Light is the -// shade used on light terminals, Dark on dark terminals. +// adaptive is a color that resolves to its light or dark variant depending on the +// terminal background. Lip Gloss v2 dropped AdaptiveColor in favour of plain +// color.Color values plus a background flag the caller supplies; resolving in +// RGBA keeps that flag out of every style definition, and lets the styles be +// built before the background is known (see DetectBackground). +type adaptive struct{ light, dark color.Color } + +func (a adaptive) RGBA() (r, g, b, al uint32) { + if hasDarkBackground() { + return a.dark.RGBA() + } + return a.light.RGBA() +} + +func adaptiveColor(light, dark string) color.Color { + return adaptive{light: lipgloss.Color(light), dark: lipgloss.Color(dark)} +} + +// hasDarkBackground reports whether the terminal has a dark background, querying +// it once per process. It defaults to dark when the query fails or there is no +// terminal to ask, matching Lip Gloss v1's behaviour. +// +// The redirected-stdio check belongs here rather than to Lip Gloss: on Windows +// its query opens CONIN$/CONOUT$ directly when stdin or stdout is not a +// terminal, so a piped run still seizes the console and still waits out the +// timeout. That turns every `lk` invocation in a script, a pipeline or a test +// harness into a multi-second stall, and there is nothing to render color for +// in the first place. +var hasDarkBackground = sync.OnceValue(func() bool { + if !isTerminalFile(os.Stdin) || !isTerminalFile(os.Stdout) { + return true + } + return lipgloss.HasDarkBackground(os.Stdin, os.Stdout) +}) + +// DetectBackground resolves the terminal background now, so no later render has +// to. The query puts stdin in raw mode and waits for the terminal to answer, +// which must not happen once a Bubble Tea program or a huh form owns the input; +// running it before any command does keeps it out of their way. It's a no-op on +// a non-interactive terminal. +func DetectBackground() { + hasDarkBackground() +} + +// palettes defines the semantic colors per theme. In adaptiveColor(light, dark) the +// first shade is used on light terminals, the second on dark ones. var palettes = map[ThemeName]palette{ // Default: ANSI only (normal on light, bright on dark). Adapts to the terminal palette. ThemeDefault: { @@ -59,11 +106,11 @@ var palettes = map[ThemeName]palette{ }, // LiveKit: brand truecolor palette. ThemeLiveKit: { - Brand: lipgloss.AdaptiveColor{Light: "#002CF2", Dark: "#1FD5F9"}, - Accent: lipgloss.AdaptiveColor{Light: "#7A15A2", Dark: "#DC85FF"}, - Success: lipgloss.AdaptiveColor{Light: "#00753B", Dark: "#23DE6B"}, - Warning: lipgloss.AdaptiveColor{Light: "#9D4D06", Dark: "#FFB752"}, - Error: lipgloss.AdaptiveColor{Light: "#B32909", Dark: "#FF7566"}, + Brand: adaptiveColor("#002CF2", "#1FD5F9"), + Accent: adaptiveColor("#7A15A2", "#DC85FF"), + Success: adaptiveColor("#00753B", "#23DE6B"), + Warning: adaptiveColor("#9D4D06", "#FFB752"), + Error: adaptiveColor("#B32909", "#FF7566"), }, } @@ -74,10 +121,11 @@ var ( activeTheme = ThemeDefault activePalette = palettes[ThemeDefault] - // Theme is the huh form theme for the active color theme. - Theme *huh.Theme + // Theme holds the huh form styles for the active color theme. Pass it to a form + // with FormTheme(); read it directly for individual styles. + Theme *huh.Styles - Fg lipgloss.AdaptiveColor + Fg color.Color FormBaseStyle lipgloss.Style FormHeaderStyle lipgloss.Style ) @@ -104,16 +152,20 @@ func SetTheme(name string) error { func applyTheme(tn ThemeName) { activeTheme = tn activePalette = palettes[tn] + Fg = adaptiveColor("235", "252") Theme = buildHuhTheme(tn, activePalette) - Fg = lipgloss.AdaptiveColor{Light: "235", Dark: "252"} FormBaseStyle = Theme.Form.Base.Foreground(Fg).Padding(0, 1) FormHeaderStyle = FormBaseStyle.Bold(true) } // buildHuhTheme constructs the huh form theme. The default theme reproduces the original // ANSI look; the livekit theme styles selection/title/cursor with the brand color. -func buildHuhTheme(tn ThemeName, p palette) *huh.Theme { - t := huh.ThemeBase() +func buildHuhTheme(tn ThemeName, p palette) *huh.Styles { + // huh v2 themes take the terminal's background brightness. Both bases we + // build on use only ANSI palette colors and ignore the flag, and the colors + // we layer on top adapt on their own, so the value passed here is moot — + // which is what lets the theme be built at init, before detection runs. + t := huh.ThemeBase(true) switch tn { case ThemeLiveKit: // Selected action uses the brand color with black text, mirroring the LiveKit tag. @@ -122,7 +174,7 @@ func buildHuhTheme(tn ThemeName, p palette) *huh.Theme { case ThemeDefault: fallthrough default: - t = huh.ThemeBase16() + t = huh.ThemeBase16(true) // ANSI: white text on a blue selection, base16 defaults elsewhere. t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("4")) } @@ -161,11 +213,18 @@ func buildHuhTheme(tn ThemeName, p palette) *huh.Theme { // Semantic color accessors. They read the active palette at call time, so they reflect the // selected theme even when used to build styles lazily. -func Brand() lipgloss.TerminalColor { return activePalette.Brand } -func Accent() lipgloss.TerminalColor { return activePalette.Accent } -func Success() lipgloss.TerminalColor { return activePalette.Success } -func Warning() lipgloss.TerminalColor { return activePalette.Warning } -func Error() lipgloss.TerminalColor { return activePalette.Error } +func Brand() color.Color { return activePalette.Brand } +func Accent() color.Color { return activePalette.Accent } +func Success() color.Color { return activePalette.Success } +func Warning() color.Color { return activePalette.Warning } +func Error() color.Color { return activePalette.Error } + +// FormTheme adapts the active theme to huh v2's Theme interface, which asks for a +// func(isDark bool) *Styles. Our styles already resolve light/dark per color (see +// adaptive), so the flag is ignored. +func FormTheme() huh.Theme { + return huh.ThemeFunc(func(bool) *huh.Styles { return Theme }) +} // Accented renders text in the active theme's title style (brand color under livekit). func Accented(text string) string {