diff --git a/Makefile b/Makefile index 90421e45..2d289c33 100644 --- a/Makefile +++ b/Makefile @@ -22,8 +22,8 @@ endif # dependency installation on Linux. lk$(EXE): git submodule update --init --recursive - CGO_ENABLED=1 go build -o lk$(EXE) ./cmd/lk + CGO_ENABLED=1 go build -o ./bin/lk$(EXE) ./cmd/lk install: lk$(EXE) - cp lk$(EXE) "$(GOBIN)/lk$(EXE)" + cp ./bin/lk$(EXE) "$(GOBIN)/lk$(EXE)" ln -sf "$(GOBIN)/lk$(EXE)" "$(GOBIN)/livekit-cli$(EXE)" diff --git a/autocomplete/fish_autocomplete b/autocomplete/fish_autocomplete index c3a4f71c..1822d07c 100644 --- a/autocomplete/fish_autocomplete +++ b/autocomplete/fish_autocomplete @@ -2,7 +2,7 @@ function __fish_lk_no_subcommand --description 'Test if there has been any subcommand yet' for i in (commandline -opc) - if contains -- $i generate-fish-completion app agent a cloud docs project room create-room list-rooms list-room update-room-metadata list-participants get-participant remove-participant update-participant mute-track update-subscriptions send-data token create-token join-room dispatch egress start-room-composite-egress start-web-egress start-participant-egress start-track-composite-egress start-track-egress list-egress update-layout update-stream stop-egress test-egress-template ingress create-ingress update-ingress list-ingress delete-ingress sip list-sip-trunk delete-sip-trunk create-sip-dispatch-rule list-sip-dispatch-rule delete-sip-dispatch-rule create-sip-participant number replay perf load-test completion + if contains -- $i generate-fish-completion app agent a cloud docs project set-theme room create-room list-rooms list-room update-room-metadata list-participants get-participant remove-participant update-participant mute-track update-subscriptions send-data token create-token join-room dispatch egress start-room-composite-egress start-web-egress start-participant-egress start-track-composite-egress start-track-egress list-egress update-layout update-stream stop-egress test-egress-template ingress create-ingress update-ingress list-ingress delete-ingress sip list-sip-trunk delete-sip-trunk create-sip-dispatch-rule list-sip-dispatch-rule delete-sip-dispatch-rule create-sip-participant number replay perf load-test completion return 1 end end @@ -267,6 +267,8 @@ complete -x -c lk -n '__fish_seen_subcommand_from project; and not __fish_seen_s complete -c lk -n '__fish_seen_subcommand_from project; and __fish_seen_subcommand_from set-default' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from project; and __fish_seen_subcommand_from set-default; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -x -c lk -n '__fish_seen_subcommand_from project; and not __fish_seen_subcommand_from add list remove set-default help h' -a 'help' -d 'Shows a list of commands or help for one command' +complete -c lk -n '__fish_seen_subcommand_from set-theme' -f -l help -s h -d 'show help' +complete -x -c lk -n '__fish_seen_subcommand_from set-theme; and not __fish_seen_subcommand_from help h' -a 'help' -d 'Shows a list of commands or help for one command' complete -x -c lk -n '__fish_lk_no_subcommand' -a 'room' -d 'Create or delete rooms and manage existing room properties' complete -c lk -n '__fish_seen_subcommand_from room' -f -l help -s h -d 'show help' complete -x -c lk -n '__fish_seen_subcommand_from room; and not __fish_seen_subcommand_from create list update delete join participants mute-track update-subscriptions send-data help h' -a 'create' -d 'Create a room' diff --git a/cmd/lk/app.go b/cmd/lk/app.go index 847a9cf5..65a56aab 100644 --- a/cmd/lk/app.go +++ b/cmd/lk/app.go @@ -163,7 +163,8 @@ func requireProjectWithOpts(ctx context.Context, cmd *cli.Command, opts ...loadO cliConfig != nil && len(cliConfig.Projects) > 1 { useDefault := true if err = huh.NewForm(huh.NewGroup(util.Confirm(). - Title(fmt.Sprintf("Use project [%s] (%s)?", rp.project.Name, rp.project.URL)). + Title(fmt.Sprintf("Use project [%s]?", rp.project.Name)). + Description(rp.project.URL). Value(&useDefault). Options( huh.NewOption("Yes", true), @@ -360,6 +361,7 @@ func setupTemplate(ctx context.Context, cmd *cli.Command) error { preinstallPrompts = append(preinstallPrompts, huh.NewInput(). Title("Application Name"). Placeholder("my-app"). + Prompt(""). Value(&appName). Validate(func(s string) error { if len(s) < 2 { @@ -551,6 +553,7 @@ func instantiateEnv(ctx context.Context, cmd *cli.Command, rootPath string, addl EchoMode(huh.EchoModePassword). Title("Enter " + key + "?"). Placeholder(oldValue). + Prompt(""). Value(&newValue). WithTheme(util.Theme). Run(); err != nil || newValue == "" { diff --git a/cmd/lk/cloud.go b/cmd/lk/cloud.go index 1fe678cc..afd42adc 100644 --- a/cmd/lk/cloud.go +++ b/cmd/lk/cloud.go @@ -259,6 +259,7 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { // get devicename if err := huh.NewForm(huh.NewGroup(huh.NewInput(). Title("What is the name of this device?"). + Prompt(""). Value(&cliConfig.DeviceName). WithTheme(util.Theme))). Run(); err != nil { @@ -328,6 +329,7 @@ func tryAuthIfNeeded(ctx context.Context, cmd *cli.Command) error { if err := huh.NewInput(). Title("Choose a different alias"). Description(fmt.Sprintf("You've already authenticated a project with the alias %q.", name)). + Prompt(""). Value(&name). Validate(func(s string) error { if cliConfig.ProjectExists(s) { diff --git a/cmd/lk/console.go b/cmd/lk/console.go index 724791c2..1626ba7e 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -32,6 +32,7 @@ import ( "github.com/livekit/livekit-cli/v2/pkg/console" "github.com/livekit/livekit-cli/v2/pkg/portaudio" + "github.com/livekit/livekit-cli/v2/pkg/util" ) func init() { @@ -240,8 +241,8 @@ func listDevices() error { return err } - headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) - defaultStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(util.Brand()) + defaultStyle := lipgloss.NewStyle().Foreground(util.Success()) out.Result(headerStyle.Render(fmt.Sprintf(" %-4s %-8s %-45s %s", "#", "Type", "Name", "Default"))) out.Result(strings.Repeat("─", 70)) diff --git a/cmd/lk/console_tui.go b/cmd/lk/console_tui.go index 0827a9b3..6186b865 100644 --- a/cmd/lk/console_tui.go +++ b/cmd/lk/console_tui.go @@ -29,21 +29,18 @@ import ( agent "github.com/livekit/protocol/livekit/agent" "github.com/livekit/livekit-cli/v2/pkg/console" + "github.com/livekit/livekit-cli/v2/pkg/util" ) // Console-specific styles (tagStyle, greenStyle, redStyle, dimStyle, boldStyle, cyanStyle -// are inherited from simulate_tui.go which is always compiled) -var ( - lkCyan = lipgloss.Color("#1fd5f9") - lkPurple = lipgloss.Color("#8f83ff") - lkGreen = lipgloss.Color("#6BCB77") - lkRed = lipgloss.Color("#EF4444") - - labelStyle = lipgloss.NewStyle().Foreground(lkPurple) - cyanBoldStyle = lipgloss.NewStyle().Foreground(lkCyan).Bold(true) - greenBoldStyle = lipgloss.NewStyle().Foreground(lkGreen).Bold(true) - redBoldStyle = lipgloss.NewStyle().Foreground(lkRed).Bold(true) -) +// are inherited from simulate_tui.go which is always compiled). Colors are pulled from the +// active theme palette at render time, so they follow `lk set-theme`. +func labelStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(util.Accent()) } +func cyanBoldStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(util.Brand()).Bold(true) } +func greenBoldStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(util.Success()).Bold(true) +} +func redBoldStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(util.Error()).Bold(true) } // Unicode block characters for frequency visualizer (matching Python console) var blocks = []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"} @@ -384,8 +381,8 @@ func (m *consoleModel) updateTextMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // ● You // text here printCmd := tea.Println( - "\n " + lipgloss.NewStyle().Foreground(lkCyan).Render("● ") + - cyanBoldStyle.Render("You") + + "\n " + lipgloss.NewStyle().Foreground(util.Brand()).Render("● ") + + cyanBoldStyle().Render("You") + "\n " + text + "\n", ) @@ -427,8 +424,8 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd m.partialTranscript = "" if text := e.UserInputTranscribed.Transcript; text != "" { cmds = append(cmds, tea.Println( - "\n "+lipgloss.NewStyle().Foreground(lkCyan).Render("● ")+ - cyanBoldStyle.Render("You")+ + "\n "+lipgloss.NewStyle().Foreground(util.Brand()).Render("● ")+ + cyanBoldStyle().Render("You")+ "\n "+text+"\n", )) } @@ -465,11 +462,11 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd if fco, ok := outputsByCallID[fc.CallId]; ok { if fco.IsError { b.WriteString("\n ") - b.WriteString(redBoldStyle.Render("✗ ")) - b.WriteString(redStyle.Render(truncateOutput(fco.Output))) + b.WriteString(redBoldStyle().Render("✗ ")) + b.WriteString(redStyle().Render(truncateOutput(fco.Output))) } else { b.WriteString("\n ") - b.WriteString(greenStyle.Render("✓ ")) + b.WriteString(greenStyle().Render("✓ ")) b.WriteString(dimStyle.Render(summarizeOutput(fco.Output))) } } @@ -479,7 +476,7 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd case *agent.AgentSessionEvent_Error_: cmds = append(cmds, tea.Println( - " "+redBoldStyle.Render("✗ ")+redStyle.Render(e.Error.Message), + " "+redBoldStyle().Render("✗ ")+redStyle().Render(e.Error.Message), )) } @@ -506,8 +503,8 @@ func formatChatItem(item *agent.ChatContext_ChatItem) string { var b strings.Builder b.WriteString("\n ") - b.WriteString(lipgloss.NewStyle().Foreground(lkGreen).Render("● ")) - b.WriteString(greenBoldStyle.Render("Agent")) + b.WriteString(lipgloss.NewStyle().Foreground(util.Success()).Render("● ")) + b.WriteString(greenBoldStyle().Render("Agent")) for tl := range strings.SplitSeq(text, "\n") { b.WriteString("\n ") b.WriteString(tl) @@ -521,8 +518,8 @@ func formatChatItem(item *agent.ChatContext_ChatItem) string { if h.OldAgentId != nil && *h.OldAgentId != "" { old = dimStyle.Render(*h.OldAgentId) + " → " } - return " " + lipgloss.NewStyle().Foreground(lkPurple).Render("● ") + - dimStyle.Render("handoff: ") + old + labelStyle.Render(h.NewAgentId) + return " " + lipgloss.NewStyle().Foreground(util.Accent()).Render("● ") + + dimStyle.Render("handoff: ") + old + labelStyle().Render(h.NewAgentId) } return "" } @@ -538,7 +535,7 @@ func (m consoleModel) View() string { if m.shuttingDown { b.WriteString("\n ") - b.WriteString(labelStyle.Render("Shutting down agent...")) + b.WriteString(labelStyle().Render("Shutting down agent...")) b.WriteString(" ") b.WriteString(dimStyle.Render("ctrl+C to force")) b.WriteString("\n") @@ -568,7 +565,7 @@ func (m consoleModel) View() string { if m.audioError != "" { b.WriteString("\n") b.WriteString(" ") - b.WriteString(redStyle.Render("audio: " + m.audioError)) + b.WriteString(redStyle().Render("audio: " + m.audioError)) } if m.showShortcuts { @@ -584,7 +581,7 @@ func (m consoleModel) View() string { } else { // ── Audio visualizer (matching old Python FrequencyVisualizer) ── b.WriteString(" ") - b.WriteString(labelStyle.Render(m.inputDev)) + b.WriteString(labelStyle().Render(m.inputDev)) b.WriteString(" ") bands := m.pipeline.FFTBands() for _, band := range bands { @@ -601,7 +598,7 @@ func (m consoleModel) View() string { if m.pipeline.Muted() { b.WriteString(" ") - b.WriteString(redBoldStyle.Render("MUTED")) + b.WriteString(redBoldStyle().Render("MUTED")) } // Partial transcription on same line (dim) @@ -677,7 +674,7 @@ func formatMetrics(m *agent.MetricsReport) string { if m.E2ELatency != nil { label := "e2e " + formatMs(*m.E2ELatency) if *m.E2ELatency >= 1.0 { - parts = append(parts, redStyle.Render(label)) + parts = append(parts, redStyle().Render(label)) } else { parts = append(parts, dimStyle.Render(label)) } diff --git a/cmd/lk/main.go b/cmd/lk/main.go index bac4163f..82b48c74 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -29,6 +29,7 @@ import ( lksdk "github.com/livekit/server-sdk-go/v2" livekitcli "github.com/livekit/livekit-cli/v2" + "github.com/livekit/livekit-cli/v2/pkg/config" "github.com/livekit/livekit-cli/v2/pkg/util" ) @@ -64,6 +65,7 @@ func main() { app.Commands = append(app.Commands, CloudCommands...) app.Commands = append(app.Commands, DocsCommands...) app.Commands = append(app.Commands, ProjectCommands...) + app.Commands = append(app.Commands, ThemeCommands...) app.Commands = append(app.Commands, RoomCommands...) app.Commands = append(app.Commands, TokenCommands...) app.Commands = append(app.Commands, JoinCommands...) @@ -92,7 +94,7 @@ func main() { checkForLegacyName() if err := app.Run(ctx, os.Args); err != nil { - errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + errStyle := lipgloss.NewStyle().Foreground(util.Error()) fmt.Fprintln(os.Stderr, errStyle.Render(err.Error())) os.Exit(1) } @@ -131,6 +133,14 @@ func initLogger(ctx context.Context, cmd *cli.Command) (context.Context, error) // defaults them to os.Stdout / os.Stderr, but they're overridable in tests). out = util.NewPrinter(cmd.Root().Writer, cmd.Root().ErrWriter, cmd.Bool("quiet")) + // Apply the persisted color theme before any output/forms render. An empty value + // resolves to the default; an invalid stored value is reported and falls back. + if conf, err := config.LoadOrCreate(); err == nil { + if err := util.SetTheme(conf.Theme); err != nil { + out.Warnf("%v; using default theme", err) + } + } + return nil, nil } diff --git a/cmd/lk/project.go b/cmd/lk/project.go index 85039005..d1c753f3 100644 --- a/cmd/lk/project.go +++ b/cmd/lk/project.go @@ -166,6 +166,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { prompts = append(prompts, huh.NewInput(). Title("Project Name"). Placeholder("my-project"). + Prompt(""). Validate(validateName). Value(&p.Name)) } @@ -187,6 +188,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { prompts = append(prompts, huh.NewInput(). Title("Project URL"). Placeholder("wss://my-project.livekit.cloud"). + Prompt(""). Validate(validateURL). Value(&p.URL)) } @@ -207,6 +209,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { prompts = append(prompts, huh.NewInput(). Title("API Key"). Placeholder("APIxxxxxxxxxxxx"). + Prompt(""). Validate(validateKey). Value(&p.APIKey)) } @@ -221,6 +224,7 @@ func addProject(ctx context.Context, cmd *cli.Command) error { prompts = append(prompts, huh.NewInput(). Title("API Secret"). Placeholder("****************************"). + Prompt(""). Validate(validateKey). Value(&p.APISecret)) } diff --git a/cmd/lk/simulate_matrix.go b/cmd/lk/simulate_matrix.go index 03f0402b..3139ab96 100644 --- a/cmd/lk/simulate_matrix.go +++ b/cmd/lk/simulate_matrix.go @@ -21,6 +21,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + + "github.com/livekit/livekit-cli/v2/pkg/util" ) const ( @@ -32,14 +34,22 @@ const ( var matrixCharset = []rune("ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホ0123456789") +// The "digital rain" head + green gradient is a deliberate standalone effect (a bright +// leading glyph fading through three greens), not part of the semantic theme palette, so +// it keeps its fixed shades regardless of theme. var ( - matrixHeadStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("231")).Bold(true) - matrixTier1Style = lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true) - matrixTier2Style = lipgloss.NewStyle().Foreground(lipgloss.Color("34")) - matrixTier3Style = lipgloss.NewStyle().Foreground(lipgloss.Color("22")) - matrixCursorMarkerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + matrixHeadStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("231")).Bold(true) + matrixTier1Style = lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true) + matrixTier2Style = lipgloss.NewStyle().Foreground(lipgloss.Color("34")) + matrixTier3Style = lipgloss.NewStyle().Foreground(lipgloss.Color("22")) ) +// matrixCursorMarkerStyle uses the active theme's brand color so the cursor ties into the +// selected theme. +func matrixCursorMarkerStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(util.Brand()).Bold(true) +} + // matrixRow describes the underlying text layer for one row of the rain area. // The renderer composites rain on top of this neutral description without // needing to know anything about the upstream domain (jobs, IDs, etc.). @@ -312,7 +322,7 @@ func writeMatrixRun(b *strings.Builder, cat int, rs []rune, iconStyle *lipgloss. b.WriteString(s) } case mcCursor: - b.WriteString(matrixCursorMarkerStyle.Render(s)) + b.WriteString(matrixCursorMarkerStyle().Render(s)) default: b.WriteString(s) } diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 91b237bd..fc3dea00 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -27,6 +27,8 @@ import ( "github.com/livekit/protocol/livekit" agent "github.com/livekit/protocol/livekit/agent" + + "github.com/livekit/livekit-cli/v2/pkg/util" ) func runSimulateTUI(config *simulateConfig) error { @@ -59,15 +61,20 @@ func runSimulateTUI(config *simulateConfig) error { // --- Styles --- +// Color styles are functions so they read the active theme palette at render time and +// follow `lk set-theme`. The colorless styles below stay vars. +func tagStyle() lipgloss.Style { + return lipgloss.NewStyle().Background(util.Brand()).Foreground(lipgloss.Color("0")).Bold(true).Padding(0, 1) +} +func greenStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(util.Success()) } +func redStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(util.Error()) } +func yellowStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(util.Warning()) } +func cyanStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(util.Brand()).Bold(true) } + var ( - tagStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1fd5f9")).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 1) - greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) - yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#e5a00d")) dimStyle = lipgloss.NewStyle().Faint(true) boldStyle = lipgloss.NewStyle().Bold(true) reverseStyle = lipgloss.NewStyle().Reverse(true) - cyanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) simSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} ) @@ -717,7 +724,7 @@ func (m *simulateModel) View() string { func (m *simulateModel) viewSetup() string { var b strings.Builder b.WriteString("\n") - b.WriteString(tagStyle.Render("Agent Simulation")) + b.WriteString(tagStyle().Render("Agent Simulation")) b.WriteString("\n\n") if m.config.pc != nil && m.config.pc.Name != "" { @@ -746,10 +753,14 @@ func (m *simulateModel) viewSetup() string { if title == "" { title = "Scenarios" } - b.WriteString(" " + cyanStyle.Render(title) + dimStyle.Render( - fmt.Sprintf(" %d from %s", len(g.GetScenarios()), m.config.scenariosPath)) + "\n") + b.WriteString(" ") + b.WriteString(cyanStyle().Render(title)) + b.WriteString(dimStyle.Render( + fmt.Sprintf(" %d from %s", len(g.GetScenarios()), m.config.scenariosPath))) + b.WriteString("\n") for _, s := range g.GetScenarios() { - b.WriteString(dimStyle.Render(" • "+s.GetLabel()) + "\n") + b.WriteString(dimStyle.Render(" • " + s.GetLabel())) + b.WriteString("\n") } } @@ -765,12 +776,12 @@ func (m *simulateModel) viewSetup() string { if m.run != nil && m.run.GetNumSimulations() > 0 { n = m.run.GetNumSimulations() } - fmt.Fprintf(&b, " %s Generating %d scenarios %s %s\n", yellowStyle.Render("⏺"), n, m.spinner(), dimStyle.Render(elapsed.String())) + fmt.Fprintf(&b, " %s Generating %d scenarios %s %s\n", yellowStyle().Render("⏺"), n, m.spinner(), dimStyle.Render(elapsed.String())) } if m.err != nil { b.WriteString("\n") - b.WriteString(redStyle.Render(" " + m.err.Error())) + b.WriteString(redStyle().Render(" " + m.err.Error())) b.WriteString("\n") if m.agent != nil { b.WriteString("\n") @@ -793,7 +804,7 @@ func (m *simulateModel) hasLogs() bool { } func (m *simulateModel) spinner() string { - return yellowStyle.Render(simSpinnerFrames[m.spinnerIdx%len(simSpinnerFrames)]) + return yellowStyle().Render(simSpinnerFrames[m.spinnerIdx%len(simSpinnerFrames)]) } func (m *simulateModel) renderSteps() string { @@ -805,12 +816,12 @@ func (m *simulateModel) renderSteps() string { if s.elapsed > 0 { elapsed = " " + dimStyle.Render(s.elapsed.Round(time.Millisecond).String()) } - fmt.Fprintf(&b, " %s %s%s\n", greenStyle.Render("✓"), s.label, elapsed) + fmt.Fprintf(&b, " %s %s%s\n", greenStyle().Render("✓"), s.label, elapsed) case "running": elapsed := time.Since(m.stepStart).Truncate(time.Second) - fmt.Fprintf(&b, " %s %s %s %s\n", yellowStyle.Render("⏺"), s.label, m.spinner(), dimStyle.Render(elapsed.String())) + fmt.Fprintf(&b, " %s %s %s %s\n", yellowStyle().Render("⏺"), s.label, m.spinner(), dimStyle.Render(elapsed.String())) case "failed": - fmt.Fprintf(&b, " %s %s\n", redStyle.Render("✗"), s.label) + fmt.Fprintf(&b, " %s %s\n", redStyle().Render("✗"), s.label) default: fmt.Fprintf(&b, " %s %s\n", dimStyle.Render("–"), s.label) } @@ -835,23 +846,24 @@ func (m *simulateModel) getDashboardURL() string { func (m *simulateModel) viewFailed() string { var b strings.Builder b.WriteString("\n") - b.WriteString(tagStyle.Render("Agent Simulation")) + b.WriteString(tagStyle().Render("Agent Simulation")) b.WriteString(" ") b.WriteString(dimStyle.Render(m.runID)) if url := m.getDashboardURL(); url != "" { - b.WriteString(" " + dimStyle.Render(url)) + b.WriteString(" ") + b.WriteString(dimStyle.Render(url)) } b.WriteString("\n\n") b.WriteString(" ") - b.WriteString(redStyle.Bold(true).Render("Failed")) + b.WriteString(redStyle().Bold(true).Render("Failed")) b.WriteString("\n\n") if m.run.Error != "" { for line := range strings.SplitSeq(m.run.Error, "\n") { - b.WriteString(redStyle.Render(" " + line)) + b.WriteString(redStyle().Render(" " + line)) b.WriteString("\n") } } else { - b.WriteString(redStyle.Render(" (no error details available)")) + b.WriteString(redStyle().Render(" (no error details available)")) b.WriteString("\n") } b.WriteString("\n") @@ -871,7 +883,7 @@ func (m *simulateModel) viewRunning() string { var b strings.Builder b.WriteString("\n") - b.WriteString(tagStyle.Render("Agent Simulation")) + b.WriteString(tagStyle().Render("Agent Simulation")) b.WriteString(" ") b.WriteString(dimStyle.Render(m.runID)) if url := m.getDashboardURL(); url != "" { @@ -896,10 +908,7 @@ func (m *simulateModel) viewRunning() string { m.descScrollOff = maxScroll } start := m.descScrollOff - end := start + descBudget - if end > len(lines) { - end = len(lines) - } + end := min(start+descBudget, len(lines)) if start > 0 { b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more\n", start))) } @@ -943,11 +952,11 @@ func (m *simulateModel) viewRunning() string { b.WriteString(m.renderJobList()) if m.run.Status == livekit.SimulationRun_STATUS_SUMMARIZING { - fmt.Fprintf(&b, "\n %s %s %s\n", yellowStyle.Render("⏺"), yellowStyle.Render("Generating summary..."), m.spinner()) + fmt.Fprintf(&b, "\n %s %s %s\n", yellowStyle().Render("⏺"), yellowStyle().Render("Generating summary..."), m.spinner()) } else if m.run.Summary != nil { b.WriteString(m.renderSummary()) } else if isTerminalRunStatus(m.run.Status) { - fmt.Fprintf(&b, "\n %s %s\n", yellowStyle.Render("⚠"), yellowStyle.Render("The summary for this run is not available")) + fmt.Fprintf(&b, "\n %s %s\n", yellowStyle().Render("⚠"), yellowStyle().Render("The summary for this run is not available")) } } @@ -987,11 +996,11 @@ func (m *simulateModel) renderHeader() string { header := boldStyle.Render("Simulation") + " — " switch style { case "green": - header += greenStyle.Bold(true).Render(label) + header += greenStyle().Bold(true).Render(label) case "red": - header += redStyle.Bold(true).Render(label) + header += redStyle().Bold(true).Render(label) case "yellow": - header += yellowStyle.Bold(true).Render(label) + header += yellowStyle().Bold(true).Render(label) } return " " + header } @@ -1010,13 +1019,13 @@ func (m *simulateModel) renderCounts() string { var parts []string parts = append(parts, boldStyle.Render(fmt.Sprintf("%d/%d", done, total))) if passed > 0 { - parts = append(parts, greenStyle.Render(fmt.Sprintf("%d passed", passed))) + parts = append(parts, greenStyle().Render(fmt.Sprintf("%d passed", passed))) } if failed > 0 { - parts = append(parts, redStyle.Render(fmt.Sprintf("%d failed", failed))) + parts = append(parts, redStyle().Render(fmt.Sprintf("%d failed", failed))) } if running > 0 { - parts = append(parts, yellowStyle.Render(fmt.Sprintf("%d running", running))) + parts = append(parts, yellowStyle().Render(fmt.Sprintf("%d running", running))) } elapsed := "" @@ -1174,16 +1183,18 @@ func plainJobIcon(job *livekit.SimulationRun_Job) rune { } func jobIconStylePtr(job *livekit.SimulationRun_Job) *lipgloss.Style { + var s lipgloss.Style switch job.Status { case livekit.SimulationRun_Job_STATUS_COMPLETED: - return &greenStyle + s = greenStyle() case livekit.SimulationRun_Job_STATUS_FAILED: - return &redStyle + s = redStyle() case livekit.SimulationRun_Job_STATUS_RUNNING: - return &yellowStyle + s = yellowStyle() default: return &dimStyle } + return &s } // buildMatrixRows produces one matrixRow per visible line of the job list, @@ -1284,17 +1295,17 @@ func (m *simulateModel) renderDetail() string { if job.Error != "" { b.WriteString("\n") if job.Status == livekit.SimulationRun_Job_STATUS_COMPLETED { - b.WriteString(greenStyle.Bold(true).Render(" Result:")) + b.WriteString(greenStyle().Bold(true).Render(" Result:")) b.WriteString("\n") for line := range strings.SplitSeq(wrapStyle.Render(job.Error), "\n") { - b.WriteString(greenStyle.Render(" " + line)) + b.WriteString(greenStyle().Render(" " + line)) b.WriteString("\n") } } else { - b.WriteString(redStyle.Bold(true).Render(" Error:")) + b.WriteString(redStyle().Bold(true).Render(" Error:")) b.WriteString("\n") for line := range strings.SplitSeq(wrapStyle.Render(job.Error), "\n") { - b.WriteString(redStyle.Render(" " + line)) + b.WriteString(redStyle().Render(" " + line)) b.WriteString("\n") } } @@ -1381,13 +1392,13 @@ func (m *simulateModel) renderSummary() string { b.WriteString(" ") b.WriteString(boldStyle.Render("Summary")) fmt.Fprintf(&b, " %s %s\n\n", - greenStyle.Render(fmt.Sprintf("%d passed", summary.Passed)), - redStyle.Render(fmt.Sprintf("%d failed", summary.Failed))) + greenStyle().Render(fmt.Sprintf("%d passed", summary.Passed)), + redStyle().Render(fmt.Sprintf("%d failed", summary.Failed))) wrapWidth := max(m.width-6, 40) if summary.GoingWell != "" { - b.WriteString(greenStyle.Bold(true).Render(" Going well:")) + b.WriteString(greenStyle().Bold(true).Render(" Going well:")) b.WriteString("\n") wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(summary.GoingWell) for line := range strings.SplitSeq(wrapped, "\n") { @@ -1399,7 +1410,7 @@ func (m *simulateModel) renderSummary() string { } if summary.ToImprove != "" { - b.WriteString(yellowStyle.Bold(true).Render(" To improve:")) + b.WriteString(yellowStyle().Bold(true).Render(" To improve:")) b.WriteString("\n") wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(summary.ToImprove) for line := range strings.SplitSeq(wrapped, "\n") { @@ -1411,7 +1422,7 @@ func (m *simulateModel) renderSummary() string { } if len(summary.Issues) > 0 { - b.WriteString(redStyle.Bold(true).Render(" Issues:")) + b.WriteString(redStyle().Bold(true).Render(" Issues:")) b.WriteString("\n") issueWrap := max( // account for " N. " prefix @@ -1444,11 +1455,6 @@ func (m *simulateModel) renderSummary() string { return b.String() } -var ( - lkCyanColor = lipgloss.Color("#1fd5f9") - lkGreenColor = lipgloss.Color("#6BCB77") -) - func (m *simulateModel) renderChatTranscript(jobID string) string { if m.run.Summary == nil || m.run.Summary.ChatHistory == nil { return "" @@ -1458,8 +1464,8 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { return "" } - userStyle := lipgloss.NewStyle().Foreground(lkCyanColor).Bold(true) - agentStyle := lipgloss.NewStyle().Foreground(lkGreenColor).Bold(true) + userStyle := lipgloss.NewStyle().Foreground(util.Brand()).Bold(true) + agentStyle := lipgloss.NewStyle().Foreground(util.Success()).Bold(true) var b strings.Builder b.WriteString("\n") @@ -1644,7 +1650,7 @@ func (m *simulateModel) renderHint() string { parts = append(parts, "q quit") footer := dimStyle.Render(" " + strings.Join(parts, " · ")) if m.exportStatus != "" { - footer += "\n" + greenStyle.Render(" "+m.exportStatus) + footer += "\n" + greenStyle().Render(" "+m.exportStatus) } return footer } @@ -1652,11 +1658,11 @@ func (m *simulateModel) renderHint() string { func jobIcon(job *livekit.SimulationRun_Job) string { switch job.Status { case livekit.SimulationRun_Job_STATUS_COMPLETED: - return greenStyle.Render("✓") + return greenStyle().Render("✓") case livekit.SimulationRun_Job_STATUS_FAILED: - return redStyle.Render("✗") + return redStyle().Render("✗") case livekit.SimulationRun_Job_STATUS_RUNNING: - return yellowStyle.Render("⏺") + return yellowStyle().Render("⏺") default: return dimStyle.Render("⏺") } diff --git a/cmd/lk/theme.go b/cmd/lk/theme.go new file mode 100644 index 00000000..3c225b22 --- /dev/null +++ b/cmd/lk/theme.go @@ -0,0 +1,58 @@ +// 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 ( + "context" + "errors" + + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/util" +) + +var ThemeCommands = []*cli.Command{ + { + Name: "set-theme", + Usage: "Set the CLI color theme", + UsageText: "lk set-theme THEME", + ArgsUsage: "THEME (one of: default, livekit)", + Hidden: true, + Before: loadProjectConfig, + Action: setTheme, + }, +} + +func setTheme(ctx context.Context, cmd *cli.Command) error { + name := cmd.Args().First() + if name == "" { + _ = cli.ShowSubcommandHelp(cmd) + return errors.New("theme is required (one of: default, livekit)") + } + + // SetTheme validates the name and also applies it, so the confirmation below renders + // in the newly selected theme. + if err := util.SetTheme(name); err != nil { + return err + } + + cliConfig.Theme = name + if err := cliConfig.PersistIfNeeded(); err != nil { + return err + } + + out.Statusf("Theme set to [%s]", util.Accented(name)) + return nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 42d444fe..eebe19d7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -29,6 +29,7 @@ type CLIConfig struct { DefaultProject string `yaml:"default_project"` Projects []ProjectConfig `yaml:"projects"` DeviceName string `yaml:"device_name"` + Theme string `yaml:"theme"` // absent from YAML hasPersisted bool } @@ -170,8 +171,8 @@ func (c *CLIConfig) RemoveProject(name string) error { } func (c *CLIConfig) PersistIfNeeded() error { - if len(c.Projects) == 0 && !c.hasPersisted { - // doesn't need to be persisted + if len(c.Projects) == 0 && c.Theme == "" && !c.hasPersisted { + // nothing worth persisting yet return nil } diff --git a/pkg/loadtester/agentloadtester.go b/pkg/loadtester/agentloadtester.go index acfd4cb5..2abf6c8d 100644 --- a/pkg/loadtester/agentloadtester.go +++ b/pkg/loadtester/agentloadtester.go @@ -303,8 +303,8 @@ func (t *AgentLoadTester) printStats() { t.lock.Lock() defer t.lock.Unlock() - checkStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // Green - crossStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Red + checkStyle := lipgloss.NewStyle().Foreground(util.Success()) + crossStyle := lipgloss.NewStyle().Foreground(util.Error()) table := util.CreateTable(). Headers("#", "Room", "Agent Dispatched At", "Agent Joined", "Agent Join Delay", "Agent Track Subscribed", "Echo Track Published") diff --git a/pkg/util/theme.go b/pkg/util/theme.go index 335bfc94..874e9303 100644 --- a/pkg/util/theme.go +++ b/pkg/util/theme.go @@ -15,42 +15,164 @@ package util import ( + "fmt" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) +// ThemeName identifies a color theme. The active theme is selected once at startup +// (see SetTheme) from the value persisted in the CLI config. +type ThemeName string + +const ( + // ThemeDefault uses only ANSI palette colors, so it adapts to the user's terminal + // color scheme. This is the original look. + ThemeDefault ThemeName = "default" + // ThemeLiveKit uses the LiveKit brand palette (truecolor hex with light/dark variants). + ThemeLiveKit ThemeName = "livekit" +) + +// ValidThemes lists the selectable theme names, for validation and help text. +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 +} + +// palettes defines the semantic colors per theme. AdaptiveColor{Light, Dark}: Light is the +// shade used on light terminals, Dark on dark terminals. +var palettes = map[ThemeName]palette{ + // Default: ANSI only (normal on light, bright on dark). Adapts to the terminal palette. + ThemeDefault: { + Brand: lipgloss.Color("6"), // cyan + Accent: lipgloss.Color("5"), // magenta + Success: lipgloss.Color("2"), // green + Warning: lipgloss.Color("3"), // yellow + Error: lipgloss.Color("1"), // red + }, + // 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"}, + }, +} + +// Active theme state. Populated by applyTheme; switched once at startup via SetTheme. These +// are package-level so existing call sites (util.Theme, util.Accented, util.Fg, …) keep +// working; they are read at render time, which always happens after the theme is selected. var ( - // brandCyan is the LiveKit accent (matches the logo / tag chips). - brandCyan = lipgloss.Color("#1fd5f9") - - Theme = func() *huh.Theme { - t := huh.ThemeBase16() - // Selected action uses the brand cyan with black text, mirroring the LiveKit tag. - t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("0")).Background(brandCyan).Bold(true) - t.Focused.Title = t.Focused.Title.Foreground(brandCyan).Bold(true) - t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(brandCyan) - t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(brandCyan) - return t - }() - - Accented = func(text string) string { - return Theme.Focused.Title.Render(text) + activeTheme = ThemeDefault + activePalette = palettes[ThemeDefault] + + // Theme is the huh form theme for the active color theme. + Theme *huh.Theme + + Fg lipgloss.AdaptiveColor + FormBaseStyle lipgloss.Style + FormHeaderStyle lipgloss.Style +) + +func init() { + applyTheme(ThemeDefault) +} + +// SetTheme selects the active theme by name. An empty name resolves to the default. It +// returns an error for any other unrecognized name (used to validate `lk set-theme`). +func SetTheme(name string) error { + tn := ThemeName(name) + if name == "" { + tn = ThemeDefault } - Dimmed = func(text string) string { - return Theme.Focused.Description.Render(text) + if _, ok := palettes[tn]; !ok { + return fmt.Errorf("unknown theme %q (valid: %s, %s)", name, ThemeDefault, ThemeLiveKit) } + applyTheme(tn) + return nil +} - Fg = lipgloss.AdaptiveColor{Light: "235", Dark: "252"} - FormBaseStyle = Theme.Form.Base.Foreground(Fg).Padding(0, 1) +// applyTheme installs a theme's huh form theme and derived styles into the package vars. +func applyTheme(tn ThemeName) { + activeTheme = tn + activePalette = palettes[tn] + Theme = buildHuhTheme(tn, activePalette) + Fg = lipgloss.AdaptiveColor{Light: "235", Dark: "252"} + FormBaseStyle = Theme.Form.Base.Foreground(Fg).Padding(0, 1) FormHeaderStyle = FormBaseStyle.Bold(true) +} - // Form helpers - Confirm = func() *huh.Select[bool] { - return huh.NewSelect[bool](). - Options( - huh.NewOption("Yes", true), - huh.NewOption("No", false), - ). - Inline(false) +// 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() + switch tn { + case ThemeLiveKit: + // Selected action uses the brand color with black text, mirroring the LiveKit tag. + t.Focused.Title = t.Focused.Title.Foreground(p.Brand).Bold(true) + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("0")).Background(p.Brand).Bold(true) + case ThemeDefault: + fallthrough + default: + t = huh.ThemeBase16() + // ANSI: white text on a blue selection, base16 defaults elsewhere. + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("7")).Background(lipgloss.Color("4")) } -) + + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(p.Accent).SetString("▶︎ ") + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(p.Accent) + t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(p.Accent).SetString("[x] ") + t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.SetString("[ ] ") + t.Focused.MultiSelectSelector = t.Focused.SelectSelector + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(p.Accent).SetString("▶︎") + t.Form.Base = t.Form.Base.BorderForeground(Fg) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(p.Error).SetString(" ×") + t.Focused.ErrorMessage = t.Focused.ErrorIndicator + + t.Blurred.SelectSelector = t.Focused.SelectSelector.SetString(" ") + t.Blurred.SelectedOption = t.Focused.SelectedOption + t.Blurred.SelectedPrefix = t.Focused.SelectedPrefix + t.Blurred.UnselectedPrefix = t.Focused.UnselectedPrefix + t.Blurred.MultiSelectSelector = t.Focused.MultiSelectSelector.SetString(" ") + t.Blurred.TextInput.Prompt = t.Focused.TextInput.Prompt.SetString(" ") + t.Blurred.ErrorIndicator = t.Focused.ErrorIndicator + t.Blurred.ErrorMessage = t.Focused.ErrorMessage + + return t +} + +// 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 } + +// Accented renders text in the active theme's title style (brand color under livekit). +func Accented(text string) string { + return Theme.Focused.Title.Render(text) +} + +// Dimmed renders text in the active theme's muted/description style. +func Dimmed(text string) string { + return Theme.Focused.Description.Render(text) +} + +// Confirm is a yes/no select styled by the active theme. +func Confirm() *huh.Select[bool] { + return huh.NewSelect[bool](). + Options( + huh.NewOption("Yes", true), + huh.NewOption("No", false), + ). + Inline(false) +}