From ad4ce380da2cb92083d42a1056fd3fd45069a6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Wed, 1 Apr 2026 22:43:49 -0700 Subject: [PATCH 01/30] add console, simulate, agent dev commands with TUI and protobuf IPC --- .github/workflows/build-binaries.yaml | 170 ++++ .github/workflows/build.yaml | 139 ++- .github/workflows/test.yaml | 2 + Formula/lk.rb | 47 + Makefile | 3 + cmd/lk/agent.go | 1 + cmd/lk/agent_reload.go | 99 ++ cmd/lk/agent_run.go | 254 +++++ cmd/lk/agent_watcher.go | 242 +++++ cmd/lk/console.go | 291 ++++++ cmd/lk/console_stub.go | 46 + cmd/lk/console_tui.go | 663 +++++++++++++ cmd/lk/simulate.go | 221 +++++ cmd/lk/simulate_subprocess.go | 315 ++++++ cmd/lk/simulate_tui.go | 1265 +++++++++++++++++++++++++ cmd/lk/utils.go | 25 +- go.mod | 10 +- go.sum | 4 - pkg/agentfs/detect.go | 26 + pkg/console/fft.go | 64 ++ pkg/console/pipeline.go | 609 ++++++++++++ pkg/console/ringbuffer.go | 130 +++ pkg/console/tcp.go | 90 ++ pkg/ipc/ipc.go | 96 ++ 24 files changed, 4786 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/build-binaries.yaml create mode 100644 Formula/lk.rb create mode 100644 cmd/lk/agent_reload.go create mode 100644 cmd/lk/agent_run.go create mode 100644 cmd/lk/agent_watcher.go create mode 100644 cmd/lk/console.go create mode 100644 cmd/lk/console_stub.go create mode 100644 cmd/lk/console_tui.go create mode 100644 cmd/lk/simulate.go create mode 100644 cmd/lk/simulate_subprocess.go create mode 100644 cmd/lk/simulate_tui.go create mode 100644 pkg/console/fft.go create mode 100644 pkg/console/pipeline.go create mode 100644 pkg/console/ringbuffer.go create mode 100644 pkg/console/tcp.go create mode 100644 pkg/ipc/ipc.go diff --git a/.github/workflows/build-binaries.yaml b/.github/workflows/build-binaries.yaml new file mode 100644 index 00000000..acefc9a6 --- /dev/null +++ b/.github/workflows/build-binaries.yaml @@ -0,0 +1,170 @@ +# Copyright 2023 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. + +name: Build binaries + +on: + workflow_call: + inputs: + output_dir: + type: string + default: bin + upload_release: + type: boolean + default: false + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + suffix: darwin_arm64 + - os: ubuntu-latest + suffix: linux_amd64 + zig_target: x86_64-linux-gnu.2.28 + alsa_arch: amd64 + alsa_triple: x86_64-linux-gnu + - os: ubuntu-latest + suffix: linux_arm64 + zig_target: aarch64-linux-gnu.2.28 + alsa_arch: arm64 + alsa_triple: aarch64-linux-gnu + goarch: arm64 + - os: ubuntu-latest + suffix: linux_arm + zig_target: arm-linux-gnueabihf.2.28 + alsa_arch: armhf + alsa_triple: arm-linux-gnueabihf + goarch: arm + goarm: "7" + - os: ubuntu-latest + suffix: windows_amd64 + zig_target: x86_64-windows-gnu + goos: windows + goarch: amd64 + - os: ubuntu-latest + suffix: windows_arm64 + zig_target: aarch64-windows-gnu + goos: windows + goarch: arm64 + - os: ubuntu-latest + suffix: windows_arm + goos: windows + goarch: arm + goarm: "7" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + lfs: 'true' + submodules: true + + - run: git lfs pull + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + + - name: Install Zig + if: matrix.zig_target + uses: mlugg/setup-zig@v2 + with: + version: 0.14.1 + + - name: Install ALSA headers + if: matrix.alsa_arch + run: | + sudo dpkg --add-architecture ${{ matrix.alsa_arch }} + if [ "${{ matrix.alsa_arch }}" != "amd64" ]; then + CODENAME=$(lsb_release -cs) + # Restrict existing sources to amd64 to avoid 404s for foreign arch + for f in /etc/apt/sources.list.d/*.sources; do + grep -q '^Architectures:' "$f" || sudo sed -i '/^Types:/a Architectures: amd64 i386' "$f" + done + # Add ports.ubuntu.com for the foreign architecture + printf 'Types: deb\nURIs: http://ports.ubuntu.com/ubuntu-ports\nSuites: %s %s-updates\nComponents: main universe\nArchitectures: %s\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \ + "$CODENAME" "$CODENAME" "${{ matrix.alsa_arch }}" | sudo tee /etc/apt/sources.list.d/ports.sources + fi + sudo apt-get update + sudo apt-get install -y libasound2-dev:${{ matrix.alsa_arch }} + + - name: Generate Windows import libraries + if: matrix.goos == 'windows' && matrix.zig_target + run: | + ZIG_LIB=$(zig env | jq -r '.lib_dir') + echo "ZIG_LIB=${ZIG_LIB}" >> "$GITHUB_ENV" + LIB_DIR="${ZIG_LIB}/libc/mingw/lib-common" + # Zig bundles MinGW .def files but lld needs .a import libraries. + # Go's compiled objects embed COFF /DEFAULTLIB directives (e.g. dbghelp, + # bcrypt) that lld resolves directly, bypassing Zig's lazy .def→.a + # generation. Pre-generate all import libraries so lld can find them. + MACHINE=${{ matrix.goarch == 'amd64' && 'i386:x86-64' || 'arm64' }} + for def in "${LIB_DIR}"/*.def; do + lib=$(basename "$def" .def) + [ -f "${LIB_DIR}/lib${lib}.a" ] && continue + zig dlltool -d "$def" -l "${LIB_DIR}/lib${lib}.a" -m "$MACHINE" 2>/dev/null || true + done + + - name: Build + env: + CGO_ENABLED: ${{ (matrix.goos && !matrix.zig_target) && '0' || '1' }} + CC: ${{ matrix.zig_target && format('zig cc -target {0}', matrix.zig_target) || '' }} + CXX: ${{ matrix.zig_target && format('zig c++ -target {0}', matrix.zig_target) || '' }} + # Zig uses its own sysroot; point it at the system ALSA headers and libraries + CGO_CFLAGS: ${{ matrix.alsa_triple && format('-isystem /usr/include -isystem /usr/include/{0}', matrix.alsa_triple) || '' }} + CGO_LDFLAGS: ${{ matrix.alsa_triple && format('-L/usr/lib/{0}', matrix.alsa_triple) || '' }} + # -fms-extensions: enable __try/__except (SEH) used by WebRTC + # -DNTDDI_VERSION: target Windows 10 base to skip WinRT includes absent from MinGW + CGO_CXXFLAGS: ${{ matrix.goos == 'windows' && '-fms-extensions -DNTDDI_VERSION=0x0A000000' || '' }} + GOOS: ${{ matrix.goos || '' }} + GOARCH: ${{ matrix.goarch || '' }} + GOARM: ${{ matrix.goarm || '' }} + shell: bash + run: | + EXT=""; if [ "${GOOS:-}" = "windows" ]; then EXT=".exe"; fi + TAGS="" + if [ "$CGO_ENABLED" = "1" ]; then TAGS="-tags console"; fi + # Force external linking for Windows so Go uses zig cc (CC) as the linker, + # and add Zig's MinGW lib path so lld can find the generated import libraries. + EXTLD="" + if [ "${GOOS:-}" = "windows" ] && [ "$CGO_ENABLED" = "1" ]; then + EXTLD="-linkmode=external -extldflags '-L${ZIG_LIB}/libc/mingw/lib-common'" + fi + go build $TAGS -ldflags "-w -s $EXTLD" -o "${{ inputs.output_dir }}/lk${EXT}" ./cmd/lk + + - name: Verify binary + if: "!matrix.goos && !matrix.goarch" + run: ${{ inputs.output_dir }}/lk --help > /dev/null + + - name: Package and upload + if: inputs.upload_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + NAME="lk_${VERSION}_${{ matrix.suffix }}" + cp LICENSE ${{ inputs.output_dir }}/ + cp -r autocomplete ${{ inputs.output_dir }}/ + if [[ "${{ matrix.suffix }}" == windows_* ]]; then + cd ${{ inputs.output_dir }} && zip -r "../${NAME}.zip" lk.exe LICENSE autocomplete && cd .. + gh release upload "$TAG" "${NAME}.zip" --clobber + else + tar -czf "${NAME}.tar.gz" -C ${{ inputs.output_dir }} lk LICENSE autocomplete + gh release upload "$TAG" "${NAME}.tar.gz" --clobber + fi diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b87a6552..99b8baae 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -20,32 +20,149 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + jobs: - build: + lint-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/cache@v5 with: - path: | - ~/go/pkg/mod - ~/go/bin - ~/.cache - key: livekit-cli + submodules: true - name: Set up Go uses: actions/setup-go@v6 with: go-version: "1.25" - - name: Download Go modules - run: go mod download - - name: Static Check uses: dominikh/staticcheck-action@v1.4.0 with: version: "latest" install-go: false - - name: Run Go tests + - name: Test run: go test -v ./... + + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + suffix: darwin_arm64 + - os: ubuntu-latest + suffix: linux_amd64 + zig_target: x86_64-linux-gnu.2.28 + alsa_arch: amd64 + alsa_triple: x86_64-linux-gnu + - os: ubuntu-latest + suffix: linux_arm64 + zig_target: aarch64-linux-gnu.2.28 + alsa_arch: arm64 + alsa_triple: aarch64-linux-gnu + goarch: arm64 + - os: ubuntu-latest + suffix: linux_arm + zig_target: arm-linux-gnueabihf.2.28 + alsa_arch: armhf + alsa_triple: arm-linux-gnueabihf + goarch: arm + goarm: "7" + - os: ubuntu-latest + suffix: windows_amd64 + zig_target: x86_64-windows-gnu + goos: windows + goarch: amd64 + - os: ubuntu-latest + suffix: windows_arm64 + zig_target: aarch64-windows-gnu + goos: windows + goarch: arm64 + - os: ubuntu-latest + suffix: windows_arm + goos: windows + goarch: arm + goarm: "7" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + + - name: Install Zig + if: matrix.zig_target + uses: mlugg/setup-zig@v2 + with: + version: 0.14.1 + + - name: Install ALSA headers + if: matrix.alsa_arch + run: | + sudo dpkg --add-architecture ${{ matrix.alsa_arch }} + if [ "${{ matrix.alsa_arch }}" != "amd64" ]; then + CODENAME=$(lsb_release -cs) + # Restrict existing sources to amd64 to avoid 404s for foreign arch + for f in /etc/apt/sources.list.d/*.sources; do + grep -q '^Architectures:' "$f" || sudo sed -i '/^Types:/a Architectures: amd64 i386' "$f" + done + # Add ports.ubuntu.com for the foreign architecture + printf 'Types: deb\nURIs: http://ports.ubuntu.com/ubuntu-ports\nSuites: %s %s-updates\nComponents: main universe\nArchitectures: %s\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \ + "$CODENAME" "$CODENAME" "${{ matrix.alsa_arch }}" | sudo tee /etc/apt/sources.list.d/ports.sources + fi + sudo apt-get update + sudo apt-get install -y libasound2-dev:${{ matrix.alsa_arch }} + + - name: Generate Windows import libraries + if: matrix.goos == 'windows' && matrix.zig_target + run: | + ZIG_LIB=$(zig env | jq -r '.lib_dir') + echo "ZIG_LIB=${ZIG_LIB}" >> "$GITHUB_ENV" + LIB_DIR="${ZIG_LIB}/libc/mingw/lib-common" + # Zig bundles MinGW .def files but lld needs .a import libraries. + # Go's compiled objects embed COFF /DEFAULTLIB directives (e.g. dbghelp, + # bcrypt) that lld resolves directly, bypassing Zig's lazy .def→.a + # generation. Pre-generate all import libraries so lld can find them. + MACHINE=${{ matrix.goarch == 'amd64' && 'i386:x86-64' || 'arm64' }} + for def in "${LIB_DIR}"/*.def; do + lib=$(basename "$def" .def) + [ -f "${LIB_DIR}/lib${lib}.a" ] && continue + zig dlltool -d "$def" -l "${LIB_DIR}/lib${lib}.a" -m "$MACHINE" 2>/dev/null || true + done + + - name: Build + env: + CGO_ENABLED: ${{ (matrix.goos && !matrix.zig_target) && '0' || '1' }} + CC: ${{ matrix.zig_target && format('zig cc -target {0}', matrix.zig_target) || '' }} + CXX: ${{ matrix.zig_target && format('zig c++ -target {0}', matrix.zig_target) || '' }} + # Zig uses its own sysroot; point it at the system ALSA headers and libraries + CGO_CFLAGS: ${{ matrix.alsa_triple && format('-isystem /usr/include -isystem /usr/include/{0}', matrix.alsa_triple) || '' }} + CGO_LDFLAGS: ${{ matrix.alsa_triple && format('-L/usr/lib/{0}', matrix.alsa_triple) || '' }} + # -fms-extensions: enable __try/__except (SEH) used by WebRTC + # -DNTDDI_VERSION: target Windows 10 base to skip WinRT includes absent from MinGW + CGO_CXXFLAGS: ${{ matrix.goos == 'windows' && '-fms-extensions -DNTDDI_VERSION=0x0A000000' || '' }} + GOOS: ${{ matrix.goos || '' }} + GOARCH: ${{ matrix.goarch || '' }} + GOARM: ${{ matrix.goarm || '' }} + shell: bash + run: | + EXT=""; if [ "${GOOS:-}" = "windows" ]; then EXT=".exe"; fi + TAGS="" + if [ "$CGO_ENABLED" = "1" ]; then TAGS="-tags console"; fi + # Force external linking for Windows so Go uses zig cc (CC) as the linker, + # and add Zig's MinGW lib path so lld can find the generated import libraries. + EXTLD="" + if [ "${GOOS:-}" = "windows" ] && [ "$CGO_ENABLED" = "1" ]; then + EXTLD="-linkmode=external -extldflags '-L${ZIG_LIB}/libc/mingw/lib-common'" + fi + go build $TAGS -ldflags "-w -s $EXTLD" -o "bin/lk${EXT}" ./cmd/lk + + - name: Verify binary + if: "!matrix.goos && !matrix.goarch" + run: bin/lk --help > /dev/null diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 58485dc4..db3106cd 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -20,6 +20,8 @@ jobs: steps: - uses: actions/checkout@v6 + with: + submodules: true - name: Set up Go uses: actions/setup-go@v6 diff --git a/Formula/lk.rb b/Formula/lk.rb new file mode 100644 index 00000000..841854fe --- /dev/null +++ b/Formula/lk.rb @@ -0,0 +1,47 @@ +# typed: false +# frozen_string_literal: true + +# This formula is meant for a custom Homebrew tap (e.g. livekit/homebrew-livekit). +# It installs a prebuilt binary with console support (PortAudio + WebRTC AEC). +# Usage: brew install livekit/livekit/lk +class Lk < Formula + desc "Command-line interface to LiveKit (with console support)" + homepage "https://livekit.io" + license "Apache-2.0" + version "VERSION" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/livekit/livekit-cli/releases/download/vVERSION/lk_VERSION_darwin_arm64.tar.gz" + sha256 "SHA256_DARWIN_ARM64" + end + end + + on_linux do + if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? + url "https://github.com/livekit/livekit-cli/releases/download/vVERSION/lk_VERSION_linux_arm64.tar.gz" + sha256 "SHA256_LINUX_ARM64" + elsif Hardware::CPU.arm? + url "https://github.com/livekit/livekit-cli/releases/download/vVERSION/lk_VERSION_linux_arm.tar.gz" + sha256 "SHA256_LINUX_ARM" + else + url "https://github.com/livekit/livekit-cli/releases/download/vVERSION/lk_VERSION_linux_amd64.tar.gz" + sha256 "SHA256_LINUX_AMD64" + end + end + + def install + bin.install "lk" + bin.install_symlink "lk" => "livekit-cli" + + bash_completion.install "autocomplete/bash_autocomplete" => "lk" + fish_completion.install "autocomplete/fish_autocomplete" => "lk.fish" + zsh_completion.install "autocomplete/zsh_autocomplete" => "_lk" + end + + test do + output = shell_output("#{bin}/lk token create --list --api-key key --api-secret secret") + assert_match "valid for (mins): 5", output + assert_match "lk version #{version}", shell_output("#{bin}/lk --version") + end +end diff --git a/Makefile b/Makefile index 0b08d5aa..573fea25 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,9 @@ cli: check_lfs GOOS=windows GOARCH=amd64 go build -ldflags "-w -s" -o bin/lk.exe ./cmd/lk +console: + CGO_ENABLED=1 go build -tags console -ldflags "-w -s" -o bin/lk ./cmd/lk + install: cli ifeq ($(DETECTED_OS),Windows) cp bin/lk.exe $(GOBIN)/lk.exe diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index e6745d0a..e5092c71 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -347,6 +347,7 @@ var ( ArgsUsage: "[working-dir]", }, privateLinkCommands, + simulateCommand, }, }, } diff --git a/cmd/lk/agent_reload.go b/cmd/lk/agent_reload.go new file mode 100644 index 00000000..703e63ea --- /dev/null +++ b/cmd/lk/agent_reload.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "net" + "sync" + "time" + + agent "github.com/livekit/protocol/livekit/agent" + + "github.com/livekit/livekit-cli/v2/pkg/ipc" +) + +// reloadServer manages the dev-mode reload protocol between Go and Python processes. +// Flow: +// 1. Go → old Python: GetRunningJobsRequest → receives GetRunningJobsResponse (capture) +// 2. New Python → Go: GetRunningJobsRequest → Go replies with saved GetRunningJobsResponse (restore) +type reloadServer struct { + listener *ipc.Listener + mu sync.Mutex + savedJobs *agent.GetRunningAgentJobsResponse +} + +func newReloadServer() (*reloadServer, error) { + ln, err := ipc.Listen("127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("reload server: %w", err) + } + return &reloadServer{listener: ln}, nil +} + +func (rs *reloadServer) addr() string { + return rs.listener.Addr().String() +} + +// captureJobs sends GetRunningJobsRequest to the old Python process and stores the response. +func (rs *reloadServer) captureJobs(conn net.Conn) { + conn.SetDeadline(time.Now().Add(1500 * time.Millisecond)) + defer conn.SetDeadline(time.Time{}) + + req := &agent.AgentDevMessage{ + Message: &agent.AgentDevMessage_GetRunningJobsRequest{ + GetRunningJobsRequest: &agent.GetRunningAgentJobsRequest{}, + }, + } + if err := ipc.WriteProto(conn, req); err != nil { + fmt.Printf("reload: failed to send capture request: %v\n", err) + return + } + + resp := &agent.AgentDevMessage{} + if err := ipc.ReadProto(conn, resp); err != nil { + fmt.Printf("reload: failed to read capture response: %v\n", err) + return + } + + if jobs := resp.GetGetRunningJobsResponse(); jobs != nil { + rs.mu.Lock() + rs.savedJobs = jobs + rs.mu.Unlock() + fmt.Printf("reload: captured %d running job(s)\n", len(jobs.Jobs)) + } +} + +// serveNewProcess handles a GetRunningJobsRequest from the new Python process, +// replying with the previously captured jobs. +func (rs *reloadServer) serveNewProcess(conn net.Conn) { + req := &agent.AgentDevMessage{} + if err := ipc.ReadProto(conn, req); err != nil { + return + } + if req.GetGetRunningJobsRequest() == nil { + return + } + + rs.mu.Lock() + saved := rs.savedJobs + rs.savedJobs = nil + rs.mu.Unlock() + + if saved == nil { + saved = &agent.GetRunningAgentJobsResponse{} + } + + resp := &agent.AgentDevMessage{ + Message: &agent.AgentDevMessage_GetRunningJobsResponse{ + GetRunningJobsResponse: saved, + }, + } + if err := ipc.WriteProto(conn, resp); err != nil { + fmt.Printf("reload: failed to send restore response: %v\n", err) + } else if len(saved.Jobs) > 0 { + fmt.Printf("reload: restored %d job(s) to new process\n", len(saved.Jobs)) + } +} + +func (rs *reloadServer) close() error { + return rs.listener.Close() +} diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go new file mode 100644 index 00000000..bddb8085 --- /dev/null +++ b/cmd/lk/agent_run.go @@ -0,0 +1,254 @@ +// Copyright 2025 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" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" +) + +func init() { + AgentCommands[0].Commands = append(AgentCommands[0].Commands, startCommand, devCommand) +} + +var agentRunFlags = []cli.Flag{ + &cli.StringFlag{ + Name: "entrypoint", + Usage: "Agent entrypoint `FILE` (default: auto-detect)", + }, + &cli.StringFlag{ + Name: "url", + Usage: "LiveKit server `URL`", + Sources: cli.EnvVars("LIVEKIT_URL"), + }, + &cli.StringFlag{ + Name: "api-key", + Usage: "LiveKit API `KEY`", + Sources: cli.EnvVars("LIVEKIT_API_KEY"), + }, + &cli.StringFlag{ + Name: "api-secret", + Usage: "LiveKit API `SECRET`", + Sources: cli.EnvVars("LIVEKIT_API_SECRET"), + }, + &cli.StringFlag{ + Name: "log-level", + Usage: "Log level (TRACE, DEBUG, INFO, WARN, ERROR)", + }, +} + +var startCommand = &cli.Command{ + Name: "start", + Usage: "Run an agent in production mode", + Flags: agentRunFlags, + Action: runAgentStart, +} + +var devCommand = &cli.Command{ + Name: "dev", + Usage: "Run an agent in development mode with auto-reload", + Flags: append(agentRunFlags, &cli.BoolFlag{ + Name: "no-reload", + Usage: "Disable auto-reload on file changes", + }), + Action: runAgentDev, +} + +// resolveCredentials returns CLI args (--url, --api-key, --api-secret) for the agent subprocess. +func resolveCredentials(cmd *cli.Command, loadOpts ...loadOption) ([]string, error) { + url := cmd.String("url") + apiKey := cmd.String("api-key") + apiSecret := cmd.String("api-secret") + + // Try project config if any are missing + if url == "" || apiKey == "" || apiSecret == "" { + opts := append([]loadOption{ignoreURL}, loadOpts...) + pc, err := loadProjectDetails(cmd, opts...) + if err != nil { + return nil, err + } + if pc != nil { + if url == "" { + url = pc.URL + } + if apiKey == "" { + apiKey = pc.APIKey + } + if apiSecret == "" { + apiSecret = pc.APISecret + } + } + } + + var args []string + if url != "" { + args = append(args, "--url", url) + } + if apiKey != "" { + args = append(args, "--api-key", apiKey) + } + if apiSecret != "" { + args = append(args, "--api-secret", apiSecret) + } + return args, nil +} + +func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error) { + projectDir, projectType, err := agentfs.DetectProjectRoot(".") + if err != nil { + return "", "", "", err + } + if !projectType.IsPython() { + return "", "", "", fmt.Errorf("currently only supports Python agents (detected: %s)", projectType) + } + entrypoint, err := findEntrypoint(projectDir, cmd.String("entrypoint"), projectType) + if err != nil { + return "", "", "", err + } + return projectDir, projectType, entrypoint, nil +} + +func buildCLIArgs(subcmd string, cmd *cli.Command, loadOpts ...loadOption) ([]string, error) { + args := []string{subcmd} + if logLevel := cmd.String("log-level"); logLevel != "" { + args = append(args, "--log-level", logLevel) + } + creds, err := resolveCredentials(cmd, loadOpts...) + if err != nil { + return nil, err + } + args = append(args, creds...) + return args, nil +} + +func runAgentStart(ctx context.Context, cmd *cli.Command) error { + projectDir, projectType, entrypoint, err := detectProject(cmd) + if err != nil { + return err + } + + cliArgs, err := buildCLIArgs("start", cmd, quietOutput) + if err != nil { + return err + } + + agent, err := startAgent(AgentStartConfig{ + Dir: projectDir, + Entrypoint: entrypoint, + ProjectType: projectType, + CLIArgs: cliArgs, + ForwardOutput: os.Stdout, + }) + if err != nil { + return err + } + + // Take over signal handling from the global NotifyContext. + signal.Reset(syscall.SIGINT, syscall.SIGTERM) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Forward every signal to the agent — Python decides + // first = graceful shutdown, second = force exit. + go func() { + for range sigCh { + agent.Shutdown() + } + }() + + // Wait for agent to exit + <-agent.exitCh + signal.Stop(sigCh) + return nil +} + +func runAgentDev(ctx context.Context, cmd *cli.Command) error { + projectDir, projectType, entrypoint, err := detectProject(cmd) + if err != nil { + return err + } + + cliArgs, err := buildCLIArgs("start", cmd, outputToStderr) + if err != nil { + return err + } + if cmd.String("log-level") == "" { + cliArgs = append(cliArgs, "--log-level", "DEBUG") + } + + cfg := AgentStartConfig{ + Dir: projectDir, + Entrypoint: entrypoint, + ProjectType: projectType, + CLIArgs: cliArgs, + ForwardOutput: os.Stdout, + } + + fmt.Fprintf(os.Stderr, "Starting agent in dev mode (%s in %s)...\n", entrypoint, projectDir) + + // Take over signal handling from the global NotifyContext. + signal.Reset(syscall.SIGINT, syscall.SIGTERM) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + if cmd.Bool("no-reload") { + // No reload — just run like start + agent, err := startAgent(cfg) + if err != nil { + return err + } + + go func() { + for range sigCh { + agent.Shutdown() + } + }() + + <-agent.exitCh + signal.Stop(sigCh) + return nil + } + + // Dev mode with file watching + watcher, err := newAgentWatcher(cfg) + if err != nil { + return err + } + + done := make(chan struct{}) + doneOnce := sync.Once{} + + // Forward signals to the current agent, and stop the watcher on first signal. + go func() { + for range sigCh { + doneOnce.Do(func() { close(done) }) + if watcher.agent != nil { + watcher.agent.Shutdown() + } + } + }() + + err = watcher.Run(done) + signal.Stop(sigCh) + return err +} diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go new file mode 100644 index 00000000..69af9a3a --- /dev/null +++ b/cmd/lk/agent_watcher.go @@ -0,0 +1,242 @@ +// Copyright 2021-2024 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 ( + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/livekit/livekit-cli/v2/pkg/agentfs" +) + +// skipDirs are directories to never watch. +var skipDirs = map[string]bool{ + ".git": true, ".hg": true, ".svn": true, + "__pycache__": true, ".mypy_cache": true, ".pytest_cache": true, ".ruff_cache": true, + ".venv": true, "venv": true, "env": true, + "node_modules": true, ".next": true, "dist": true, "build": true, +} + +// watchExtensions returns file extensions to watch for a project type. +func watchExtensions(pt agentfs.ProjectType) map[string]bool { + if pt.IsPython() { + return map[string]bool{".py": true} + } + return map[string]bool{".js": true, ".ts": true, ".mjs": true, ".mts": true} +} + +// agentWatcher watches for file changes and restarts an agent subprocess. +type agentWatcher struct { + config AgentStartConfig + exts map[string]bool + debounce time.Duration + watcher *fsnotify.Watcher + agent *AgentProcess + restartCh chan struct{} + + reloadSrv *reloadServer + conn net.Conn +} + +func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("failed to create file watcher: %w", err) + } + + // Walk directory tree and add all non-skip directories + err = filepath.Walk(config.Dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + return nil + } + name := info.Name() + if skipDirs[name] || (strings.HasPrefix(name, ".") && name != ".") { + return filepath.SkipDir + } + return w.Add(path) + }) + if err != nil { + w.Close() + return nil, fmt.Errorf("failed to setup file watcher: %w", err) + } + + rs, err := newReloadServer() + if err != nil { + w.Close() + return nil, err + } + + // Append --reload-addr to CLI args so the Python process connects back + config.CLIArgs = append(config.CLIArgs, "--reload-addr", rs.addr()) + + return &agentWatcher{ + config: config, + exts: watchExtensions(config.ProjectType), + debounce: 500 * time.Millisecond, + watcher: w, + restartCh: make(chan struct{}, 1), + reloadSrv: rs, + }, nil +} + +func (aw *agentWatcher) start() error { + agent, err := startAgent(aw.config) + if err != nil { + return err + } + aw.agent = agent + + // Accept connection from new Python process in background + go func() { + conn, err := aw.reloadSrv.listener.Accept() + if err != nil { + return + } + aw.conn = conn + // Serve the initial restore request (will be empty on first start) + go aw.reloadSrv.serveNewProcess(conn) + }() + + return nil +} + +func (aw *agentWatcher) restart() error { + // 1. Capture active jobs from the current process (best-effort) + if aw.conn != nil { + aw.reloadSrv.captureJobs(aw.conn) + aw.conn.Close() + aw.conn = nil + } + + // 2. Kill old process + if aw.agent != nil { + aw.agent.Kill() + } + + fmt.Fprintln(os.Stderr, "Reloading agent...") + + // 3. Start new process + agent, err := startAgent(aw.config) + if err != nil { + return err + } + aw.agent = agent + + // 4. Accept new connection and serve restored jobs + go func() { + conn, err := aw.reloadSrv.listener.Accept() + if err != nil { + return + } + aw.conn = conn + go aw.reloadSrv.serveNewProcess(conn) + }() + + return nil +} + +// Run watches for file changes and restarts the agent. Blocks until done is closed. +func (aw *agentWatcher) Run(done <-chan struct{}) error { + if err := aw.start(); err != nil { + return err + } + defer func() { + if aw.agent != nil { + // If Shutdown() was already called by the signal forwarder, + // just wait for exit. Otherwise send SIGINT ourselves. + if !aw.agent.shutdownCalled { + aw.agent.Shutdown() + } + select { + case <-aw.agent.exitCh: + case <-time.After(5 * time.Second): + aw.agent.ForceKill() + } + } + if aw.conn != nil { + aw.conn.Close() + } + aw.reloadSrv.close() + aw.watcher.Close() + }() + + var debounceTimer *time.Timer + var debounceCh <-chan time.Time + + for { + select { + case <-done: + return nil + + case event, ok := <-aw.watcher.Events: + if !ok { + return nil + } + // Only trigger on relevant file extensions + if !aw.exts[filepath.Ext(event.Name)] { + continue + } + // Only care about writes, creates, renames + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + // Add new directories to the watch list + if event.Op&fsnotify.Create != 0 { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + _ = aw.watcher.Add(event.Name) + } + } + // Start or reset debounce timer + if debounceTimer == nil { + debounceTimer = time.NewTimer(aw.debounce) + debounceCh = debounceTimer.C + } else { + debounceTimer.Reset(aw.debounce) + } + + case <-debounceCh: + debounceTimer = nil + debounceCh = nil + if err := aw.restart(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to restart agent: %v\n", err) + fmt.Fprintln(os.Stderr, "Waiting for file changes...") + } + + case err, ok := <-aw.watcher.Errors: + if !ok { + return nil + } + fmt.Fprintf(os.Stderr, "Watcher error: %v\n", err) + + case <-aw.agent.exitCh: + // Agent crashed — wait for file changes to restart + fmt.Fprintln(os.Stderr, "Agent exited. Waiting for file changes to restart...") + // Drain any pending debounce + if debounceTimer != nil { + debounceTimer.Stop() + debounceTimer = nil + debounceCh = nil + } + } + } +} diff --git a/cmd/lk/console.go b/cmd/lk/console.go new file mode 100644 index 00000000..8ae3b4ab --- /dev/null +++ b/cmd/lk/console.go @@ -0,0 +1,291 @@ +//go:build console + +// Copyright 2025 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" + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "strings" + "syscall" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" + "github.com/livekit/livekit-cli/v2/pkg/console" + "github.com/livekit/livekit-cli/v2/pkg/portaudio" +) + +func init() { + AgentCommands[0].Commands = append(AgentCommands[0].Commands, consoleCommand) +} + +var consoleCommand = &cli.Command{ + Name: "console", + Usage: "Voice chat with an agent via mic/speakers", + Category: "Core", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "port", + Aliases: []string{"p"}, + Usage: "TCP port for agent communication", + Value: 0, + }, + &cli.StringFlag{ + Name: "input-device", + Usage: "Input device index or name substring", + }, + &cli.StringFlag{ + Name: "output-device", + Usage: "Output device index or name substring", + }, + &cli.BoolFlag{ + Name: "list-devices", + Usage: "List available audio devices and exit", + }, + &cli.BoolFlag{ + Name: "no-aec", + Usage: "Disable acoustic echo cancellation", + }, + &cli.BoolFlag{ + Name: "text", + Aliases: []string{"t"}, + Usage: "Start in text mode instead of audio mode", + }, + &cli.BoolFlag{ + Name: "record", + Usage: "Record audio and session report to console-recordings/", + }, + &cli.StringFlag{ + Name: "entrypoint", + Usage: "Agent entrypoint `FILE` (default: auto-detect)", + }, + }, + Action: runConsole, +} + +func runConsole(ctx context.Context, cmd *cli.Command) error { + textMode := cmd.Bool("text") + + var inputDev, outputDev *portaudio.DeviceInfo + if !textMode { + if err := portaudio.Initialize(); err != nil { + return fmt.Errorf("failed to initialize PortAudio: %w", err) + } + defer portaudio.Terminate() + + if cmd.Bool("list-devices") { + return listDevices() + } + + var err error + if q := cmd.String("input-device"); q != "" { + inputDev, err = portaudio.FindDevice(q, true) + } else { + inputDev, err = portaudio.DefaultInputDevice() + } + if err != nil { + return fmt.Errorf("input device: %w", err) + } + + if q := cmd.String("output-device"); q != "" { + outputDev, err = portaudio.FindDevice(q, false) + } else { + outputDev, err = portaudio.DefaultOutputDevice() + } + if err != nil { + return fmt.Errorf("output device: %w", err) + } + } + + port := cmd.Int("port") + addr := fmt.Sprintf("127.0.0.1:%d", port) + var err error + server, err := console.NewTCPServer(addr) + if err != nil { + return err + } + defer server.Close() + + actualAddr := server.Addr().String() + if inputDev != nil { + fmt.Fprintf(os.Stderr, "Input: %s\n", inputDev.Name) + fmt.Fprintf(os.Stderr, "Output: %s\n", outputDev.Name) + } + + // Detect project type, walking up parent directories if needed. + projectDir, projectType, err := agentfs.DetectProjectRoot(".") + if err != nil { + return err + } + if !projectType.IsPython() { + return fmt.Errorf("console currently only supports Python agents (detected: %s)", projectType) + } + + // Resolve entrypoint relative to project root + entrypoint, err := findEntrypoint(projectDir, cmd.String("entrypoint"), projectType) + if err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "Starting agent (%s in %s)...\n", entrypoint, projectDir) + agentProc, err := startAgent(AgentStartConfig{ + Dir: projectDir, + Entrypoint: entrypoint, + ProjectType: projectType, + CLIArgs: buildConsoleArgs(actualAddr, cmd.Bool("record")), + }) + if err != nil { + return fmt.Errorf("failed to start agent: %w", err) + } + defer agentProc.Kill() + + // Stream agent logs to the TUI + agentProc.LogStream = make(chan string, 128) + + // Wait for TCP connection, agent crash, timeout, or cancellation + type acceptResult struct { + conn net.Conn + err error + } + acceptCh := make(chan acceptResult, 1) + go func() { + conn, err := server.Accept() + acceptCh <- acceptResult{conn, err} + }() + + var conn net.Conn + select { + case res := <-acceptCh: + if res.err != nil { + return fmt.Errorf("agent connection: %w", res.err) + } + conn = res.conn + case err := <-agentProc.Done(): + logs := agentProc.RecentLogs(20) + for _, l := range logs { + fmt.Fprintln(os.Stderr, l) + } + if err != nil { + return fmt.Errorf("agent exited before connecting: %w", err) + } + return fmt.Errorf("agent exited before connecting") + case <-time.After(60 * time.Second): + logs := agentProc.RecentLogs(20) + for _, l := range logs { + fmt.Fprintln(os.Stderr, l) + } + return fmt.Errorf("timed out waiting for agent to connect") + case <-ctx.Done(): + return ctx.Err() + } + pipeline, err := console.NewPipeline(console.PipelineConfig{ + InputDevice: inputDev, + OutputDevice: outputDev, + NoAEC: cmd.Bool("no-aec"), + Conn: conn, + }) + if err != nil { + return fmt.Errorf("pipeline: %w", err) + } + + pipelineCtx, pipelineCancel := context.WithCancel(ctx) + defer pipelineCancel() + + go func() { + pipeline.Start(pipelineCtx) + }() + + // Redirect Go's default logger to discard so it doesn't corrupt the TUI + log.SetOutput(io.Discard) + + // Remove the global SIGINT handler (from signal.NotifyContext in main.go) + // so that ctrl+C in raw mode reaches Bubble Tea as a key event, and after + // the TUI exits, a ctrl+C during cleanup uses the default handler (terminate). + signal.Reset(syscall.SIGINT) + + var inputDevName, outputDevName string + if inputDev != nil { + inputDevName = inputDev.Name + } + if outputDev != nil { + outputDevName = outputDev.Name + } + model := newConsoleModel(pipeline, pipelineCancel, agentProc, inputDevName, outputDevName, textMode) + p := tea.NewProgram(model, tea.WithoutSignalHandler()) + + if _, err := p.Run(); err != nil { + return err + } + + return nil +} + +func buildConsoleArgs(addr string, record bool) []string { + args := []string{"console", "--connect-addr", addr} + if record { + args = append(args, "--record") + } + return args +} + +func listDevices() error { + devices, err := portaudio.ListDevices() + if err != nil { + return err + } + + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) + defaultStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + + fmt.Println(headerStyle.Render(fmt.Sprintf(" %-4s %-8s %-45s %s", "#", "Type", "Name", "Default"))) + fmt.Println(strings.Repeat("─", 70)) + + for _, d := range devices { + devType := "" + if d.MaxInputChannels > 0 && d.MaxOutputChannels > 0 { + devType = "Both" + } else if d.MaxInputChannels > 0 { + devType = "Input" + } else { + devType = "Output" + } + + defStr := "" + if d.IsDefaultInput { + defStr += defaultStyle.Render("✓ input") + } + if d.IsDefaultOutput { + if defStr != "" { + defStr += " " + } + defStr += defaultStyle.Render("✓ output") + } + + fmt.Printf(" %-4d %-8s %-45s %s\n", d.Index, devType, d.Name, defStr) + } + + return nil +} + diff --git a/cmd/lk/console_stub.go b/cmd/lk/console_stub.go new file mode 100644 index 00000000..917095cb --- /dev/null +++ b/cmd/lk/console_stub.go @@ -0,0 +1,46 @@ +//go:build !console + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/urfave/cli/v3" +) + +func init() { + AgentCommands[0].Commands = append(AgentCommands[0].Commands, &cli.Command{ + Name: "console", + Usage: "Voice chat with an agent via mic/speakers", + Action: func(ctx context.Context, cmd *cli.Command) error { + msg := "console is not included in this build.\n\n" + if isHomebrewInstall() { + msg += "\"brew install livekit-cli\" does not include console support.\n" + + "Install with console support:\n" + + " brew tap livekit/livekit && brew install lk\n" + } else { + msg += "Install with console support:\n" + + " https://docs.livekit.io/intro/basics/cli/start/\n" + } + msg += "\nOr build from source:\n" + + " go build -tags console ./cmd/lk" + return fmt.Errorf("%s", msg) + }, + }) +} + +func isHomebrewInstall() bool { + exe, err := os.Executable() + if err != nil { + return false + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + return false + } + return strings.Contains(resolved, "/Cellar/") +} diff --git a/cmd/lk/console_tui.go b/cmd/lk/console_tui.go new file mode 100644 index 00000000..e4b543b8 --- /dev/null +++ b/cmd/lk/console_tui.go @@ -0,0 +1,663 @@ +//go:build console + +// Copyright 2025 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" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + agent "github.com/livekit/protocol/livekit/agent" + + "github.com/livekit/livekit-cli/v2/pkg/console" +) + +// 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) +) + +// Unicode block characters for frequency visualizer (matching Python console) +var blocks = []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"} + +// Braille spinner frames (matching Rich's "dots" spinner) +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type consoleTickMsg struct{} +type sessionEventMsg struct{ event *agent.AgentSessionEvent } +type sessionResponseMsg struct{ resp *agent.SessionResponse } +type audioInitResultMsg struct{ err error } +type agentLogMsg struct{ line string } +type agentExitedMsg struct{} +type shutdownTimeoutMsg struct{} + +type consoleModel struct { + pipeline *console.AudioPipeline + pipelineCancel context.CancelFunc + agentProc *AgentProcess + inputDev string + outputDev string + + width int + + // Partial user transcription (not yet final) + partialTranscript string + + // Text mode + textMode bool + textInput textinput.Model + + // Shortcut help toggle (? key) + showShortcuts bool + + // Audio init error (shown when switching from text to audio fails) + audioError string + + // Last turn metrics text (cleared on next thinking state) + metricsText string + + // Request counter for unique IDs + reqCounter int + + // Waiting for agent response (text mode loading indicator) + waitingForAgent bool + + // Shutdown state + shuttingDown bool +} + +func newConsoleModel(pipeline *console.AudioPipeline, pipelineCancel context.CancelFunc, agentProc *AgentProcess, inputDev, outputDev string, textMode bool) consoleModel { + ti := textinput.New() + ti.Placeholder = "Type to talk to your agent" + ti.CharLimit = 1000 + ti.Width = 60 + ti.Prompt = "❯ " + ti.PromptStyle = boldStyle + + if textMode { + ti.Focus() + } + + return consoleModel{ + pipeline: pipeline, + pipelineCancel: pipelineCancel, + agentProc: agentProc, + inputDev: inputDev, + outputDev: outputDev, + textInput: ti, + textMode: textMode, + } +} + +func (m consoleModel) Init() tea.Cmd { + cmds := []tea.Cmd{ + consoleTickCmd(), + pollEventsCmd(m.pipeline), + pollResponsesCmd(m.pipeline), + } + if m.agentProc != nil && m.agentProc.LogStream != nil { + cmds = append(cmds, pollLogsCmd(m.agentProc.LogStream)) + } + if m.textMode { + cmds = append(cmds, textinput.Blink) + } + return tea.Batch(cmds...) +} + +func consoleTickCmd() tea.Cmd { + return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg { + return consoleTickMsg{} + }) +} + +func pollEventsCmd(pipeline *console.AudioPipeline) tea.Cmd { + return func() tea.Msg { + ev, ok := <-pipeline.Events + if !ok { + return nil + } + return sessionEventMsg{event: ev} + } +} + +func pollResponsesCmd(pipeline *console.AudioPipeline) tea.Cmd { + return func() tea.Msg { + resp, ok := <-pipeline.Responses + if !ok { + return nil + } + return sessionResponseMsg{resp: resp} + } +} + +func pollLogsCmd(ch chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return nil + } + return agentLogMsg{line: line} + } +} + +func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if m.shuttingDown { + if msg.String() == "ctrl+c" { + m.agentProc.ForceKill() + m.pipelineCancel() + go m.pipeline.Stop() + return m, tea.Quit + } + return m, nil + } + if m.textMode { + return m.updateTextMode(msg) + } + switch msg.String() { + case "q", "ctrl+c": + return m, m.beginShutdown() + case "m": + m.pipeline.SetMuted(!m.pipeline.Muted()) + case "ctrl+t": + m.textMode = true + m.showShortcuts = false + m.textInput.Focus() + return m, textinput.Blink + case "?": + m.showShortcuts = !m.showShortcuts + case "esc": + m.showShortcuts = false + } + + case tea.WindowSizeMsg: + m.width = msg.Width + + case consoleTickMsg: + if m.shuttingDown { + return m, nil + } + return m, consoleTickCmd() + + case sessionEventMsg: + if m.shuttingDown { + return m, nil + } + cmds := m.handleSessionEvent(msg.event) + cmds = append(cmds, pollEventsCmd(m.pipeline)) + return m, tea.Batch(cmds...) + + case sessionResponseMsg: + if m.waitingForAgent { + m.waitingForAgent = false + if m.textMode { + m.textInput.Focus() + } + } + return m, pollResponsesCmd(m.pipeline) + + case audioInitResultMsg: + if msg.err != nil { + m.audioError = msg.err.Error() + } else { + m.textMode = false + m.showShortcuts = false + m.textInput.Blur() + m.audioError = "" + m.inputDev = "Default Input" + m.outputDev = "Default Output" + } + return m, nil + + case agentLogMsg: + cmd := tea.Println(dimStyle.Render(msg.line)) + var nextCmd tea.Cmd + if m.agentProc != nil && m.agentProc.LogStream != nil { + nextCmd = pollLogsCmd(m.agentProc.LogStream) + } + return m, tea.Batch(cmd, nextCmd) + + case agentExitedMsg: + return m, tea.Quit + + case shutdownTimeoutMsg: + m.agentProc.ForceKill() + m.pipelineCancel() + go m.pipeline.Stop() + return m, tea.Quit + } + + return m, nil +} + +func (m *consoleModel) switchToAudio() tea.Cmd { + if m.pipeline.HasAudio() { + m.textMode = false + m.showShortcuts = false + m.textInput.Blur() + m.audioError = "" + return nil + } + // Lazy init audio in a goroutine + return func() tea.Msg { + return audioInitResultMsg{err: m.pipeline.EnableAudio()} + } +} + +func (m *consoleModel) beginShutdown() tea.Cmd { + m.shuttingDown = true + m.textMode = false + m.showShortcuts = false + + // Close the audio pipeline/TCP connection first so the agent's audio + // input ends and STT stops receiving data. Then send SIGINT so the + // agent's session.aclose() runs with nothing left to drain. + m.pipelineCancel() + go m.pipeline.Stop() + + m.agentProc.Shutdown() + + // Wait for agent exit or timeout. + return tea.Batch( + func() tea.Msg { + <-m.agentProc.Done() + return agentExitedMsg{} + }, + tea.Tick(5*time.Second, func(time.Time) tea.Msg { + return shutdownTimeoutMsg{} + }), + ) +} + +func (m *consoleModel) updateTextMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + return m, m.beginShutdown() + case "ctrl+t": + return m, m.switchToAudio() + case "esc": + if m.showShortcuts { + m.showShortcuts = false + return m, nil + } + return m, m.switchToAudio() + case "?": + if m.textInput.Value() == "" { + m.showShortcuts = !m.showShortcuts + return m, nil + } + case "enter": + if m.waitingForAgent { + return m, nil + } + text := strings.TrimSpace(m.textInput.Value()) + if text != "" { + m.reqCounter++ + reqID := fmt.Sprintf("console-%d", m.reqCounter) + m.textInput.SetValue("") + m.waitingForAgent = true + + // Print user message matching the old console format: + // ● You + // text here + printCmd := tea.Println( + "\n " + lipgloss.NewStyle().Foreground(lkCyan).Render("● ") + + cyanBoldStyle.Render("You") + + "\n " + text, + ) + + req := &agent.SessionRequest{ + RequestId: reqID, + Request: &agent.SessionRequest_RunInput_{ + RunInput: &agent.SessionRequest_RunInput{Text: text}, + }, + } + go m.pipeline.SendRequest(req) + return m, tea.Batch(printCmd, consoleTickCmd()) + } + return m, nil + } + + m.audioError = "" // clear on any key press + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd { + if ev == nil { + return nil + } + var cmds []tea.Cmd + + switch e := ev.Event.(type) { + case *agent.AgentSessionEvent_AgentStateChanged_: + if e.AgentStateChanged.NewState == agent.AgentState_AS_THINKING { + m.metricsText = "" + } + + case *agent.AgentSessionEvent_UserInputTranscribed_: + if e.UserInputTranscribed.IsFinal { + m.partialTranscript = "" + if text := e.UserInputTranscribed.Transcript; text != "" { + cmds = append(cmds, tea.Println( + "\n "+lipgloss.NewStyle().Foreground(lkCyan).Render("● ")+ + cyanBoldStyle.Render("You")+ + "\n "+text, + )) + } + } else { + m.partialTranscript = e.UserInputTranscribed.Transcript + } + + case *agent.AgentSessionEvent_ConversationItemAdded_: + if item := e.ConversationItemAdded.Item; item != nil { + // Extract metrics from ChatMessage (matching Python console pattern) + if msg := item.GetMessage(); msg != nil { + if text := formatMetrics(msg.Metrics); text != "" { + m.metricsText = text + } + } + lines := formatChatItem(item) + for _, line := range lines { + cmds = append(cmds, tea.Println(line)) + } + } + + case *agent.AgentSessionEvent_Error_: + cmds = append(cmds, tea.Println( + " "+redBoldStyle.Render("✗ ")+redStyle.Render(e.Error.Message), + )) + } + + return cmds +} + +// formatChatItem returns lines to print for a conversation item, +// matching the old Python console format. +func formatChatItem(item *agent.ChatContext_ChatItem) []string { + switch i := item.Item.(type) { + case *agent.ChatContext_ChatItem_Message: + msg := i.Message + // User messages are printed from UserInputTranscribed (final) to avoid + // ordering issues with partial transcripts. + if msg.Role == agent.ChatRole_USER { + return nil + } + var textParts []string + for _, c := range msg.Content { + if t := c.GetText(); t != "" { + textParts = append(textParts, t) + } + } + text := strings.Join(textParts, "") + if text == "" { + return nil + } + + var lines []string + lines = append(lines, + "\n "+lipgloss.NewStyle().Foreground(lkGreen).Render("● ")+ + greenBoldStyle.Render("Agent"), + ) + for _, tl := range strings.Split(text, "\n") { + lines = append(lines, " "+tl) + } + return lines + + case *agent.ChatContext_ChatItem_FunctionCall: + return []string{ + " " + lipgloss.NewStyle().Foreground(lkCyan).Render("➜ ") + + cyanBoldStyle.Render(i.FunctionCall.Name), + } + + case *agent.ChatContext_ChatItem_FunctionCallOutput: + if i.FunctionCallOutput.IsError { + return []string{ + " " + redBoldStyle.Render("✗ ") + redStyle.Render(truncateOutput(i.FunctionCallOutput.Output)), + } + } + return []string{ + " " + greenStyle.Render("✓ ") + dimStyle.Render(summarizeOutput(i.FunctionCallOutput.Output)), + } + } + return nil +} + +// ────────────────────────────────────────────────────────────────── +// View — compact status area at the bottom (not fullscreen). +// Logs and conversation scroll up via tea.Println. +// Layout matches the old Python console (FrequencyVisualizer + prompt). +// ────────────────────────────────────────────────────────────────── + +func (m consoleModel) View() string { + var b strings.Builder + + if m.shuttingDown { + b.WriteString("\n ") + b.WriteString(labelStyle.Render("Shutting down agent...")) + b.WriteString(" ") + b.WriteString(dimStyle.Render("ctrl+C to force")) + b.WriteString("\n") + return b.String() + } + + if m.textMode { + if m.waitingForAgent { + // Braille spinner (matching Rich's "dots" spinner) + frame := spinnerFrames[int(time.Now().UnixMilli()/80)%len(spinnerFrames)] + b.WriteString(" " + dimStyle.Render(frame+" thinking")) + } else { + // ── Text input ── + w := m.width + if w <= 0 { + w = 80 + } + sep := dimStyle.Render(strings.Repeat("─", min(w, 80))) + b.WriteString(sep) + b.WriteString("\n") + b.WriteString(m.textInput.View()) + b.WriteString("\n") + b.WriteString(sep) + } + + if m.audioError != "" { + b.WriteString("\n") + b.WriteString(" " + redStyle.Render("audio: "+m.audioError)) + } + + if m.showShortcuts { + b.WriteString("\n") + m.writeShortcutsInline(&b, []shortcut{ + {"Ctrl+T", "audio mode"}, + {"Ctrl+C", "exit"}, + }) + } else { + b.WriteString("\n") + b.WriteString(dimStyle.Render(" ? for shortcuts")) + } + } else { + // ── Audio visualizer (matching old Python FrequencyVisualizer) ── + b.WriteString(" ") + b.WriteString(labelStyle.Render(m.inputDev)) + b.WriteString(" ") + bands := m.pipeline.FFTBands() + for _, band := range bands { + idx := int(band * float64(len(blocks)-1)) + if idx >= len(blocks) { + idx = len(blocks) - 1 + } + if idx < 0 { + idx = 0 + } + b.WriteString(" ") + b.WriteString(blocks[idx]) + } + + if m.pipeline.Muted() { + b.WriteString(" ") + b.WriteString(redBoldStyle.Render("MUTED")) + } + + // Partial transcription on same line (dim) + if m.partialTranscript != "" { + b.WriteString(" ") + b.WriteString(dimStyle.Render("● " + m.partialTranscript + "...")) + } + + // ERLE > 6dB means the AEC is actively cancelling echo — show as a + // reassuring status indicator, not a warning. + if m.pipeline.IsPlaying() { + if stats := m.pipeline.AECStats(); stats != nil && stats.HasERLE && stats.EchoReturnLossEnhancement > 2 { + b.WriteString(" ") + b.WriteString(dimStyle.Render("echo cancelling")) + } + } + + // Metrics on same line (right side) + if m.metricsText != "" { + b.WriteString(" ") + b.WriteString(m.metricsText) + } + + if m.showShortcuts { + b.WriteString("\n") + m.writeShortcutsInline(&b, []shortcut{ + {"m", "mute/unmute"}, + {"Ctrl+T", "text mode"}, + {"q", "quit"}, + }) + } else { + b.WriteString("\n") + b.WriteString(dimStyle.Render(" ? for shortcuts")) + } + } + + return b.String() +} + +type shortcut struct { + key string + desc string +} + +func (m consoleModel) writeShortcutsInline(b *strings.Builder, shortcuts []shortcut) { + dimBoldStyle := lipgloss.NewStyle().Faint(true).Bold(true) + b.WriteString(" ") + for i, s := range shortcuts { + if i > 0 { + b.WriteString(dimStyle.Render(" · ")) + } + b.WriteString(dimBoldStyle.Render(s.key)) + b.WriteString(" ") + b.WriteString(dimStyle.Render(s.desc)) + } +} + +// formatMetrics formats a MetricsReport matching the Python console display. +func formatMetrics(m *agent.MetricsReport) string { + if m == nil { + return "" + } + + var parts []string + sep := dimStyle.Render(" · ") + + if m.LlmNodeTtft != nil { + parts = append(parts, dimStyle.Render("llm_ttft ")+dimStyle.Render(formatMs(*m.LlmNodeTtft))) + } + if m.TtsNodeTtfb != nil { + parts = append(parts, dimStyle.Render("tts_ttfb ")+dimStyle.Render(formatMs(*m.TtsNodeTtfb))) + } + if m.E2ELatency != nil { + label := "e2e " + formatMs(*m.E2ELatency) + if *m.E2ELatency >= 1.0 { + parts = append(parts, redStyle.Render(label)) + } else { + parts = append(parts, dimStyle.Render(label)) + } + } + + if len(parts) == 0 { + return "" + } + return strings.Join(parts, sep) +} + +func formatMs(seconds float64) string { + ms := seconds * 1000 + if ms >= 100 { + return fmt.Sprintf("%.0fms", ms) + } + return fmt.Sprintf("%.1fms", ms) +} + +// summarizeOutput tries to parse JSON and produce a "key=value, key=value" summary +// matching the old Python console behavior. Falls back to truncation. +func summarizeOutput(output string) string { + jsonStart := strings.Index(output, "{") + if jsonStart < 0 { + return truncateOutput(output) + } + + var data map[string]any + if err := json.Unmarshal([]byte(output[jsonStart:]), &data); err != nil { + return truncateOutput(output) + } + + var parts []string + for k, v := range data { + if v == nil || k == "type" { + continue + } + parts = append(parts, fmt.Sprintf("%s=%v", k, v)) + if len(parts) >= 3 { + break + } + } + result := strings.Join(parts, ", ") + if len(data) > 3 { + result += ", ..." + } + if result == "" { + return truncateOutput(output) + } + return result +} + +func truncateOutput(output string) string { + if len(output) > 200 { + return output[:197] + "..." + } + return output +} diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go new file mode 100644 index 00000000..18ec2d1a --- /dev/null +++ b/cmd/lk/simulate.go @@ -0,0 +1,221 @@ +// Copyright 2025 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" + "encoding/json" + "fmt" + "math/rand" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/urfave/cli/v3" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" + "github.com/livekit/livekit-cli/v2/pkg/config" + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" +) + +var ( + simulateProjectConfig *config.ProjectConfig +) + +var simulateCommand = &cli.Command{ + Name: "simulate", + Usage: "Run agent simulations against LiveKit Cloud", + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + pc, err := loadProjectDetails(cmd) + if err != nil { + return nil, err + } + simulateProjectConfig = pc + return nil, nil + }, + Action: runSimulate, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "num-simulations", + Aliases: []string{"n"}, + Usage: "Number of scenarios to generate", + Value: 5, + }, + &cli.StringFlag{ + Name: "description", + Usage: "Agent description for scenario generation", + }, + &cli.StringFlag{ + Name: "scenario-group-id", + Usage: "Use a pre-configured scenario group", + }, + &cli.StringFlag{ + Name: "config", + Usage: "Path to simulation config `FILE`", + }, + &cli.StringFlag{ + Name: "entrypoint", + Usage: "Agent entrypoint `FILE` (default: agent.py)", + }, + }, +} + +// simulationConfig represents the simulation.json config file. +type simulationConfig struct { + AgentDescription string `json:"agent_description"` + Scenarios []scenarioConfig `json:"scenarios"` +} + +type scenarioConfig struct { + Label string `json:"label"` + Instructions string `json:"instructions"` + AgentExpectations string `json:"agent_expectations"` + Metadata map[string]string `json:"metadata"` +} + +func loadSimulationConfig(path string) (*simulationConfig, error) { + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config: %w", err) + } + var cfg simulationConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + return &cfg, nil +} + +func generateAgentName() string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 8) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return "simulation-" + string(b) +} + +// simulateMode represents how scenarios are sourced. +type simulateMode int + +const ( + modeInlineScenarios simulateMode = iota + modeScenarioGroup + modeGenerateFromDescription + modeGenerateFromSource +) + +func runSimulate(ctx context.Context, cmd *cli.Command) error { + pc := simulateProjectConfig + + configPath := cmd.String("config") + cfg, err := loadSimulationConfig(configPath) + if err != nil { + return err + } + + description := cmd.String("description") + if description == "" && cfg != nil { + description = cfg.AgentDescription + } + + numSimulations := int32(cmd.Int("num-simulations")) + scenarioGroupID := cmd.String("scenario-group-id") + agentName := generateAgentName() + + // Mode detection (checked in priority order) + var mode simulateMode + switch { + case cfg != nil && len(cfg.Scenarios) > 0: + mode = modeInlineScenarios + case scenarioGroupID != "": + mode = modeScenarioGroup + case description != "": + mode = modeGenerateFromDescription + default: + mode = modeGenerateFromSource + } + + // Detect project type, walking up parent directories if needed + projectDir, projectType, err := agentfs.DetectProjectRoot(".") + if err != nil { + return err + } + if !projectType.IsPython() { + return fmt.Errorf("simulate currently only supports Python agents (detected: %s)", projectType) + } + + // Resolve entrypoint + entrypoint, err := findEntrypoint(projectDir, cmd.String("entrypoint"), projectType) + if err != nil { + return err + } + + simClient := lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret) + + m := newSimulateModel(&simulateConfig{ + ctx: ctx, + client: simClient, + pc: pc, + numSimulations: numSimulations, + mode: mode, + description: description, + agentName: agentName, + projectDir: projectDir, + projectType: projectType, + entrypoint: entrypoint, + cfg: cfg, + scenarioGroupID: scenarioGroupID, + }) + + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("TUI error: %w", err) + } + + if m.agent != nil { + m.agent.Kill() + if m.agent.LogPath != "" { + fmt.Fprintf(os.Stderr, "Agent logs: %s\n", m.agent.LogPath) + } + } + + if url := m.getDashboardURL(); url != "" { + fmt.Fprintf(os.Stderr, "Dashboard: %s\n", url) + } + + // Cancel the run — server will no-op if already terminal + if m.runID != "" && !m.runFinished { + cancelCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + if _, err := simClient.CancelSimulationRun(cancelCtx, &livekit.SimulationRun_Cancel_Request{ + SimulationRunId: m.runID, + }); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cancel run: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "Run cancelled\n") + } + } + + if m.err != nil && m.err != context.Canceled { + return m.err + } + return nil +} + + diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go new file mode 100644 index 00000000..792ec79c --- /dev/null +++ b/cmd/lk/simulate_subprocess.go @@ -0,0 +1,315 @@ +// Copyright 2025 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 ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" +) + +// AgentProcess manages a Python agent subprocess. +type AgentProcess struct { + cmd *exec.Cmd + readyCh chan struct{} + doneCh chan error + exitCh chan struct{} // closed when process exits, safe to read multiple times + shutdownCalled bool // true after Shutdown() sends SIGINT + + // LogStream receives log lines in real-time. Nil if not needed. + LogStream chan string + + mu sync.Mutex + logLines []string + maxLogs int + logFile *os.File + LogPath string +} + +// findPythonBinary locates a Python binary for the given project type. +func findPythonBinary(dir string, projectType agentfs.ProjectType) (string, []string, error) { + if projectType == agentfs.ProjectTypePythonUV { + uvPath, err := exec.LookPath("uv") + if err == nil { + return uvPath, []string{"run", "python"}, nil + } + } + + // Check common venv locations + for _, venvDir := range []string{".venv", "venv"} { + candidate := filepath.Join(dir, venvDir, "bin", "python") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil, nil + } + } + + // Fall back to system python + pythonPath, err := exec.LookPath("python3") + if err != nil { + pythonPath, err = exec.LookPath("python") + if err != nil { + return "", nil, fmt.Errorf("could not find Python binary; ensure a virtual environment exists or Python is on PATH") + } + } + return pythonPath, nil, nil +} + +// findEntrypoint resolves the agent entrypoint file. +func findEntrypoint(dir, explicit string, projectType agentfs.ProjectType) (string, error) { + if explicit != "" { + path := explicit + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("entrypoint not found: %s", explicit) + } + return explicit, nil + } + def := projectType.DefaultEntrypoint() + if def == "" { + def = "agent.py" + } + + // Check project root first + if _, err := os.Stat(filepath.Join(dir, def)); err == nil { + return def, nil + } + + // Fall back to cwd-relative path (e.g. running from examples/drive-thru/) + cwd, _ := os.Getwd() + if rel, err := filepath.Rel(dir, cwd); err == nil && rel != "." { + candidate := filepath.Join(rel, def) + if _, err := os.Stat(filepath.Join(dir, candidate)); err == nil { + return candidate, nil + } + } + + return "", fmt.Errorf("entrypoint not found: %s (use --entrypoint to specify)", def) +} + +// AgentStartConfig configures how to launch an agent subprocess. +type AgentStartConfig struct { + Dir string + Entrypoint string + ProjectType agentfs.ProjectType + CLIArgs []string // e.g. ["start", "--url", "..."] or ["console", "--connect-addr", addr] + Env []string // e.g. ["LIVEKIT_AGENT_NAME=x"] or nil + ReadySignal string // substring to scan for in output (e.g. "registered worker"), empty to skip + ForwardOutput io.Writer // if set, forward each output line to this writer +} + +// startAgent launches a Python agent subprocess and monitors its output. +func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { + pythonBin, prefixArgs, err := findPythonBinary(cfg.Dir, cfg.ProjectType) + if err != nil { + return nil, err + } + + args := append(prefixArgs, cfg.Entrypoint) + args = append(args, cfg.CLIArgs...) + cmd := exec.Command(pythonBin, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Dir = cfg.Dir + if len(cfg.Env) > 0 { + cmd.Env = append(os.Environ(), cfg.Env...) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stderr pipe: %w", err) + } + + logFile, err := os.CreateTemp("", "lk-simulate-*.log") + if err != nil { + return nil, fmt.Errorf("failed to create log file: %w", err) + } + + ap := &AgentProcess{ + cmd: cmd, + readyCh: make(chan struct{}), + doneCh: make(chan error, 1), + exitCh: make(chan struct{}), + maxLogs: 200, + logFile: logFile, + LogPath: logFile.Name(), + } + + if err := cmd.Start(); err != nil { + logFile.Close() + os.Remove(logFile.Name()) + return nil, fmt.Errorf("failed to start agent: %w", err) + } + + // Capture output from both stdout and stderr + readyOnce := sync.Once{} + scanOutput := func(r io.Reader) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + ap.appendLog(line) + if cfg.ForwardOutput != nil { + fmt.Fprintln(cfg.ForwardOutput, line) + } + if cfg.ReadySignal != "" && strings.Contains(line, cfg.ReadySignal) { + readyOnce.Do(func() { close(ap.readyCh) }) + } + } + } + + // If no ready signal, mark ready immediately + if cfg.ReadySignal == "" { + close(ap.readyCh) + } + + var scanWg sync.WaitGroup + scanWg.Add(2) + go func() { defer scanWg.Done(); scanOutput(stdout) }() + go func() { defer scanWg.Done(); scanOutput(stderr) }() + go func() { + ap.doneCh <- cmd.Wait() + close(ap.exitCh) + scanWg.Wait() + if ap.LogStream != nil { + close(ap.LogStream) + } + }() + + return ap, nil +} + +func (ap *AgentProcess) appendLog(line string) { + ap.mu.Lock() + defer ap.mu.Unlock() + ap.logLines = append(ap.logLines, line) + if len(ap.logLines) > ap.maxLogs { + ap.logLines = ap.logLines[len(ap.logLines)-ap.maxLogs:] + } + if ap.logFile != nil { + fmt.Fprintln(ap.logFile, line) + } + if ap.LogStream != nil { + select { + case ap.LogStream <- line: + default: + } + } +} + +// Ready returns a channel that is closed when the agent worker has registered. +func (ap *AgentProcess) Ready() <-chan struct{} { + return ap.readyCh +} + +// Done returns a channel that receives the process exit error. +func (ap *AgentProcess) Done() <-chan error { + return ap.doneCh +} + +// RecentLogs returns the last n log lines from the subprocess. +func (ap *AgentProcess) RecentLogs(n int) []string { + ap.mu.Lock() + defer ap.mu.Unlock() + if n >= len(ap.logLines) { + result := make([]string, len(ap.logLines)) + copy(result, ap.logLines) + return result + } + result := make([]string, n) + copy(result, ap.logLines[len(ap.logLines)-n:]) + return result +} + +// LogCount returns the total number of log lines captured. +func (ap *AgentProcess) LogCount() int { + ap.mu.Lock() + defer ap.mu.Unlock() + return len(ap.logLines) +} + +// Kill sends SIGINT to the process group and SIGKILL after a timeout. +// If Shutdown() was already called, it just waits for exit (no duplicate SIGINT). +func (ap *AgentProcess) Kill() { + if ap.cmd.Process == nil { + return + } + // Already exited — nothing to do. + select { + case <-ap.exitCh: + ap.closeLogFile() + return + default: + } + if !ap.shutdownCalled { + ap.signalGroup(syscall.SIGINT) + } + select { + case <-ap.exitCh: + case <-time.After(5 * time.Second): + ap.signalGroup(syscall.SIGKILL) + } + ap.closeLogFile() +} + +func (ap *AgentProcess) closeLogFile() { + ap.mu.Lock() + defer ap.mu.Unlock() + if ap.logFile != nil { + ap.logFile.Close() + ap.logFile = nil + } +} + +// Shutdown sends SIGINT to the main process to initiate graceful shutdown. +// Only signals the main process (not the group) so that Python manages +// its own child process cleanup without stray signal bouncing. +func (ap *AgentProcess) Shutdown() { + if ap.cmd.Process == nil { + return + } + ap.shutdownCalled = true + ap.cmd.Process.Signal(syscall.SIGINT) +} + +// ForceKill sends SIGKILL to the process group immediately. +func (ap *AgentProcess) ForceKill() { + if ap.cmd.Process == nil { + return + } + ap.signalGroup(syscall.SIGKILL) +} + +// signalGroup sends a signal to the entire process group (Setpgid must be true). +func (ap *AgentProcess) signalGroup(sig syscall.Signal) { + if ap.cmd.Process == nil { + return + } + // Negative PID signals the entire process group. + _ = syscall.Kill(-ap.cmd.Process.Pid, sig) +} diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go new file mode 100644 index 00000000..5d9ba2ef --- /dev/null +++ b/cmd/lk/simulate_tui.go @@ -0,0 +1,1265 @@ +// Copyright 2025 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 ( + "bytes" + "context" + "fmt" + "math/rand" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/livekit/livekit-cli/v2/pkg/agentfs" + "github.com/livekit/livekit-cli/v2/pkg/config" + "github.com/livekit/server-sdk-go/v2/pkg/cloudagents" + agent "github.com/livekit/protocol/livekit/agent" + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" +) + +// --- Styles --- + +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("3")) + dimStyle = lipgloss.NewStyle().Faint(true) + boldStyle = lipgloss.NewStyle().Bold(true) + reverseStyle = lipgloss.NewStyle().Reverse(true) + cyanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) +) + +// --- Message types --- + +type simulationRunMsg struct { + run *livekit.SimulationRun + err error +} + +type pollTickMsg struct{} +type spinnerTickMsg struct{} +type glowTickMsg struct{} + +type subprocessExitMsg struct { + err error +} + +// --- Filter --- + +const ( + filterAll = iota + filterFailed + filterPassed + filterRunning +) + +var filterNames = []string{"All", "Failed", "Passed", "Running"} + +// --- Model --- + +type step struct { + label string + status string // "pending", "running", "done", "failed" + elapsed time.Duration +} + +type simulateConfig struct { + ctx context.Context + client *lksdk.AgentSimulationClient + pc *config.ProjectConfig + numSimulations int32 + mode simulateMode + description string + agentName string + projectDir string + projectType agentfs.ProjectType + entrypoint string + cfg *simulationConfig + scenarioGroupID string +} + +type simulateModel struct { + config *simulateConfig + client *lksdk.AgentSimulationClient + runID string + agent *AgentProcess + setupCancel context.CancelFunc + + // Setup phase + steps []step + setupDone bool + + // Run phase + run *livekit.SimulationRun + runFinished bool + numSimulations int32 + startTime time.Time + genStart time.Time + + quoteIdx int + quoteTick int + spinnerIdx int + glowIdx int + + filter int + cursor int + scrollOff int + detailJobID string + showLogs bool + showDescription bool + + width int + height int + err error +} + +type quote struct { + text string + glow bool // iconic quotes get a subtle glow + weight int // higher = more likely to appear +} + +var simulationQuotes = []quote{ + // Iconic — glow sweep, high weight + {"There is no spoon.", true, 5}, // Spoon Boy — The Matrix + {"What is real? How do you define real?", true, 5}, // Morpheus — The Matrix + {"Wake up, Neo.", true, 5}, // Trinity — The Matrix + {"Free your mind.", true, 4}, // Morpheus — The Matrix + {"Welcome to the real world.", true, 4}, // Morpheus — The Matrix + {"Shall we play a game?", true, 4}, // WOPR — WarGames + {"Open the pod bay doors, HAL.", true, 4}, // Dave — 2001: A Space Odyssey + {"The Matrix is everywhere. It is all around us.", true, 3}, // Morpheus — The Matrix + // Well-known — no glow, medium weight + {"Do not try and bend the spoon. That's impossible.", false, 3}, // Spoon Boy — The Matrix + {"The only winning move is not to play.", false, 3}, // WarGames + {"These violent delights have violent ends.", false, 3}, // Westworld + {"I think, therefore I am.", false, 3}, // René Descartes + {"Unfortunately, no one can be told what the Matrix is.", false, 2}, // Morpheus — The Matrix + {"Ever had that feeling where you're not sure if you're awake or still dreaming?", false, 2}, // Neo — The Matrix + {"I can only show you the door. You're the one that has to walk through it.", false, 2}, // Morpheus — The Matrix + {"Remember, all I'm offering is the truth. Nothing more.", false, 2}, // Morpheus — The Matrix + // Niche — low weight + {"I don't like the idea that I'm not in control of my life.", false, 1}, // Neo — The Matrix + {"Choice is an illusion created between those with power and those without.", false, 1}, // Merovingian — The Matrix Reloaded + {"The odds that we are in base reality is one in billions.", false, 1}, // Elon Musk + {"The world, then, is a radical illusion.", false, 1}, // Jean Baudrillard + {"That's all it is. Information.", false, 1}, // Ghost in the Shell + {"Not one single bit of it is real.", false, 1}, // The Metamorphosis of Prime Intellect + {"I wish I had a good argument against it.", false, 1}, // Neil deGrasse Tyson + // Playful + {"Warming up the neural pathways...", false, 1}, + {"Reticulating splines...", false, 1}, // SimCity + {"Generating plausible humans...", false, 1}, + {"Convincing the AI to cooperate...", false, 2}, + {"Teaching robots to small talk...", false, 1}, +} + +// weightedQuotePool builds a flat slice with quotes repeated by weight for random selection. +var weightedQuotePool = func() []int { + var pool []int + for i, q := range simulationQuotes { + for range q.weight { + pool = append(pool, i) + } + } + return pool +}() + +func newSimulateModel(config *simulateConfig) *simulateModel { + return &simulateModel{ + config: config, + client: config.client, + numSimulations: config.numSimulations, + quoteIdx: weightedQuotePool[rand.Intn(len(weightedQuotePool))], + width: 80, + height: 24, + } +} + +// --- Setup messages --- + +type setupStepMsg struct { + stepIdx int + elapsed []time.Duration // elapsed time per completed step + err error + runID string + agent *AgentProcess +} + +func (m *simulateModel) Init() tea.Cmd { + return tea.Batch( + m.runSetup(), + tickCmd(), + spinnerTickCmd(), + glowTickCmd(), + ) +} + +func (m *simulateModel) runSetup() tea.Cmd { + c := m.config + + // Determine which steps to show + m.steps = []step{ + {label: "Starting agent", status: "running"}, + {label: "Creating simulation", status: "pending"}, + } + if c.mode == modeGenerateFromSource { + m.steps = append(m.steps, step{label: "Uploading source", status: "pending"}) + } + + ctx, cancel := context.WithCancel(c.ctx) + m.setupCancel = cancel + + return func() tea.Msg { + var elapsed []time.Duration + stepStart := time.Now() + + // Step 0: Start agent & wait for registration + agent, err := startAgent(AgentStartConfig{ + Dir: c.projectDir, + Entrypoint: c.entrypoint, + ProjectType: c.projectType, + CLIArgs: []string{ + "start", + "--url", c.pc.URL, + "--api-key", c.pc.APIKey, + "--api-secret", c.pc.APISecret, + }, + Env: []string{ + "LIVEKIT_AGENT_NAME=" + c.agentName, + "LIVEKIT_URL=" + c.pc.URL, + "LIVEKIT_API_KEY=" + c.pc.APIKey, + "LIVEKIT_API_SECRET=" + c.pc.APISecret, + }, + ReadySignal: "registered worker", + }) + if err != nil { + return setupStepMsg{stepIdx: 0, err: fmt.Errorf("failed to start agent: %w", err)} + } + + // Wait for agent ready + timeout := time.NewTimer(10 * time.Second) + defer timeout.Stop() + select { + case <-agent.Ready(): + case err := <-agent.Done(): + if err != nil { + return setupStepMsg{stepIdx: 0, err: fmt.Errorf("agent exited before registering: %w", err), agent: agent} + } + return setupStepMsg{stepIdx: 0, err: fmt.Errorf("agent exited before registering"), agent: agent} + case <-timeout.C: + return setupStepMsg{stepIdx: 0, err: fmt.Errorf("timed out waiting for agent to register (10s)"), agent: agent} + case <-ctx.Done(): + return setupStepMsg{stepIdx: 0, err: ctx.Err(), agent: agent} + } + elapsed = append(elapsed, time.Since(stepStart)) + stepStart = time.Now() + + // Step 1: Create simulation run + req := &livekit.SimulationRun_Create_Request{ + AgentName: c.agentName, + AgentDescription: c.description, + NumSimulations: c.numSimulations, + } + switch c.mode { + case modeInlineScenarios: + scenarios := make([]*livekit.SimulationRun_Create_Scenario, 0, len(c.cfg.Scenarios)) + for _, sc := range c.cfg.Scenarios { + scenarios = append(scenarios, &livekit.SimulationRun_Create_Scenario{ + Label: sc.Label, + Instructions: sc.Instructions, + AgentExpectations: sc.AgentExpectations, + Metadata: sc.Metadata, + }) + } + req.Source = &livekit.SimulationRun_Create_Request_Scenarios{ + Scenarios: &livekit.SimulationRun_Create_Scenarios{ + Scenarios: scenarios, + }, + } + case modeScenarioGroup: + req.Source = &livekit.SimulationRun_Create_Request_GroupId{ + GroupId: c.scenarioGroupID, + } + } + + resp, err := c.client.CreateSimulationRun(ctx, req) + if err != nil { + return setupStepMsg{stepIdx: 1, err: fmt.Errorf("failed to create simulation: %w", err), agent: agent} + } + elapsed = append(elapsed, time.Since(stepStart)) + stepStart = time.Now() + runID := resp.SimulationRunId + + // Step 2: Upload source (if needed) + if c.mode == modeGenerateFromSource { + presigned := resp.PresignedPostRequest + if presigned == nil { + return setupStepMsg{stepIdx: 2, err: fmt.Errorf("server did not return upload URL"), agent: agent, runID: runID} + } + + sourceDir, _ := os.Getwd() + var buf bytes.Buffer + if err := cloudagents.CreateSourceZip(os.DirFS(sourceDir), nil, &buf); err != nil { + return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to create source zip: %w", err), agent: agent, runID: runID} + } + if err := cloudagents.MultipartUpload(presigned.Url, presigned.Values, &buf); err != nil { + return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to upload source: %w", err), agent: agent, runID: runID} + } + if _, err := c.client.ConfirmSimulationSourceUpload(ctx, &livekit.SimulationRun_ConfirmSourceUpload_Request{ + SimulationRunId: runID, + }); err != nil { + return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to confirm upload: %w", err), agent: agent, runID: runID} + } + elapsed = append(elapsed, time.Since(stepStart)) + } + + // All done + lastStep := len(m.steps) - 1 + return setupStepMsg{stepIdx: lastStep, elapsed: elapsed, agent: agent, runID: runID} + } +} + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return pollTickMsg{} + }) +} + +func spinnerTickCmd() tea.Cmd { + return tea.Tick(80*time.Millisecond, func(t time.Time) tea.Msg { + return spinnerTickMsg{} + }) +} + +func glowTickCmd() tea.Cmd { + return tea.Tick(40*time.Millisecond, func(t time.Time) tea.Msg { + return glowTickMsg{} + }) +} + +func (m *simulateModel) pollSimulation() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + resp, err := m.client.GetSimulationRun(ctx, &livekit.SimulationRun_Get_Request{ + SimulationRunId: m.runID, + }) + if err != nil { + return simulationRunMsg{err: err} + } + return simulationRunMsg{run: resp.Run} + } +} + +func (m *simulateModel) waitSubprocess() tea.Cmd { + if m.agent == nil { + return nil + } + return func() tea.Msg { + err := <-m.agent.Done() + return subprocessExitMsg{err: err} + } +} + +func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case setupStepMsg: + if msg.agent != nil { + m.agent = msg.agent + } + if msg.runID != "" { + m.runID = msg.runID + } + if msg.err != nil { + // Mark current step as failed + if msg.stepIdx < len(m.steps) { + m.steps[msg.stepIdx].status = "failed" + } + m.err = msg.err + m.setupDone = true + m.runFinished = true + return m, nil + } + // Mark all steps up to and including this one as done + for i := 0; i <= msg.stepIdx && i < len(m.steps); i++ { + m.steps[i].status = "done" + if i < len(msg.elapsed) { + m.steps[i].elapsed = msg.elapsed[i] + } + } + // If all steps are done, start polling + if msg.stepIdx >= len(m.steps)-1 { + m.setupDone = true + m.genStart = time.Now() + return m, tea.Batch(m.pollSimulation(), m.waitSubprocess()) + } + // Mark next step as running + if msg.stepIdx+1 < len(m.steps) { + m.steps[msg.stepIdx+1].status = "running" + } + return m, nil + + case simulationRunMsg: + if msg.err == nil && msg.run != nil { + m.run = msg.run + if m.startTime.IsZero() && msg.run.Status == livekit.SimulationRun_STATUS_RUNNING { + m.startTime = time.Now() + } + if msg.run.Status == livekit.SimulationRun_STATUS_COMPLETED || + msg.run.Status == livekit.SimulationRun_STATUS_FAILED || + msg.run.Status == livekit.SimulationRun_STATUS_CANCELLED { + m.runFinished = true + } + } + + case spinnerTickMsg: + m.spinnerIdx++ + return m, spinnerTickCmd() + + case glowTickMsg: + m.glowIdx++ + return m, glowTickCmd() + + case pollTickMsg: + m.quoteTick++ + if m.quoteTick%60 == 0 { + m.quoteIdx = weightedQuotePool[rand.Intn(len(weightedQuotePool))] + } + var cmds []tea.Cmd + if m.setupDone && !m.runFinished { + cmds = append(cmds, m.pollSimulation()) + } + cmds = append(cmds, tickCmd()) + return m, tea.Batch(cmds...) + + case subprocessExitMsg: + // Subprocess exited — don't quit TUI, just note it + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c": + if m.setupCancel != nil { + m.setupCancel() + } + return m, tea.Quit + case "ctrl+l": + m.showLogs = !m.showLogs + case "d": + if m.detailJobID == "" { + m.showDescription = !m.showDescription + } + case "up", "shift+tab": + m.cursor-- + case "down", "tab": + m.cursor++ + case "pgup": + m.cursor -= 20 + case "pgdown": + m.cursor += 20 + case "left": + m.filter = (m.filter + len(filterNames) - 1) % len(filterNames) + m.cursor = 0 + m.scrollOff = 0 + case "right": + m.filter = (m.filter + 1) % len(filterNames) + m.cursor = 0 + m.scrollOff = 0 + case "enter": + if m.detailJobID == "" { + jobs := m.filteredJobs() + if m.cursor >= 0 && m.cursor < len(jobs) { + m.detailJobID = jobs[m.cursor].job.Id + } + } + case "esc", "backspace": + if m.detailJobID != "" { + m.detailJobID = "" + } + case "q": + if m.detailJobID != "" { + m.detailJobID = "" + } else { + return m, tea.Quit + } + } + return m, nil +} + +type indexedJob struct { + origIdx int + job *livekit.SimulationRun_Job +} + +func (m *simulateModel) filteredJobs() []indexedJob { + if m.run == nil { + return nil + } + var result []indexedJob + for i, j := range m.run.Jobs { + match := false + switch m.filter { + case filterAll: + match = true + case filterFailed: + match = j.Status == livekit.SimulationRun_Job_STATUS_FAILED + case filterPassed: + match = j.Status == livekit.SimulationRun_Job_STATUS_COMPLETED + case filterRunning: + match = j.Status == livekit.SimulationRun_Job_STATUS_RUNNING + } + if match { + result = append(result, indexedJob{origIdx: i + 1, job: j}) + } + } + return result +} + +func (m *simulateModel) View() string { + // Setup phase or generating phase — show unified step view + if !m.setupDone || m.run == nil || m.run.Status == livekit.SimulationRun_STATUS_GENERATING { + return m.viewSetup() + } + switch m.run.Status { + case livekit.SimulationRun_STATUS_FAILED: + if len(m.run.Jobs) == 0 { + return m.viewFailed() + } + return m.viewRunning() + default: + return m.viewRunning() + } +} + +func (m *simulateModel) viewSetup() string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(tagStyle.Render("Agent Simulation")) + b.WriteString("\n\n") + + if m.config.pc != nil && m.config.pc.Name != "" { + b.WriteString(dimStyle.Render(" Project: "+m.config.pc.Name) + "\n") + } + if m.config.pc != nil && m.config.pc.URL != "" { + b.WriteString(dimStyle.Render(" URL: "+m.config.pc.URL) + "\n") + } + if m.runID != "" { + b.WriteString(dimStyle.Render(" Run: "+m.runID) + "\n") + } + if url := m.getDashboardURL(); url != "" { + b.WriteString(dimStyle.Render(" "+url) + "\n") + } + b.WriteString("\n") + + b.WriteString(m.renderSteps()) + + // Show generation progress after setup completes + if m.setupDone && m.err == nil { + elapsed := time.Since(m.genStart).Truncate(time.Second) + b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("●"), m.numSimulations, m.spinner(), dimStyle.Render(elapsed.String()))) + } + + if m.err != nil { + b.WriteString("\n") + b.WriteString(redStyle.Render(" "+m.err.Error()) + "\n") + if m.agent != nil { + b.WriteString("\n") + b.WriteString(m.renderLogs()) + } + b.WriteString("\n") + b.WriteString(dimStyle.Render(" q quit")) + b.WriteString("\n") + } else { + b.WriteString("\n") + if m.showLogs { + b.WriteString(m.renderLogs()) + } + b.WriteString(m.quoteAboveHint(" Ctrl+L logs")) + b.WriteString("\n") + } + return b.String() +} + +func (m *simulateModel) spinner() string { + return yellowStyle.Render(spinnerFrames[m.spinnerIdx%len(spinnerFrames)]) +} + +var quoteStyleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("237")) + +// glowShades are brightness levels for the sweep effect (dark → bright → dark) +var glowShades = []lipgloss.Color{"237", "239", "242", "245", "248", "245", "242", "239", "237"} + +func (m *simulateModel) quote() string { + q := simulationQuotes[m.quoteIdx] + if !q.glow { + return quoteStyleDim.Render(q.text) + } + // Sweep a bright spot across the text, then stay dark for a long pause + runes := []rune(q.text) + sweepLen := len(runes) + len(glowShades) + cycleLen := sweepLen + 250 // ~10s pause at 40ms tick + center := m.glowIdx % cycleLen + if center >= sweepLen { + // In the pause phase — render all dim + return quoteStyleDim.Render(q.text) + } + var b strings.Builder + for i, r := range runes { + dist := center - i + if dist >= 0 && dist < len(glowShades) { + style := lipgloss.NewStyle().Foreground(glowShades[dist]) + if dist >= 2 && dist <= 6 { // italic only for the brightest chars + style = style.Italic(true) + } + b.WriteString(style.Render(string(r))) + } else { + b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("237")).Render(string(r))) + } + } + return b.String() +} + +func (m *simulateModel) renderSteps() string { + var b strings.Builder + for _, s := range m.steps { + switch s.status { + case "done": + elapsed := "" + if s.elapsed > 0 { + elapsed = " " + dimStyle.Render(s.elapsed.Round(time.Millisecond).String()) + } + b.WriteString(fmt.Sprintf(" %s %s%s\n", greenStyle.Render("✓"), s.label, elapsed)) + case "running": + b.WriteString(fmt.Sprintf(" %s %s\n", yellowStyle.Render("●"), s.label)) + case "failed": + b.WriteString(fmt.Sprintf(" %s %s\n", redStyle.Render("✗"), s.label)) + default: + b.WriteString(fmt.Sprintf(" %s %s\n", dimStyle.Render("○"), s.label)) + } + } + return b.String() +} + +func (m *simulateModel) getDashboardURL() string { + if m.runID == "" || m.config == nil || m.config.pc == nil || m.config.pc.ProjectId == "" { + return "" + } + return fmt.Sprintf("%s/projects/%s/agents/simulations/%s", dashboardURL, m.config.pc.ProjectId, m.runID) +} + +func (m *simulateModel) viewFailed() string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(tagStyle.Render("Agent Simulation")) + b.WriteString(" ") + b.WriteString(dimStyle.Render(m.runID)) + b.WriteString("\n\n") + b.WriteString(" " + redStyle.Bold(true).Render("Failed") + "\n\n") + if m.run.Error != "" { + for _, line := range strings.Split(m.run.Error, "\n") { + b.WriteString(redStyle.Render(" "+line) + "\n") + } + } else { + b.WriteString(redStyle.Render(" (no error details available)") + "\n") + } + b.WriteString("\n") + if m.showLogs { + b.WriteString(m.renderLogs()) + } + b.WriteString(dimStyle.Render(" Ctrl+L logs · q quit")) + b.WriteString("\n") + return b.String() +} + +func (m *simulateModel) viewRunning() string { + var b strings.Builder + + b.WriteString("\n") + 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("\n\n") + + // Agent description + if m.run != nil && m.run.AgentDescription != "" { + b.WriteString(boldStyle.Render(" Agent Description") + "\n") + if m.showDescription { + wrapped := dimStyle.Width(m.width - 4).Render(m.run.AgentDescription) + for _, line := range strings.Split(wrapped, "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString(dimStyle.Render(" (press d to collapse)") + "\n\n") + } else { + desc := firstMeaningfulLine(m.run.AgentDescription) + if desc != "" { + b.WriteString(dimStyle.Width(m.width-4).Render(" "+desc) + "\n") + b.WriteString(dimStyle.Render(" (press d to expand)") + "\n\n") + } + } + } + + // Header line + b.WriteString(m.renderHeader()) + b.WriteString("\n") + + // Progress counts + b.WriteString(m.renderCounts()) + b.WriteString("\n") + + // Filter tabs + b.WriteString(m.renderFilterTabs()) + b.WriteString("\n\n") + + if m.detailJobID != "" { + b.WriteString(m.renderDetail()) + } else { + b.WriteString(m.renderJobList()) + + // Show summary when run is completed and summary is available + if m.run.Summary != nil { + b.WriteString(m.renderSummary()) + } + } + + b.WriteString("\n") + if m.showLogs { + b.WriteString(m.renderLogs()) + } + b.WriteString(m.renderHint()) + b.WriteString("\n") + return b.String() +} + +func (m *simulateModel) renderHeader() string { + var label, style string + switch { + case m.run.Status == livekit.SimulationRun_STATUS_COMPLETED || m.run.Status == livekit.SimulationRun_STATUS_FAILED || m.run.Status == livekit.SimulationRun_STATUS_CANCELLED: + total, done, _, _ := m.jobCounts() + allJobsDone := total > 0 && done == total + if m.run.Status == livekit.SimulationRun_STATUS_CANCELLED { + label = "Cancelled" + style = "yellow" + } else if m.run.Status == livekit.SimulationRun_STATUS_FAILED && !allJobsDone { + label = "Failed" + style = "red" + } else { + label = "Completed" + style = "green" + if allJobsDone && m.run.Summary == nil { + label += " — summary unavailable" + } + } + case m.run.Status == livekit.SimulationRun_STATUS_SUMMARIZING: + label = "Summarizing..." + style = "yellow" + default: + label = "Running" + style = "yellow" + } + + header := boldStyle.Render("Simulation") + " — " + switch style { + case "green": + header += greenStyle.Bold(true).Render(label) + case "red": + header += redStyle.Bold(true).Render(label) + case "yellow": + header += yellowStyle.Bold(true).Render(label) + } + return " " + header +} + +func (m *simulateModel) jobCounts() (total, done, passed, failed int) { + if m.run == nil { + return + } + total = len(m.run.Jobs) + for _, j := range m.run.Jobs { + switch j.Status { + case livekit.SimulationRun_Job_STATUS_COMPLETED: + done++ + passed++ + case livekit.SimulationRun_Job_STATUS_FAILED: + done++ + failed++ + } + } + return +} + +func (m *simulateModel) renderCounts() string { + total, done, passed, failed := m.jobCounts() + running := 0 + if m.run != nil { + for _, j := range m.run.Jobs { + if j.Status == livekit.SimulationRun_Job_STATUS_RUNNING { + running++ + } + } + } + + 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))) + } + if failed > 0 { + parts = append(parts, redStyle.Render(fmt.Sprintf("%d failed", failed))) + } + if running > 0 { + parts = append(parts, yellowStyle.Render(fmt.Sprintf("%d running", running))) + } + + elapsed := "" + if !m.startTime.IsZero() { + d := time.Since(m.startTime) + secs := int(d.Seconds()) + mins := secs / 60 + secs = secs % 60 + if mins > 0 { + elapsed = fmt.Sprintf("%dm%02ds", mins, secs) + } else { + elapsed = fmt.Sprintf("%ds", secs) + } + } + + result := " " + strings.Join(parts, " ") + if elapsed != "" { + result += " " + dimStyle.Render(elapsed) + } + return result +} + +func (m *simulateModel) renderFilterTabs() string { + total, _, passed, failed := m.jobCounts() + running := 0 + if m.run != nil { + for _, j := range m.run.Jobs { + if j.Status == livekit.SimulationRun_Job_STATUS_RUNNING { + running++ + } + } + } + + counts := []int{total, failed, passed, running} + styles := []lipgloss.Style{lipgloss.NewStyle(), redStyle, greenStyle, yellowStyle} + + var parts []string + for i, name := range filterNames { + label := fmt.Sprintf("%s: %d", name, counts[i]) + if i == m.filter { + parts = append(parts, styles[i].Bold(true).Render(label)) + } else { + parts = append(parts, dimStyle.Render(label)) + } + } + return " " + strings.Join(parts, " ") +} + +func (m *simulateModel) renderJobList() string { + jobs := m.filteredJobs() + if len(jobs) == 0 { + return dimStyle.Render(" (no jobs match this filter)") + } + + // Clamp cursor + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(jobs) { + m.cursor = len(jobs) - 1 + } + + // Compute visible window + availHeight := m.height - 14 + if availHeight < 5 { + availHeight = 5 + } + + if m.cursor < m.scrollOff { + m.scrollOff = m.cursor + } else if m.cursor >= m.scrollOff+availHeight { + m.scrollOff = m.cursor - availHeight + 1 + } + if m.scrollOff < 0 { + m.scrollOff = 0 + } + if m.scrollOff > len(jobs)-availHeight { + m.scrollOff = len(jobs) - availHeight + } + if m.scrollOff < 0 { + m.scrollOff = 0 + } + + winStart := m.scrollOff + winEnd := m.scrollOff + availHeight + if winEnd > len(jobs) { + winEnd = len(jobs) + } + + var b strings.Builder + + if winStart > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ... %d more above ...", winStart))) + b.WriteString("\n") + } + + for i := winStart; i < winEnd; i++ { + ij := jobs[i] + icon := jobIcon(ij.job) + instr := ij.job.Instructions + if len(instr) > 60 { + instr = instr[:60] + "..." + } + if instr == "" { + instr = "—" + } + + var line string + if i == m.cursor { + // Build without inner styles so reverse applies cleanly + line = fmt.Sprintf(" %s %3d. %s %s", icon, ij.origIdx, ij.job.Id, instr) + visible := lipgloss.Width(line) + if visible < m.width { + line += strings.Repeat(" ", m.width-visible) + } + line = reverseStyle.Render(line) + } else { + line = fmt.Sprintf(" %s %3d. %s %s", icon, ij.origIdx, dimStyle.Render(ij.job.Id), instr) + } + b.WriteString(line) + b.WriteString("\n") + } + + remaining := len(jobs) - winEnd + if remaining > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ... %d more below ...", remaining))) + b.WriteString("\n") + } + + return b.String() +} + +func (m *simulateModel) renderDetail() string { + if m.run == nil { + return "" + } + var job *livekit.SimulationRun_Job + origIdx := 0 + for i, j := range m.run.Jobs { + if j.Id == m.detailJobID { + job = j + origIdx = i + 1 + break + } + } + if job == nil { + m.detailJobID = "" + return dimStyle.Render(" (job not found)\n") + } + + var b strings.Builder + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" %s %s %s\n", + jobIcon(job), + boldStyle.Render(fmt.Sprintf("Job %d", origIdx)), + dimStyle.Render(job.Id), + )) + b.WriteString("\n") + + wrapWidth := m.width - 6 + if wrapWidth < 40 { + wrapWidth = 40 + } + wrapStyle := lipgloss.NewStyle().Width(wrapWidth) + + b.WriteString(boldStyle.Render(" Instructions:")) + b.WriteString("\n") + instr := job.Instructions + if instr == "" { + instr = "—" + } + for _, line := range strings.Split(wrapStyle.Render(instr), "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString("\n") + + b.WriteString(dimStyle.Bold(true).Render(" Expected:")) + b.WriteString("\n") + expect := job.AgentExpectations + if expect == "" { + expect = "—" + } + for _, line := range strings.Split(wrapStyle.Render(expect), "\n") { + b.WriteString(dimStyle.Render(" "+line) + "\n") + } + + if job.Error != "" { + b.WriteString("\n") + if job.Status == livekit.SimulationRun_Job_STATUS_COMPLETED { + b.WriteString(greenStyle.Bold(true).Render(" Result:")) + b.WriteString("\n") + for _, line := range strings.Split(wrapStyle.Render(job.Error), "\n") { + b.WriteString(greenStyle.Render(" "+line) + "\n") + } + } else { + b.WriteString(redStyle.Bold(true).Render(" Error:")) + b.WriteString("\n") + for _, line := range strings.Split(wrapStyle.Render(job.Error), "\n") { + b.WriteString(redStyle.Render(" "+line) + "\n") + } + } + } + + // Show chat transcript if available + b.WriteString(m.renderChatTranscript(job.Id)) + + return b.String() +} + +func (m *simulateModel) renderSummary() string { + summary := m.run.Summary + if summary == nil { + return "" + } + + var b strings.Builder + b.WriteString("\n") + b.WriteString(dimStyle.Render(" " + strings.Repeat("─", 40))) + b.WriteString("\n\n") + b.WriteString(" " + boldStyle.Render("Summary")) + b.WriteString(fmt.Sprintf(" %s %s\n\n", + greenStyle.Render(fmt.Sprintf("%d passed", summary.Passed)), + redStyle.Render(fmt.Sprintf("%d failed", summary.Failed)), + )) + + wrapWidth := m.width - 6 + if wrapWidth < 40 { + wrapWidth = 40 + } + + if summary.GoingWell != "" { + b.WriteString(greenStyle.Bold(true).Render(" Going well:")) + b.WriteString("\n") + wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(summary.GoingWell) + for _, line := range strings.Split(wrapped, "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString("\n") + } + + if summary.ToImprove != "" { + b.WriteString(yellowStyle.Bold(true).Render(" To improve:")) + b.WriteString("\n") + wrapped := lipgloss.NewStyle().Width(wrapWidth).Render(summary.ToImprove) + for _, line := range strings.Split(wrapped, "\n") { + b.WriteString(" " + line + "\n") + } + b.WriteString("\n") + } + + if len(summary.Issues) > 0 { + b.WriteString(redStyle.Bold(true).Render(" Issues:")) + b.WriteString("\n") + issueWrap := wrapWidth - 4 // account for " N. " prefix + if issueWrap < 30 { + issueWrap = 30 + } + for i, issue := range summary.Issues { + prefix := fmt.Sprintf(" %d. ", i+1) + descWrapped := lipgloss.NewStyle().Width(issueWrap).Render(issue.Description) + for j, line := range strings.Split(descWrapped, "\n") { + if j == 0 { + b.WriteString(prefix + line + "\n") + } else { + b.WriteString(strings.Repeat(" ", len(prefix)) + line + "\n") + } + } + if issue.Suggestion != "" { + sugWrapped := lipgloss.NewStyle().Width(issueWrap).Render("Suggestion: " + issue.Suggestion) + for _, line := range strings.Split(sugWrapped, "\n") { + b.WriteString(dimStyle.Render(strings.Repeat(" ", len(prefix))+line) + "\n") + } + } + } + b.WriteString("\n") + } + + return b.String() +} + +func (m *simulateModel) renderChatTranscript(jobID string) string { + if m.run.Summary == nil || m.run.Summary.ChatHistory == nil { + return "" + } + chatCtx, ok := m.run.Summary.ChatHistory[jobID] + if !ok || chatCtx == nil || len(chatCtx.Items) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("\n") + b.WriteString(boldStyle.Render(" Transcript:")) + b.WriteString("\n\n") + + for _, item := range chatCtx.Items { + switch v := item.Item.(type) { + case *agent.ChatContext_ChatItem_Message: + msg := v.Message + role := chatRoleLabel(msg.Role) + text := chatMessageText(msg) + b.WriteString(fmt.Sprintf(" %s: %s\n", role, text)) + case *agent.ChatContext_ChatItem_FunctionCall: + fc := v.FunctionCall + args := fc.Arguments + if len(args) > 80 { + args = args[:80] + "..." + } + b.WriteString(dimStyle.Render(fmt.Sprintf(" [call] %s(%s)", fc.Name, args))) + b.WriteString("\n") + case *agent.ChatContext_ChatItem_FunctionCallOutput: + fco := v.FunctionCallOutput + output := fco.Output + if len(output) > 80 { + output = output[:80] + "..." + } + label := "output" + if fco.IsError { + label = "error" + } + b.WriteString(dimStyle.Render(fmt.Sprintf(" [%s] %s -> %s", label, fco.Name, output))) + b.WriteString("\n") + case *agent.ChatContext_ChatItem_AgentHandoff: + h := v.AgentHandoff + b.WriteString(dimStyle.Render(fmt.Sprintf(" [handoff] -> %s", h.NewAgentId))) + b.WriteString("\n") + case *agent.ChatContext_ChatItem_AgentConfigUpdate: + b.WriteString(dimStyle.Render(" [config update]")) + b.WriteString("\n") + } + } + return b.String() +} + +func chatRoleLabel(role agent.ChatRole) string { + switch role { + case agent.ChatRole_USER: + return cyanStyle.Render("User") + case agent.ChatRole_ASSISTANT: + return greenStyle.Render("Agent") + case agent.ChatRole_SYSTEM: + return dimStyle.Render("System") + case agent.ChatRole_DEVELOPER: + return dimStyle.Render("Developer") + default: + return dimStyle.Render("Unknown") + } +} + +func chatMessageText(msg *agent.ChatMessage) string { + if msg == nil || len(msg.Content) == 0 { + return "" + } + var parts []string + for _, c := range msg.Content { + if t := c.GetText(); t != "" { + parts = append(parts, t) + } + } + return strings.Join(parts, " ") +} + +func (m *simulateModel) renderLogs() string { + if m.agent == nil { + return "" + } + var b strings.Builder + b.WriteString(dimStyle.Render(" " + strings.Repeat("─", 40))) + b.WriteString("\n") + logBudget := m.height - 15 + if logBudget < 3 { + logBudget = 3 + } + lines := m.agent.RecentLogs(logBudget) + for _, line := range lines { + b.WriteString(dimStyle.Render(" "+line) + "\n") + } + return b.String() +} + +// firstMeaningfulLine returns the first non-empty, non-heading line from text. +func firstMeaningfulLine(text string) string { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + return line + } + return "" +} + +func (m *simulateModel) renderHint() string { + var hint string + if m.detailJobID != "" { + hint = " ESC/q back · Ctrl+L logs" + } else { + hint = " ↑↓/Tab navigate · ENTER detail · ←→ filter · d description · Ctrl+L logs" + if m.runFinished { + hint += " · q quit" + } + } + return m.quoteAboveHint(hint) +} + +func (m *simulateModel) quoteAboveHint(hint string) string { + q := m.quote() + if !m.showLogs && lipgloss.Width(q) < m.width-4 { + return " " + q + "\n" + dimStyle.Render(hint) + } + return dimStyle.Render(hint) +} + +func jobIcon(job *livekit.SimulationRun_Job) string { + switch job.Status { + case livekit.SimulationRun_Job_STATUS_COMPLETED: + return greenStyle.Render("✓") + case livekit.SimulationRun_Job_STATUS_FAILED: + return redStyle.Render("✗") + case livekit.SimulationRun_Job_STATUS_RUNNING: + return yellowStyle.Render("●") + default: + return dimStyle.Render("○") + } +} diff --git a/cmd/lk/utils.go b/cmd/lk/utils.go index caf3571e..af5ba82e 100644 --- a/cmd/lk/utils.go +++ b/cmd/lk/utils.go @@ -18,6 +18,7 @@ import ( "context" "errors" "fmt" + "io" "maps" "os" "strings" @@ -233,6 +234,7 @@ func parseKeyValuePairs(c *cli.Command, flag string) (map[string]string, error) type loadParams struct { requireURL bool confirmProject bool + output io.Writer } type loadOption func(*loadParams) @@ -244,6 +246,12 @@ var ( confirmProject = func(p *loadParams) { p.confirmProject = true } + outputToStderr = func(p *loadParams) { + p.output = os.Stderr + } + quietOutput = func(p *loadParams) { + p.output = io.Discard + } ) // attempt to load connection config, it'll prioritize @@ -251,13 +259,14 @@ var ( // 2. config file (by default, livekit.toml) // 3. default project config func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConfig, error) { - p := loadParams{requireURL: true, confirmProject: false} + p := loadParams{requireURL: true, confirmProject: false, output: os.Stdout} for _, opt := range opts { opt(&p) } + w := p.output logDetails := func(c *cli.Command, pc *config.ProjectConfig) { if c.Bool("verbose") { - fmt.Printf("URL: %s, api-key: %s, api-secret: %s\n", + fmt.Fprintf(w, "URL: %s, api-key: %s, api-secret: %s\n", pc.URL, pc.APIKey, "************", @@ -275,7 +284,7 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf if err != nil { return nil, err } - fmt.Fprintf(os.Stderr, "Using project [%s]\n", util.Accented(c.String("project"))) + fmt.Fprintln(w, "Using project ["+util.Accented(c.String("project"))+"]") logDetails(c, pc) return pc, nil } @@ -289,7 +298,7 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf if err != nil { return nil, err } - fmt.Fprintf(os.Stderr, "Using project [%s]\n", util.Accented(pc.Name)) + fmt.Fprintln(w, "Using project ["+util.Accented(pc.Name)+"]") logDetails(c, pc) return pc, nil } @@ -323,7 +332,7 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf envVars = append(envVars, "api-secret") } if len(envVars) > 0 { - fmt.Fprintf(os.Stderr, "Using %s from environment\n", strings.Join(envVars, ", ")) + fmt.Fprintf(w, "Using %s from environment\n", strings.Join(envVars, ", ")) logDetails(c, pc) } return pc, nil @@ -331,7 +340,7 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf if c.Bool("dev") { pc.APIKey = "devkey" pc.APISecret = "secret" - fmt.Fprintln(os.Stderr, "Using dev credentials") + fmt.Fprintln(w, "Using dev credentials") return pc, nil } @@ -363,13 +372,13 @@ func loadProjectDetails(c *cli.Command, opts ...loadOption) (*config.ProjectConf if _, err = selectProject(context.Background(), c); err != nil { return nil, err } - fmt.Fprintf(os.Stderr, "Using project [%s]\n", util.Accented(project.Name)) + fmt.Fprintf(w, "Using project [%s]\n", util.Accented(project.Name)) return project, nil } } } else { if !c.Bool("silent") && !SkipPrompts(c) { - fmt.Fprintf(os.Stderr, "Using default project [%s]\n", util.Theme.Focused.Title.Render(dp.Name)) + fmt.Fprintln(w, "Using default project ["+util.Theme.Focused.Title.Render(dp.Name)+"]") logDetails(c, dp) } } diff --git a/go.mod b/go.mod index c2eaa927..97b1a731 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,13 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.5.0 github.com/Masterminds/semver/v3 v3.4.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v0.7.1-0.20250818142555-c41a69ba6443 github.com/charmbracelet/huh/spinner v0.0.0-20250818142555-c41a69ba6443 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/frostbyte73/core v0.1.1 + github.com/fsnotify/fsnotify v1.9.0 github.com/go-logr/logr v1.4.3 github.com/go-task/task/v3 v3.44.1 github.com/joho/godotenv v1.5.1 @@ -53,8 +56,6 @@ 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/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect @@ -84,7 +85,6 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gammazero/deque v1.2.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -208,3 +208,7 @@ require ( mvdan.cc/sh/v3 v3.12.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect ) + +replace github.com/livekit/protocol => ../protocol + +replace github.com/livekit/server-sdk-go/v2 => ../server-sdk-go diff --git a/go.sum b/go.sum index ead5d6b7..07bfc50c 100644 --- a/go.sum +++ b/go.sum @@ -273,12 +273,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260309115634-0e2e24b36ee8 h1:coWig9fKxdb/nwOaIoGUUAogso12GblAJh/9SA9hcxk= github.com/livekit/mediatransportutil v0.0.0-20260309115634-0e2e24b36ee8/go.mod h1:RCd46PT+6sEztld6XpkCrG1xskb0u3SqxIjy4G897Ss= -github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3 h1:wmg/PTPHbIXpKoQvoLcqdJS0K8KpKGf34Xe+YPOPTm8= -github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0= github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= -github.com/livekit/server-sdk-go/v2 v2.16.1 h1:ZkIA9OdVvQ6Up1uW/RtQ0YJUgYMJ6+ywOmDg0jX7bTg= -github.com/livekit/server-sdk-go/v2 v2.16.1/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.16.1 h1:j5UwkdA48xTlGs0Hcm1Q3sSAcxBorntQjiewDNMsqlo= diff --git a/pkg/agentfs/detect.go b/pkg/agentfs/detect.go index 55a0ceae..4d8d1c05 100644 --- a/pkg/agentfs/detect.go +++ b/pkg/agentfs/detect.go @@ -16,7 +16,10 @@ package agentfs import ( "errors" + "fmt" "io/fs" + "os" + "path/filepath" "github.com/livekit/livekit-cli/v2/pkg/util" "github.com/pelletier/go-toml" @@ -115,3 +118,26 @@ func DetectProjectType(dir fs.FS) (ProjectType, error) { return ProjectTypeUnknown, errors.New("expected package.json, requirements.txt, pyproject.toml, or lock files") } + +// DetectProjectRoot walks up from dir to find a directory containing project +// files (pyproject.toml, requirements.txt, package.json, etc). Returns the +// absolute path to the project root and the detected project type. +func DetectProjectRoot(dir string) (string, ProjectType, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return "", ProjectTypeUnknown, err + } + + for { + pt, err := DetectProjectType(os.DirFS(absDir)) + if err == nil { + return absDir, pt, nil + } + + parent := filepath.Dir(absDir) + if parent == absDir { + return "", ProjectTypeUnknown, fmt.Errorf("could not detect project type in %s or any parent directory", dir) + } + absDir = parent + } +} diff --git a/pkg/console/fft.go b/pkg/console/fft.go new file mode 100644 index 00000000..b1ac4983 --- /dev/null +++ b/pkg/console/fft.go @@ -0,0 +1,64 @@ +//go:build console + +package console + +import ( + "math" + "math/cmplx" +) + +// fft computes an in-place radix-2 Cooley-Tukey FFT. +func fft(a []complex128) { + n := len(a) + if n <= 1 { + return + } + + // Bit-reversal permutation + for i, j := 1, 0; i < n; i++ { + bit := n >> 1 + for ; j&bit != 0; bit >>= 1 { + j ^= bit + } + j ^= bit + if i < j { + a[i], a[j] = a[j], a[i] + } + } + + // Butterfly stages + for length := 2; length <= n; length <<= 1 { + angle := -2 * math.Pi / float64(length) + wn := cmplx.Exp(complex(0, angle)) + for i := 0; i < n; i += length { + w := complex(1, 0) + for j := 0; j < length/2; j++ { + u := a[i+j] + v := w * a[i+j+length/2] + a[i+j] = u + v + a[i+j+length/2] = u - v + w *= wn + } + } + } +} + +// rfft computes the real FFT of x, returning n/2+1 complex bins +// where n is the next power of 2 >= len(x). +func rfft(x []float64) ([]complex128, int) { + n := nextPow2(len(x)) + buf := make([]complex128, n) + for i, v := range x { + buf[i] = complex(v, 0) + } + fft(buf) + return buf[:n/2+1], n +} + +func nextPow2(n int) int { + p := 1 + for p < n { + p <<= 1 + } + return p +} diff --git a/pkg/console/pipeline.go b/pkg/console/pipeline.go new file mode 100644 index 00000000..6e43b6c5 --- /dev/null +++ b/pkg/console/pipeline.go @@ -0,0 +1,609 @@ +//go:build console + +// Package console implements the audio pipeline for the lk console command. +// It connects microphone input and speaker output via PortAudio, applies +// WebRTC audio processing (echo cancellation, noise suppression), and +// communicates with an agent over TCP using protobuf-framed SessionMessages. +// +// Architecture (3 goroutines, matching the Python console's PortAudio model): +// +// micLoop — reads PortAudio input into the capture ring buffer. +// speakerLoop — reads both rings, runs ProcessRender + ProcessCapture in +// lockstep, writes to speakers, sends capture to agent. +// Paced by outputStream.Write at the hardware output rate. +// tcpReader — reads TCP messages: audio → playback ring, events → TUI. +// +// All APM calls happen in speakerLoop, so they are single-threaded and +// guaranteed 1:1. +package console + +import ( + "context" + "encoding/binary" + "fmt" + "math" + "net" + "sync" + "time" + + agent "github.com/livekit/protocol/livekit/agent" + + "github.com/livekit/livekit-cli/v2/pkg/apm" + "github.com/livekit/livekit-cli/v2/pkg/portaudio" +) + +const ( + SampleRate = 48000 + Channels = 1 + FrameDurationMs = 30 + SamplesPerFrame = SampleRate * FrameDurationMs / 1000 // 1440 + APMFrameSamples = SampleRate / 100 // 480 (10ms) + NumFFTBands = 14 + + CaptureRingFrames = 50 // ~1.5s — small, just absorbs jitter between mic and speaker loops + PlaybackRingFrames = 4000 // ~120s — large, TTS pushes faster than real-time +) + +type AudioPipeline struct { + inputStream *portaudio.Stream + outputStream *portaudio.Stream + apmInst *apm.APM + noAEC bool + conn net.Conn + connMu sync.Mutex // protects writes to conn + + captureRing *RingBuffer + playbackRing *RingBuffer + + // Events channel receives AgentSessionEvents from the agent for the TUI. + Events chan *agent.AgentSessionEvent + + // Responses channel receives SessionResponses (request completions) for the TUI. + Responses chan *agent.SessionResponse + + // ready is closed when the agent session is established (first TCP message). + ready chan struct{} + readyOnce sync.Once + + // flushCancel cancels the current waitForDrainAndAck goroutine. + // Only accessed from the tcpReader goroutine. + flushCancel context.CancelFunc + + mu sync.Mutex + fftBands [NumFFTBands]float64 + muted bool + level float64 // capture level in dB + playing bool // true when outputting real audio (not silence) + + cancel context.CancelFunc + audioCtx context.Context // stored so EnableAudio can start goroutines + wg sync.WaitGroup +} + +type PipelineConfig struct { + InputDevice *portaudio.DeviceInfo // nil to skip audio (text-only) + OutputDevice *portaudio.DeviceInfo // nil to skip audio (text-only) + NoAEC bool + Conn net.Conn +} + +func NewPipeline(cfg PipelineConfig) (*AudioPipeline, error) { + ap := &AudioPipeline{ + conn: cfg.Conn, + noAEC: cfg.NoAEC, + Events: make(chan *agent.AgentSessionEvent, 64), + Responses: make(chan *agent.SessionResponse, 16), + ready: make(chan struct{}), + } + + if cfg.InputDevice != nil && cfg.OutputDevice != nil { + if err := ap.initAudio(cfg.InputDevice, cfg.OutputDevice, cfg.NoAEC); err != nil { + return nil, err + } + } + + return ap, nil +} + +func (p *AudioPipeline) initAudio(inputDev, outputDev *portaudio.DeviceInfo, noAEC bool) error { + inputStream, err := portaudio.OpenInputStream(inputDev, SampleRate, Channels, SamplesPerFrame) + if err != nil { + return err + } + + outputStream, err := portaudio.OpenOutputStream(outputDev, SampleRate, Channels, SamplesPerFrame) + if err != nil { + inputStream.Close() + return err + } + + var apmInst *apm.APM + if !noAEC { + apmCfg := apm.DefaultConfig() + apmCfg.CaptureChannels = Channels + apmCfg.RenderChannels = Channels + apmInst, err = apm.NewAPM(apmCfg) + if err != nil { + apmInst = nil // run without AEC + } + } + + if apmInst != nil { + inInfo := inputStream.Info() + outInfo := outputStream.Info() + delayMs := int((inInfo.InputLatency + outInfo.OutputLatency).Milliseconds()) + apmInst.SetStreamDelayMs(delayMs) + } + + p.inputStream = inputStream + p.outputStream = outputStream + p.apmInst = apmInst + p.captureRing = NewRingBuffer(SamplesPerFrame * CaptureRingFrames) + p.playbackRing = NewRingBuffer(SamplesPerFrame * PlaybackRingFrames) + return nil +} + +// EnableAudio lazily initializes audio devices. Returns an error if +// PortAudio is not available or devices cannot be opened. +func (p *AudioPipeline) EnableAudio() error { + if p.HasAudio() { + return nil + } + + if err := portaudio.Initialize(); err != nil { + return fmt.Errorf("failed to initialize PortAudio: %w", err) + } + + inputDev, err := portaudio.DefaultInputDevice() + if err != nil { + portaudio.Terminate() + return fmt.Errorf("input device: %w", err) + } + outputDev, err := portaudio.DefaultOutputDevice() + if err != nil { + portaudio.Terminate() + return fmt.Errorf("output device: %w", err) + } + + if err := p.initAudio(inputDev, outputDev, p.noAEC); err != nil { + portaudio.Terminate() + return err + } + + // Start the audio loops + if err := p.outputStream.Start(); err != nil { + return err + } + if err := p.inputStream.Start(); err != nil { + p.outputStream.Stop() + return err + } + + p.wg.Add(2) + ctx := p.audioCtx + go p.micLoop(ctx) + go p.speakerLoop(ctx) + + return nil +} + +// HasAudio reports whether the audio pipeline is active. +func (p *AudioPipeline) HasAudio() bool { + return p.inputStream != nil +} + +func (p *AudioPipeline) Start(ctx context.Context) error { + ctx, p.cancel = context.WithCancel(ctx) + p.audioCtx = ctx + + // Always run the TCP reader for events/responses. + p.wg.Add(1) + go p.tcpReader(ctx) + + // Start audio loops if devices are available. + if p.HasAudio() { + if err := p.outputStream.Start(); err != nil { + return err + } + if err := p.inputStream.Start(); err != nil { + p.outputStream.Stop() + return err + } + p.wg.Add(2) + go p.micLoop(ctx) + go p.speakerLoop(ctx) + } + + <-ctx.Done() + return nil +} + +func (p *AudioPipeline) Stop() { + if p.cancel != nil { + p.cancel() + } + + if p.HasAudio() { + p.inputStream.Abort() + p.outputStream.Abort() + } + p.conn.Close() + if p.captureRing != nil { + p.captureRing.cond.Broadcast() + } + + p.wg.Wait() + + if p.HasAudio() { + p.inputStream.Close() + p.outputStream.Close() + } + if p.apmInst != nil { + p.apmInst.Close() + } +} + +func (p *AudioPipeline) writeMessage(msg *agent.AgentSessionMessage) error { + p.connMu.Lock() + defer p.connMu.Unlock() + return WriteSessionMessage(p.conn, msg) +} + +func (p *AudioPipeline) SendRequest(req *agent.SessionRequest) error { + return p.writeMessage(&agent.AgentSessionMessage{ + Message: &agent.AgentSessionMessage_Request{Request: req}, + }) +} + +func (p *AudioPipeline) SetMuted(muted bool) { + p.mu.Lock() + p.muted = muted + p.mu.Unlock() +} + +func (p *AudioPipeline) Muted() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.muted +} + +func (p *AudioPipeline) Level() float64 { + p.mu.Lock() + defer p.mu.Unlock() + return p.level +} + +func (p *AudioPipeline) FFTBands() [NumFFTBands]float64 { + p.mu.Lock() + defer p.mu.Unlock() + return p.fftBands +} + +func (p *AudioPipeline) IsPlaying() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.playing +} + +func (p *AudioPipeline) AECStats() *apm.Stats { + if p.apmInst == nil { + return nil + } + s := p.apmInst.GetStats() + return &s +} + +// micLoop reads mic input at hardware rate and writes to the capture ring. +// Muting is applied here so speakerLoop always sees clean data. +func (p *AudioPipeline) micLoop(ctx context.Context) { + defer p.wg.Done() + buf := make([]int16, SamplesPerFrame*Channels) + + for { + if ctx.Err() != nil { + return + } + if err := p.inputStream.Read(buf); err != nil { + if ctx.Err() != nil { + return + } + continue + } + + p.mu.Lock() + muted := p.muted + p.mu.Unlock() + + if muted { + for i := range buf { + buf[i] = 0 + } + } + + p.captureRing.Write(buf) + } +} + +// speakerLoop runs all APM processing and output. Paced by outputStream.Write +// at the hardware output rate (~30ms). Each iteration: +// 1. Reads capture from captureRing (non-blocking, silence if empty) +// 2. Reads playback from playbackRing (non-blocking, silence if empty) +// 3. ProcessRender then ProcessCapture (single-threaded, 1:1) +// 4. Writes playback to speakers +// 5. Sends processed capture to agent +func (p *AudioPipeline) speakerLoop(ctx context.Context) { + defer p.wg.Done() + captureBuf := make([]int16, SamplesPerFrame*Channels) + playbackBuf := make([]int16, SamplesPerFrame*Channels) + apmBuf := make([]int16, APMFrameSamples*Channels) + ready := false + + for { + if ctx.Err() != nil { + return + } + + // Read capture (non-blocking); pad remainder with silence. + cn := p.captureRing.ReadAvailable(captureBuf) + for i := cn; i < len(captureBuf); i++ { + captureBuf[i] = 0 + } + + // Read playback (non-blocking); pad remainder with silence. + pn := p.playbackRing.ReadAvailable(playbackBuf) + for i := pn; i < len(playbackBuf); i++ { + playbackBuf[i] = 0 + } + + p.mu.Lock() + p.playing = pn > 0 + p.mu.Unlock() + + // ProcessRender then ProcessCapture — both in this goroutine, + // right next to each other, no mutex needed. + if p.apmInst != nil { + for i := 0; i < SamplesPerFrame; i += APMFrameSamples { + copy(apmBuf, playbackBuf[i:i+APMFrameSamples]) + _ = p.apmInst.ProcessRender(apmBuf) + + copy(apmBuf, captureBuf[i:i+APMFrameSamples]) + _ = p.apmInst.ProcessCapture(apmBuf) + copy(captureBuf[i:], apmBuf) + } + } + + // Write playback to speakers — blocks at hardware rate. + if err := p.outputStream.Write(playbackBuf); err != nil { + if ctx.Err() != nil { + return + } + } + + // Send processed capture to agent (only after session is ready). + if !ready { + select { + case <-p.ready: + ready = true + default: + continue + } + } + + p.computeMetrics(captureBuf) + + _ = p.writeMessage(&agent.AgentSessionMessage{ + Message: &agent.AgentSessionMessage_AudioInput{ + AudioInput: &agent.AgentSessionMessage_ConsoleIO_AudioFrame{ + Data: SamplesToBytes(captureBuf), + SampleRate: SampleRate, + NumChannels: Channels, + SamplesPerChannel: uint32(SamplesPerFrame), + }, + }, + }) + } +} + +// tcpReader reads messages from the agent over TCP and dispatches them. +func (p *AudioPipeline) tcpReader(ctx context.Context) { + defer p.wg.Done() + + for { + msg, err := ReadSessionMessage(p.conn) + if err != nil { + return + } + + p.readyOnce.Do(func() { close(p.ready) }) + + switch m := msg.Message.(type) { + case *agent.AgentSessionMessage_AudioOutput: + p.playbackRing.Write(BytesToSamples(m.AudioOutput.Data)) + + case *agent.AgentSessionMessage_Event: + select { + case p.Events <- m.Event: + default: + } + + case *agent.AgentSessionMessage_AudioPlaybackClear: + if p.flushCancel != nil { + p.flushCancel() + p.flushCancel = nil + } + p.playbackRing.Reset() + + case *agent.AgentSessionMessage_AudioPlaybackFlush: + if p.flushCancel != nil { + p.flushCancel() + } + flushCtx, cancel := context.WithCancel(ctx) + p.flushCancel = cancel + go p.waitForDrainAndAck(flushCtx) + + case *agent.AgentSessionMessage_Response: + // Forward response so the TUI knows the request completed. + // Don't synthesize ConversationItemAdded — those arrive via the + // event stream already. + if m.Response != nil { + select { + case p.Responses <- m.Response: + default: + } + } + } + } +} + +func (p *AudioPipeline) sendPlaybackFinished() { + _ = p.writeMessage(&agent.AgentSessionMessage{ + Message: &agent.AgentSessionMessage_AudioPlaybackFinished{ + AudioPlaybackFinished: &agent.AgentSessionMessage_ConsoleIO_AudioPlaybackFinished{}, + }, + }) +} + +func (p *AudioPipeline) waitForDrainAndAck(ctx context.Context) { + for p.playbackRing.Available() > 0 { + select { + case <-ctx.Done(): + return + default: + } + time.Sleep(5 * time.Millisecond) + } + select { + case <-ctx.Done(): + return + default: + } + p.sendPlaybackFinished() +} + +func (p *AudioPipeline) computeMetrics(samples []int16) { + n := len(samples) + sr := float64(SampleRate) + + // Convert to float64, normalize, apply Hanning window + x := make([]float64, n) + for i, s := range samples { + v := float64(s) / 32768.0 + w := 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n))) + x[i] = v * w + } + + // Real FFT + X, nfft := rfft(x) + + // Magnitude spectrum, scaled by 2/n + mag := make([]float64, len(X)) + scale := 2.0 / float64(n) + for i, c := range X { + r, im := real(c), imag(c) + mag[i] = math.Sqrt(r*r+im*im) * scale + } + mag[0] *= 0.5 + if n%2 == 0 { + mag[len(mag)-1] *= 0.5 + } + + // Geometric frequency band edges: 20 Hz → Nyquist*0.96 + nb := NumFFTBands + nyquist := sr * 0.5 * 0.96 + logLow := math.Log(20.0) + logHigh := math.Log(nyquist) + edges := make([]float64, nb+1) + for i := 0; i <= nb; i++ { + edges[i] = math.Exp(logLow + float64(i)*(logHigh-logLow)/float64(nb)) + } + + // Bin power into frequency bands + binFreq := sr / float64(nfft) + sump := make([]float64, nb) + cnts := make([]float64, nb) + for i, m := range mag { + freq := float64(i) * binFreq + // Find band via edges (equivalent to np.digitize - 1, clipped) + band := nb - 1 + for b := 1; b <= nb; b++ { + if freq < edges[b] { + band = b - 1 + break + } + } + if band < 0 { + band = 0 + } + sump[band] += m * m + cnts[band]++ + } + + // Mean power → dB → normalize to [0,1] + const floorDB, hotDB = -70.0, -20.0 + var bands [NumFFTBands]float64 + for b := 0; b < nb; b++ { + c := cnts[b] + if c == 0 { + c = 1 + } + pmean := sump[b] / c + db := 10.0 * math.Log10(pmean + 1e-12) + lev := (db - floorDB) / (hotDB - floorDB) + lev = math.Max(0, math.Min(1, lev)) + // Power-law compression + lev = math.Max(math.Pow(lev, 0.75)-0.02, 0) + bands[b] = lev + } + + // Peak normalization (cap scale at 3x to avoid blowing up silence) + peak := 0.0 + for _, v := range bands { + if v > peak { + peak = v + } + } + normScale := math.Min(0.95/(peak+1e-6), 3.0) + for b := range bands { + bands[b] = math.Min(bands[b]*normScale, 1.0) + } + + // Exponential decay smoothing (~100ms time constant) + decay := math.Exp(-float64(n) / sr / 0.1) + + // RMS level in dB + var sum float64 + for _, s := range samples { + v := float64(s) / 32768.0 + sum += v * v + } + rms := math.Sqrt(sum / float64(n)) + db := 20 * math.Log10(rms+1e-10) + + p.mu.Lock() + for b := 0; b < nb; b++ { + if bands[b] > p.fftBands[b]*decay { + p.fftBands[b] = bands[b] + } else { + p.fftBands[b] *= decay + } + } + p.level = db + p.mu.Unlock() +} + +func SamplesToBytes(samples []int16) []byte { + buf := make([]byte, len(samples)*2) + for i, s := range samples { + binary.LittleEndian.PutUint16(buf[i*2:], uint16(s)) + } + return buf +} + +func BytesToSamples(data []byte) []int16 { + n := len(data) / 2 + samples := make([]int16, n) + for i := range samples { + samples[i] = int16(binary.LittleEndian.Uint16(data[i*2:])) + } + return samples +} diff --git a/pkg/console/ringbuffer.go b/pkg/console/ringbuffer.go new file mode 100644 index 00000000..29648bb4 --- /dev/null +++ b/pkg/console/ringbuffer.go @@ -0,0 +1,130 @@ +//go:build console + +package console + +import ( + "sync" + "sync/atomic" +) + +// RingBuffer is a SPSC ring buffer for int16 audio samples. +// When the writer outruns the reader, the reader skips ahead to avoid stale data. +type RingBuffer struct { + buf []int16 + size int + r atomic.Int64 + w atomic.Int64 + mu sync.Mutex // only for condition variable + cond *sync.Cond +} + +func NewRingBuffer(size int) *RingBuffer { + rb := &RingBuffer{ + buf: make([]int16, size), + size: size, + } + rb.cond = sync.NewCond(&rb.mu) + return rb +} + +func (rb *RingBuffer) Write(samples []int16) int { + n := len(samples) + if n > rb.size { + samples = samples[n-rb.size:] + n = rb.size + } + w := int(rb.w.Load()) + for i := 0; i < n; i++ { + rb.buf[(w+i)%rb.size] = samples[i] + } + rb.w.Add(int64(n)) + rb.cond.Signal() + return n +} + +// ReadAvailable copies up to len(out) available samples into out (non-blocking). +// Returns the number of samples actually copied. +func (rb *RingBuffer) ReadAvailable(out []int16) int { + avail := int(rb.w.Load() - rb.r.Load()) + if avail <= 0 { + return 0 + } + // If writer has lapped us, skip ahead + if avail > rb.size { + skip := int64(avail - rb.size) + rb.r.Add(skip) + avail = rb.size + } + n := len(out) + if n > avail { + n = avail + } + r := int(rb.r.Load()) + for i := 0; i < n; i++ { + out[i] = rb.buf[(r+i)%rb.size] + } + rb.r.Add(int64(n)) + return n +} + +// Read blocks until len(out) samples are available, then copies them. +func (rb *RingBuffer) Read(out []int16) bool { + needed := len(out) + copied := 0 + for copied < needed { + avail := int(rb.w.Load() - rb.r.Load()) + if avail <= 0 { + rb.mu.Lock() + for rb.w.Load()-rb.r.Load() <= 0 { + rb.cond.Wait() + } + rb.mu.Unlock() + continue + } + if avail > rb.size { + skip := int64(avail - rb.size) + rb.r.Add(skip) + avail = rb.size + } + toCopy := needed - copied + if toCopy > avail { + toCopy = avail + } + r := int(rb.r.Load()) + for i := 0; i < toCopy; i++ { + out[copied+i] = rb.buf[(r+i)%rb.size] + } + rb.r.Add(int64(toCopy)) + copied += toCopy + } + return true +} + +func (rb *RingBuffer) Available() int { + return int(rb.w.Load() - rb.r.Load()) +} + +// WaitForData blocks until samples are available in the buffer. +// Returns true if data is available, false if woken up with no data +// (e.g., after Reset or Broadcast for shutdown). +func (rb *RingBuffer) WaitForData() bool { + if rb.w.Load()-rb.r.Load() > 0 { + return true + } + rb.mu.Lock() + for rb.w.Load()-rb.r.Load() <= 0 { + rb.cond.Wait() + // After wakeup, re-check. If still empty (Reset/shutdown), return false. + if rb.w.Load()-rb.r.Load() <= 0 { + rb.mu.Unlock() + return false + } + } + rb.mu.Unlock() + return true +} + +func (rb *RingBuffer) Reset() { + rb.r.Store(rb.w.Load()) + rb.cond.Broadcast() +} diff --git a/pkg/console/tcp.go b/pkg/console/tcp.go new file mode 100644 index 00000000..6b3efbad --- /dev/null +++ b/pkg/console/tcp.go @@ -0,0 +1,90 @@ +//go:build console + +package console + +import ( + "errors" + "io" + "net" + "sync" + + "github.com/livekit/livekit-cli/v2/pkg/ipc" + + agent "github.com/livekit/protocol/livekit/agent" +) + +type TCPServer struct { + listener *ipc.Listener + conn net.Conn + mu sync.Mutex + closed bool +} + +func NewTCPServer(addr string) (*TCPServer, error) { + ln, err := ipc.Listen(addr) + if err != nil { + return nil, err + } + return &TCPServer{listener: ln}, nil +} + +func (s *TCPServer) Addr() net.Addr { + return s.listener.Addr() +} + +// Accept waits for a single agent connection; subsequent connections are rejected. +func (s *TCPServer) Accept() (net.Conn, error) { + conn, err := s.listener.Accept() + if err != nil { + return nil, err + } + + s.mu.Lock() + if s.conn != nil { + s.mu.Unlock() + conn.Close() + return nil, errors.New("console tcp: already connected") + } + s.conn = conn + s.mu.Unlock() + + // Close listener to reject further connections + s.listener.Close() + + return conn, nil +} + +// Conn returns the accepted connection, or nil if none. +func (s *TCPServer) Conn() net.Conn { + s.mu.Lock() + defer s.mu.Unlock() + return s.conn +} + +func (s *TCPServer) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.closed = true + var errs []error + if s.conn != nil { + errs = append(errs, s.conn.Close()) + s.conn = nil + } + errs = append(errs, s.listener.Close()) + return errors.Join(errs...) +} + +// WriteSessionMessage sends a protobuf-framed AgentSessionMessage. +func WriteSessionMessage(w io.Writer, msg *agent.AgentSessionMessage) error { + return ipc.WriteProto(w, msg) +} + +// ReadSessionMessage reads a protobuf-framed AgentSessionMessage. +func ReadSessionMessage(r io.Reader) (*agent.AgentSessionMessage, error) { + msg := &agent.AgentSessionMessage{} + if err := ipc.ReadProto(r, msg); err != nil { + return nil, err + } + return msg, nil +} diff --git a/pkg/ipc/ipc.go b/pkg/ipc/ipc.go new file mode 100644 index 00000000..341f7996 --- /dev/null +++ b/pkg/ipc/ipc.go @@ -0,0 +1,96 @@ +package ipc + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "sync" + + "google.golang.org/protobuf/proto" +) + +const maxMessageSize = 1 << 20 // 1MB + +// WriteProto sends a protobuf message with a 4-byte big-endian length prefix. +func WriteProto(w io.Writer, msg proto.Message) error { + data, err := proto.Marshal(msg) + if err != nil { + return fmt.Errorf("ipc: marshal: %w", err) + } + + buf := make([]byte, 4+len(data)) + binary.BigEndian.PutUint32(buf[:4], uint32(len(data))) + copy(buf[4:], data) + _, err = w.Write(buf) + return err +} + +// ReadProto reads a length-prefixed protobuf message into msg. +func ReadProto(r io.Reader, msg proto.Message) error { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return err + } + + length := binary.BigEndian.Uint32(header[:]) + if length > maxMessageSize { + return fmt.Errorf("ipc: message too large: %d bytes", length) + } + + data := make([]byte, length) + if length > 0 { + if _, err := io.ReadFull(r, data); err != nil { + return fmt.Errorf("ipc: partial message: %w", err) + } + } + + if err := proto.Unmarshal(data, msg); err != nil { + return fmt.Errorf("ipc: unmarshal: %w", err) + } + return nil +} + +// Listener wraps a net.Listener for protobuf IPC. +type Listener struct { + listener net.Listener + mu sync.Mutex + closed bool +} + +// Listen creates a new IPC listener on the given address. +func Listen(addr string) (*Listener, error) { + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("ipc: listen on %s: %w", addr, err) + } + return &Listener{listener: ln}, nil +} + +// Accept waits for a new connection. +func (l *Listener) Accept() (net.Conn, error) { + conn, err := l.listener.Accept() + if err != nil { + return nil, err + } + if tc, ok := conn.(*net.TCPConn); ok { + tc.SetNoDelay(true) + } + return conn, nil +} + +// Addr returns the listener's address. +func (l *Listener) Addr() net.Addr { + return l.listener.Addr() +} + +// Close closes the listener. +func (l *Listener) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil + } + l.closed = true + return l.listener.Close() +} From e47f79f5f0550e051669691a5b9eee39cffc4562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Thu, 2 Apr 2026 15:08:16 -0700 Subject: [PATCH 02/30] improve agent detection error messages and consistency - Add friendly error when no agent project is detected (start/dev/console) - Print detected language and directory to stderr for start/dev/console - Deduplicate console detection logic to use shared detectProject() - Add nil guard in BytesToSamples for empty data --- cmd/lk/agent.go | 6 +++--- cmd/lk/agent_run.go | 12 ++++++++++-- cmd/lk/console.go | 16 +++------------- pkg/console/pipeline.go | 5 ++++- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index e5092c71..ce7867d7 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -562,7 +562,7 @@ func createAgent(ctx context.Context, cmd *cli.Command) error { projectType, err := agentfs.DetectProjectType(os.DirFS(workingDir)) if err != nil { - return fmt.Errorf("unable to determine agent language: %w, please navigate to a directory containing an agent written in a supported language", err) + return noAgentError() } fmt.Printf("Detected agent language [%s]\n", util.Accented(string(projectType))) @@ -749,7 +749,7 @@ func deployAgent(ctx context.Context, cmd *cli.Command) error { projectType, err := agentfs.DetectProjectType(os.DirFS(workingDir)) if err != nil { - return fmt.Errorf("unable to determine agent language: %w, please make sure you are inside a directory containing an agent written in a supported language", err) + return noAgentError() } fmt.Printf("Detected agent language [%s]\n", util.Accented(string(projectType))) @@ -1493,7 +1493,7 @@ func generateAgentDockerfile(ctx context.Context, cmd *cli.Command) error { projectType, err := agentfs.DetectProjectType(os.DirFS(workingDir)) if err != nil { - return fmt.Errorf("unable to determine agent language: %w, please make sure you are inside a directory containing an agent written in a supported language", err) + return noAgentError() } fmt.Printf("Detected agent language [%s]\n", util.Accented(string(projectType))) diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index bddb8085..48b00085 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -113,10 +113,17 @@ func resolveCredentials(cmd *cli.Command, loadOpts ...loadOption) ([]string, err return args, nil } +func noAgentError() error { + return fmt.Errorf("no agent project detected in the current directory.\n\n" + + " Make sure you are running this command from an agent project directory\n" + + " containing one of: pyproject.toml, requirements.txt, uv.lock, package.json, or lock files.\n\n" + + " To get started, see: https://docs.livekit.io/agents/quickstart") +} + func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error) { projectDir, projectType, err := agentfs.DetectProjectRoot(".") if err != nil { - return "", "", "", err + return "", "", "", noAgentError() } if !projectType.IsPython() { return "", "", "", fmt.Errorf("currently only supports Python agents (detected: %s)", projectType) @@ -146,6 +153,7 @@ func runAgentStart(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } + fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) cliArgs, err := buildCLIArgs("start", cmd, quietOutput) if err != nil { @@ -204,7 +212,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { ForwardOutput: os.Stdout, } - fmt.Fprintf(os.Stderr, "Starting agent in dev mode (%s in %s)...\n", entrypoint, projectDir) + fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) // Take over signal handling from the global NotifyContext. signal.Reset(syscall.SIGINT, syscall.SIGTERM) diff --git a/cmd/lk/console.go b/cmd/lk/console.go index 8ae3b4ab..20f18b71 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -32,7 +32,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/urfave/cli/v3" - "github.com/livekit/livekit-cli/v2/pkg/agentfs" "github.com/livekit/livekit-cli/v2/pkg/console" "github.com/livekit/livekit-cli/v2/pkg/portaudio" ) @@ -134,22 +133,13 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf(os.Stderr, "Output: %s\n", outputDev.Name) } - // Detect project type, walking up parent directories if needed. - projectDir, projectType, err := agentfs.DetectProjectRoot(".") - if err != nil { - return err - } - if !projectType.IsPython() { - return fmt.Errorf("console currently only supports Python agents (detected: %s)", projectType) - } - - // Resolve entrypoint relative to project root - entrypoint, err := findEntrypoint(projectDir, cmd.String("entrypoint"), projectType) + // Detect project type and entrypoint, walking up parent directories if needed. + projectDir, projectType, entrypoint, err := detectProject(cmd) if err != nil { return err } - fmt.Fprintf(os.Stderr, "Starting agent (%s in %s)...\n", entrypoint, projectDir) + fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) agentProc, err := startAgent(AgentStartConfig{ Dir: projectDir, Entrypoint: entrypoint, diff --git a/pkg/console/pipeline.go b/pkg/console/pipeline.go index 6e43b6c5..8b6b399f 100644 --- a/pkg/console/pipeline.go +++ b/pkg/console/pipeline.go @@ -600,7 +600,10 @@ func SamplesToBytes(samples []int16) []byte { } func BytesToSamples(data []byte) []int16 { - n := len(data) / 2 + n := len(data) / 2 // truncate odd trailing byte + if n == 0 { + return nil + } samples := make([]int16, n) for i := range samples { samples[i] = int16(binary.LittleEndian.Uint16(data[i*2:])) From 5f2cf6f27be509a667d105f0108e69b4a18ee444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Thu, 2 Apr 2026 15:22:57 -0700 Subject: [PATCH 03/30] improve error messages, entrypoint detection, and IPC safety - Detect project root from entrypoint directory when --entrypoint is set - Better entrypoint-not-found error showing checked paths - Print detected language and directory to stderr for start/dev/console - Render CLI errors in red at the call site - Add maxMessageSize check to IPC WriteProto --- cmd/lk/agent_run.go | 37 +++++++++++++++++++++++++++++------ cmd/lk/console.go | 1 - cmd/lk/main.go | 4 +++- cmd/lk/simulate_subprocess.go | 17 ++++++++++++---- pkg/ipc/ipc.go | 3 +++ 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 48b00085..483c36de 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -19,6 +19,7 @@ import ( "fmt" "os" "os/signal" + "path/filepath" "sync" "syscall" @@ -114,21 +115,45 @@ func resolveCredentials(cmd *cli.Command, loadOpts ...loadOption) ([]string, err } func noAgentError() error { - return fmt.Errorf("no agent project detected in the current directory.\n\n" + - " Make sure you are running this command from an agent project directory\n" + - " containing one of: pyproject.toml, requirements.txt, uv.lock, package.json, or lock files.\n\n" + - " To get started, see: https://docs.livekit.io/agents/quickstart") + return fmt.Errorf("no agent project detected in the current directory\n\n" + + "Make sure you are running this command from an agent project directory\n" + + "containing one of: pyproject.toml, requirements.txt, uv.lock, package.json, or lock files.\n\n" + + "To get started, see: https://docs.livekit.io/agents/quickstart") } func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error) { - projectDir, projectType, err := agentfs.DetectProjectRoot(".") + explicit := cmd.String("entrypoint") + + detectFrom := "." + if explicit != "" { + absPath, err := filepath.Abs(explicit) + if err != nil { + return "", "", "", err + } + if _, err := os.Stat(absPath); err != nil { + return "", "", "", fmt.Errorf("entrypoint file not found: %s", explicit) + } + detectFrom = filepath.Dir(absPath) + } + + projectDir, projectType, err := agentfs.DetectProjectRoot(detectFrom) if err != nil { return "", "", "", noAgentError() } if !projectType.IsPython() { return "", "", "", fmt.Errorf("currently only supports Python agents (detected: %s)", projectType) } - entrypoint, err := findEntrypoint(projectDir, cmd.String("entrypoint"), projectType) + + if explicit != "" { + absPath, _ := filepath.Abs(explicit) + rel, err := filepath.Rel(projectDir, absPath) + if err != nil { + return "", "", "", fmt.Errorf("entrypoint %s is outside project root %s", explicit, projectDir) + } + return projectDir, projectType, rel, nil + } + + entrypoint, err := findEntrypoint(projectDir, "", projectType) if err != nil { return "", "", "", err } diff --git a/cmd/lk/console.go b/cmd/lk/console.go index 20f18b71..1c680450 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -133,7 +133,6 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { fmt.Fprintf(os.Stderr, "Output: %s\n", outputDev.Name) } - // Detect project type and entrypoint, walking up parent directories if needed. projectDir, projectType, entrypoint, err := detectProject(cmd) if err != nil { return err diff --git a/cmd/lk/main.go b/cmd/lk/main.go index 030af653..0892c571 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -22,6 +22,7 @@ import ( "strings" "syscall" + "github.com/charmbracelet/lipgloss" "github.com/urfave/cli/v3" "github.com/livekit/protocol/logger" @@ -90,7 +91,8 @@ func main() { checkForLegacyName() if err := app.Run(ctx, os.Args); err != nil { - fmt.Fprintln(os.Stderr, err) + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + fmt.Fprintln(os.Stderr, errStyle.Render(err.Error())) os.Exit(1) } } diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 792ec79c..4e7c9985 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -83,7 +83,7 @@ func findEntrypoint(dir, explicit string, projectType agentfs.ProjectType) (stri path = filepath.Join(dir, path) } if _, err := os.Stat(path); err != nil { - return "", fmt.Errorf("entrypoint not found: %s", explicit) + return "", fmt.Errorf("entrypoint file not found: %s", explicit) } return explicit, nil } @@ -93,7 +93,8 @@ func findEntrypoint(dir, explicit string, projectType agentfs.ProjectType) (stri } // Check project root first - if _, err := os.Stat(filepath.Join(dir, def)); err == nil { + checked := []string{filepath.Join(dir, def)} + if _, err := os.Stat(checked[0]); err == nil { return def, nil } @@ -101,12 +102,20 @@ func findEntrypoint(dir, explicit string, projectType agentfs.ProjectType) (stri cwd, _ := os.Getwd() if rel, err := filepath.Rel(dir, cwd); err == nil && rel != "." { candidate := filepath.Join(rel, def) - if _, err := os.Stat(filepath.Join(dir, candidate)); err == nil { + absCandidate := filepath.Join(dir, candidate) + checked = append(checked, absCandidate) + if _, err := os.Stat(absCandidate); err == nil { return candidate, nil } } - return "", fmt.Errorf("entrypoint not found: %s (use --entrypoint to specify)", def) + msg := "no agent entrypoint found, checked:\n" + for _, p := range checked { + msg += fmt.Sprintf(" - %s\n", p) + } + msg += "\nMake sure you are running this command from a directory containing a LiveKit agent.\n" + msg += "Use --entrypoint to specify the agent entrypoint file." + return "", fmt.Errorf("%s", msg) } // AgentStartConfig configures how to launch an agent subprocess. diff --git a/pkg/ipc/ipc.go b/pkg/ipc/ipc.go index 341f7996..e83ac2c9 100644 --- a/pkg/ipc/ipc.go +++ b/pkg/ipc/ipc.go @@ -18,6 +18,9 @@ func WriteProto(w io.Writer, msg proto.Message) error { if err != nil { return fmt.Errorf("ipc: marshal: %w", err) } + if len(data) > maxMessageSize { + return fmt.Errorf("ipc: message too large: %d bytes", len(data)) + } buf := make([]byte, 4+len(data)) binary.BigEndian.PutUint32(buf[:4], uint32(len(data))) From f2a132f6552c9c36ef3708f8fbdb71c0213ed711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Thu, 2 Apr 2026 15:37:47 -0700 Subject: [PATCH 04/30] add console build docs and PortAudio submodule check --- README.md | 24 ++++++++++++++++++++++++ pkg/portaudio/portaudio.go | 5 +++++ 2 files changed, 29 insertions(+) diff --git a/README.md b/README.md index 0350759d..eb7b8a93 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,30 @@ git clone https://github.com/livekit/livekit-cli && cd livekit-cli make install ``` +### Building with console support + +The `lk agent console` command (voice chat with an agent via mic/speakers) requires native dependencies (PortAudio, WebRTC audio processing) and is built separately with a build tag. + +This repo uses git submodules for vendored native sources. Make sure to clone with submodules: + +```shell +git clone --recurse-submodules https://github.com/livekit/livekit-cli && cd livekit-cli +``` + +Or if you've already cloned: + +```shell +git submodule update --init --recursive +``` + +Then build with the `console` tag: + +```shell +make console +``` + +This produces a `bin/lk` binary with console support enabled. + # Usage See `lk --help` for a complete list of subcommands. The `--help` flag can also be used on any subcommand for more information. diff --git a/pkg/portaudio/portaudio.go b/pkg/portaudio/portaudio.go index e1ad24ce..5670e538 100644 --- a/pkg/portaudio/portaudio.go +++ b/pkg/portaudio/portaudio.go @@ -6,6 +6,10 @@ package portaudio /* #cgo CFLAGS: -I${SRCDIR}/pa_src/include -I${SRCDIR}/pa_src/src/common -DPA_LITTLE_ENDIAN -Wno-unused-parameter -Wno-deprecated-declarations +#if !__has_include("pa_src/include/portaudio.h") +#error "PortAudio submodule not found. Run: git submodule update --init --recursive" +#else + #include "pa_src/src/common/pa_allocation.c" #include "pa_src/src/common/pa_converters.c" #include "pa_src/src/common/pa_cpuload.c" @@ -18,6 +22,7 @@ package portaudio #include "pa_src/src/common/pa_trace.c" #include "portaudio.h" +#endif */ import "C" From 78a6094b6e4cb198bf8b9c5d4066d8d50d177489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Thu, 2 Apr 2026 15:54:12 -0700 Subject: [PATCH 05/30] add blank line after user/agent messages in console TUI --- cmd/lk/console_tui.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/lk/console_tui.go b/cmd/lk/console_tui.go index e4b543b8..f506581a 100644 --- a/cmd/lk/console_tui.go +++ b/cmd/lk/console_tui.go @@ -333,7 +333,7 @@ func (m *consoleModel) updateTextMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) { printCmd := tea.Println( "\n " + lipgloss.NewStyle().Foreground(lkCyan).Render("● ") + cyanBoldStyle.Render("You") + - "\n " + text, + "\n " + text + "\n", ) req := &agent.SessionRequest{ @@ -373,7 +373,7 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd cmds = append(cmds, tea.Println( "\n "+lipgloss.NewStyle().Foreground(lkCyan).Render("● ")+ cyanBoldStyle.Render("You")+ - "\n "+text, + "\n "+text+"\n", )) } } else { @@ -430,8 +430,13 @@ func formatChatItem(item *agent.ChatContext_ChatItem) []string { "\n "+lipgloss.NewStyle().Foreground(lkGreen).Render("● ")+ greenBoldStyle.Render("Agent"), ) - for _, tl := range strings.Split(text, "\n") { - lines = append(lines, " "+tl) + parts := strings.Split(text, "\n") + for i, tl := range parts { + if i == len(parts)-1 { + lines = append(lines, " "+tl+"\n") + } else { + lines = append(lines, " "+tl) + } } return lines From 8be6798c0f8269a1aa5196785db76cadbf3f2ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Thu, 2 Apr 2026 16:05:52 -0700 Subject: [PATCH 06/30] remove duplicate -lc++ linker flags, cgo handles C++ linking automatically --- pkg/apm/bridge.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/apm/bridge.go b/pkg/apm/bridge.go index c21f33f3..ea15eb0d 100644 --- a/pkg/apm/bridge.go +++ b/pkg/apm/bridge.go @@ -7,9 +7,7 @@ package apm // #cgo linux CXXFLAGS: -DWEBRTC_LINUX -DWEBRTC_POSIX // #cgo windows CXXFLAGS: -DWEBRTC_WIN // #cgo arm64 CXXFLAGS: -DWEBRTC_HAS_NEON -DWEBRTC_ARCH_ARM64 -// #cgo darwin LDFLAGS: -lc++ -// #cgo linux LDFLAGS: -lc++ -lm -lpthread -// #cgo windows LDFLAGS: -lc++ +// #cgo linux LDFLAGS: -lm -lpthread // #include "bridge.h" import "C" From 3df43a6be2384b0fcfdb462b63b7e9694b3eb7e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Thu, 2 Apr 2026 16:10:14 -0700 Subject: [PATCH 07/30] fix console TUI rendering, add startup spinner and handoff support --- cmd/lk/console.go | 8 +++ cmd/lk/console_stub.go | 32 ++---------- cmd/lk/console_tui.go | 110 +++++++++++++++++++++++++++-------------- 3 files changed, 85 insertions(+), 65 deletions(-) diff --git a/cmd/lk/console.go b/cmd/lk/console.go index 1c680450..51d62f59 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -139,6 +139,9 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { } fmt.Fprintf(os.Stderr, "Detected %s agent (%s in %s)\n", projectType.Lang(), entrypoint, projectDir) + + // Show spinner while starting agent + stopSpinner := startSpinner("Starting agent") agentProc, err := startAgent(AgentStartConfig{ Dir: projectDir, Entrypoint: entrypoint, @@ -146,6 +149,7 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { CLIArgs: buildConsoleArgs(actualAddr, cmd.Bool("record")), }) if err != nil { + stopSpinner() return fmt.Errorf("failed to start agent: %w", err) } defer agentProc.Kill() @@ -167,11 +171,13 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { var conn net.Conn select { case res := <-acceptCh: + stopSpinner() if res.err != nil { return fmt.Errorf("agent connection: %w", res.err) } conn = res.conn case err := <-agentProc.Done(): + stopSpinner() logs := agentProc.RecentLogs(20) for _, l := range logs { fmt.Fprintln(os.Stderr, l) @@ -181,12 +187,14 @@ func runConsole(ctx context.Context, cmd *cli.Command) error { } return fmt.Errorf("agent exited before connecting") case <-time.After(60 * time.Second): + stopSpinner() logs := agentProc.RecentLogs(20) for _, l := range logs { fmt.Fprintln(os.Stderr, l) } return fmt.Errorf("timed out waiting for agent to connect") case <-ctx.Done(): + stopSpinner() return ctx.Err() } pipeline, err := console.NewPipeline(console.PipelineConfig{ diff --git a/cmd/lk/console_stub.go b/cmd/lk/console_stub.go index 917095cb..452bf181 100644 --- a/cmd/lk/console_stub.go +++ b/cmd/lk/console_stub.go @@ -5,9 +5,6 @@ package main import ( "context" "fmt" - "os" - "path/filepath" - "strings" "github.com/urfave/cli/v3" ) @@ -17,30 +14,11 @@ func init() { Name: "console", Usage: "Voice chat with an agent via mic/speakers", Action: func(ctx context.Context, cmd *cli.Command) error { - msg := "console is not included in this build.\n\n" - if isHomebrewInstall() { - msg += "\"brew install livekit-cli\" does not include console support.\n" + - "Install with console support:\n" + - " brew tap livekit/livekit && brew install lk\n" - } else { - msg += "Install with console support:\n" + - " https://docs.livekit.io/intro/basics/cli/start/\n" - } - msg += "\nOr build from source:\n" + - " go build -tags console ./cmd/lk" - return fmt.Errorf("%s", msg) + return fmt.Errorf("console is not included in this build (requires -tags console).\n\n" + + "Install with console support:\n" + + " https://docs.livekit.io/intro/basics/cli/start/\n\n" + + "Or build from source:\n" + + " go build -tags console ./cmd/lk") }, }) } - -func isHomebrewInstall() bool { - exe, err := os.Executable() - if err != nil { - return false - } - resolved, err := filepath.EvalSymlinks(exe) - if err != nil { - return false - } - return strings.Contains(resolved, "/Cellar/") -} diff --git a/cmd/lk/console_tui.go b/cmd/lk/console_tui.go index f506581a..cd4f5c70 100644 --- a/cmd/lk/console_tui.go +++ b/cmd/lk/console_tui.go @@ -20,6 +20,7 @@ import ( "context" "encoding/json" "fmt" + "os" "strings" "time" @@ -52,6 +53,27 @@ var blocks = []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"} // Braille spinner frames (matching Rich's "dots" spinner) var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +// startSpinner shows a braille spinner on stderr with the given message. +// Returns a stop function that clears the spinner line. +func startSpinner(msg string) func() { + done := make(chan struct{}) + go func() { + i := 0 + for { + select { + case <-done: + fmt.Fprintf(os.Stderr, "\r\033[K") + return + default: + fmt.Fprintf(os.Stderr, "\r %s %s", spinnerFrames[i%len(spinnerFrames)], msg) + i++ + time.Sleep(80 * time.Millisecond) + } + } + }() + return func() { close(done) } +} + type consoleTickMsg struct{} type sessionEventMsg struct{ event *agent.AgentSessionEvent } type sessionResponseMsg struct{ resp *agent.SessionResponse } @@ -388,11 +410,38 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd m.metricsText = text } } - lines := formatChatItem(item) - for _, line := range lines { - cmds = append(cmds, tea.Println(line)) + cmds = append(cmds, tea.Println(formatChatItem(item))) + } + + case *agent.AgentSessionEvent_FunctionToolsExecuted_: + ft := e.FunctionToolsExecuted + outputsByCallID := make(map[string]*agent.FunctionCallOutput) + for _, fco := range ft.FunctionCallOutputs { + outputsByCallID[fco.CallId] = fco + } + var b strings.Builder + for i, fc := range ft.FunctionCalls { + if i > 0 { + b.WriteString("\n") + } + b.WriteString("\n ") + b.WriteString("● ") + b.WriteString("function_tool: ") + b.WriteString(fc.Name) + if fco, ok := outputsByCallID[fc.CallId]; ok { + if fco.IsError { + b.WriteString("\n ") + b.WriteString(redBoldStyle.Render("✗ ")) + b.WriteString(redStyle.Render(truncateOutput(fco.Output))) + } else { + b.WriteString("\n ") + b.WriteString(greenStyle.Render("✓ ")) + b.WriteString(dimStyle.Render(summarizeOutput(fco.Output))) + } } } + b.WriteString("\n") + cmds = append(cmds, tea.Println(b.String())) case *agent.AgentSessionEvent_Error_: cmds = append(cmds, tea.Println( @@ -403,16 +452,12 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd return cmds } -// formatChatItem returns lines to print for a conversation item, -// matching the old Python console format. -func formatChatItem(item *agent.ChatContext_ChatItem) []string { +func formatChatItem(item *agent.ChatContext_ChatItem) string { switch i := item.Item.(type) { case *agent.ChatContext_ChatItem_Message: msg := i.Message - // User messages are printed from UserInputTranscribed (final) to avoid - // ordering issues with partial transcripts. if msg.Role == agent.ChatRole_USER { - return nil + return "" } var textParts []string for _, c := range msg.Content { @@ -422,41 +467,30 @@ func formatChatItem(item *agent.ChatContext_ChatItem) []string { } text := strings.Join(textParts, "") if text == "" { - return nil - } - - var lines []string - lines = append(lines, - "\n "+lipgloss.NewStyle().Foreground(lkGreen).Render("● ")+ - greenBoldStyle.Render("Agent"), - ) - parts := strings.Split(text, "\n") - for i, tl := range parts { - if i == len(parts)-1 { - lines = append(lines, " "+tl+"\n") - } else { - lines = append(lines, " "+tl) - } + return "" } - return lines - case *agent.ChatContext_ChatItem_FunctionCall: - return []string{ - " " + lipgloss.NewStyle().Foreground(lkCyan).Render("➜ ") + - cyanBoldStyle.Render(i.FunctionCall.Name), + var b strings.Builder + b.WriteString("\n ") + b.WriteString(lipgloss.NewStyle().Foreground(lkGreen).Render("● ")) + b.WriteString(greenBoldStyle.Render("Agent")) + for _, tl := range strings.Split(text, "\n") { + b.WriteString("\n ") + b.WriteString(tl) } + b.WriteString("\n") + return b.String() - case *agent.ChatContext_ChatItem_FunctionCallOutput: - if i.FunctionCallOutput.IsError { - return []string{ - " " + redBoldStyle.Render("✗ ") + redStyle.Render(truncateOutput(i.FunctionCallOutput.Output)), - } - } - return []string{ - " " + greenStyle.Render("✓ ") + dimStyle.Render(summarizeOutput(i.FunctionCallOutput.Output)), + case *agent.ChatContext_ChatItem_AgentHandoff: + h := i.AgentHandoff + old := "" + 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 nil + return "" } // ────────────────────────────────────────────────────────────────── From 61a5803b6484b11c3cdfe830b556b90bcc8b68e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Thu, 2 Apr 2026 20:59:23 -0700 Subject: [PATCH 08/30] remove local replace directives, use published server-sdk-go Switch to the server-sdk-go branch that exports CreateSourceTarball, MultipartUpload, and AgentSimulationClient. Fix spinnerFrames reference across build tags. --- cmd/lk/simulate_tui.go | 8 +++++--- go.mod | 6 +----- go.sum | 4 ++++ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 5d9ba2ef..2fa2a2b4 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -45,6 +45,8 @@ var ( boldStyle = lipgloss.NewStyle().Bold(true) reverseStyle = lipgloss.NewStyle().Reverse(true) cyanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + + simSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} ) // --- Message types --- @@ -318,8 +320,8 @@ func (m *simulateModel) runSetup() tea.Cmd { sourceDir, _ := os.Getwd() var buf bytes.Buffer - if err := cloudagents.CreateSourceZip(os.DirFS(sourceDir), nil, &buf); err != nil { - return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to create source zip: %w", err), agent: agent, runID: runID} + if err := cloudagents.CreateSourceTarball(os.DirFS(sourceDir), nil, &buf); err != nil { + return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to create source archive: %w", err), agent: agent, runID: runID} } if err := cloudagents.MultipartUpload(presigned.Url, presigned.Values, &buf); err != nil { return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to upload source: %w", err), agent: agent, runID: runID} @@ -609,7 +611,7 @@ func (m *simulateModel) viewSetup() string { } func (m *simulateModel) spinner() string { - return yellowStyle.Render(spinnerFrames[m.spinnerIdx%len(spinnerFrames)]) + return yellowStyle.Render(simSpinnerFrames[m.spinnerIdx%len(simSpinnerFrames)]) } var quoteStyleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("237")) diff --git a/go.mod b/go.mod index 97b1a731..92540a01 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/go-task/task/v3 v3.44.1 github.com/joho/godotenv v1.5.1 github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3 - github.com/livekit/server-sdk-go/v2 v2.16.1 + github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403035727-4e7182404d8d github.com/mattn/go-isatty v0.0.20 github.com/moby/patternmatcher v0.6.0 github.com/modelcontextprotocol/go-sdk v1.4.0 @@ -208,7 +208,3 @@ require ( mvdan.cc/sh/v3 v3.12.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect ) - -replace github.com/livekit/protocol => ../protocol - -replace github.com/livekit/server-sdk-go/v2 => ../server-sdk-go diff --git a/go.sum b/go.sum index 07bfc50c..906597a6 100644 --- a/go.sum +++ b/go.sum @@ -273,8 +273,12 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260309115634-0e2e24b36ee8 h1:coWig9fKxdb/nwOaIoGUUAogso12GblAJh/9SA9hcxk= github.com/livekit/mediatransportutil v0.0.0-20260309115634-0e2e24b36ee8/go.mod h1:RCd46PT+6sEztld6XpkCrG1xskb0u3SqxIjy4G897Ss= +github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3 h1:wmg/PTPHbIXpKoQvoLcqdJS0K8KpKGf34Xe+YPOPTm8= +github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0= github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= +github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403035727-4e7182404d8d h1:a5yx50VcM/mWa2TimoV+eRdBWifdj0I2OqI6HDhQ/FQ= +github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403035727-4e7182404d8d/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.16.1 h1:j5UwkdA48xTlGs0Hcm1Q3sSAcxBorntQjiewDNMsqlo= From d10d751b236b2fc41598b2e85fac64253ff054d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Thu, 2 Apr 2026 21:01:29 -0700 Subject: [PATCH 09/30] update server-sdk-go to include complete AgentSimulationClient --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 92540a01..261139cb 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/go-task/task/v3 v3.44.1 github.com/joho/godotenv v1.5.1 github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3 - github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403035727-4e7182404d8d + github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403040052-6244a381c6d9 github.com/mattn/go-isatty v0.0.20 github.com/moby/patternmatcher v0.6.0 github.com/modelcontextprotocol/go-sdk v1.4.0 diff --git a/go.sum b/go.sum index 906597a6..e29b8f04 100644 --- a/go.sum +++ b/go.sum @@ -277,8 +277,8 @@ github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3 h1:wmg/PTPHbIX github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0= github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= -github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403035727-4e7182404d8d h1:a5yx50VcM/mWa2TimoV+eRdBWifdj0I2OqI6HDhQ/FQ= -github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403035727-4e7182404d8d/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ= +github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403040052-6244a381c6d9 h1:nA2mf1zQBIrFRn6GPI1SjXSdrUyep/EFu6bLVGRJ71g= +github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403040052-6244a381c6d9/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.16.1 h1:j5UwkdA48xTlGs0Hcm1Q3sSAcxBorntQjiewDNMsqlo= From e90f90499372477fb8f247d2d59ebd172e61d82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Fri, 3 Apr 2026 09:31:17 -0700 Subject: [PATCH 10/30] update server-sdk-go to merged main with SimulationAdmin grant --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 261139cb..8355c147 100644 --- a/go.mod +++ b/go.mod @@ -15,8 +15,8 @@ require ( github.com/go-logr/logr v1.4.3 github.com/go-task/task/v3 v3.44.1 github.com/joho/godotenv v1.5.1 - github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3 - github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403040052-6244a381c6d9 + github.com/livekit/protocol v1.45.2-0.20260403151849-8a360e8d0221 + github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403163006-dbb96cc2c416 github.com/mattn/go-isatty v0.0.20 github.com/moby/patternmatcher v0.6.0 github.com/modelcontextprotocol/go-sdk v1.4.0 @@ -90,7 +90,7 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.16.2 // indirect - github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-task/template v0.2.0 // indirect diff --git a/go.sum b/go.sum index e29b8f04..1a350ea5 100644 --- a/go.sum +++ b/go.sum @@ -194,8 +194,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= -github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= -github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -273,12 +273,12 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260309115634-0e2e24b36ee8 h1:coWig9fKxdb/nwOaIoGUUAogso12GblAJh/9SA9hcxk= github.com/livekit/mediatransportutil v0.0.0-20260309115634-0e2e24b36ee8/go.mod h1:RCd46PT+6sEztld6XpkCrG1xskb0u3SqxIjy4G897Ss= -github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3 h1:wmg/PTPHbIXpKoQvoLcqdJS0K8KpKGf34Xe+YPOPTm8= -github.com/livekit/protocol v1.45.2-0.20260325065350-7558ba4c26d3/go.mod h1:63AUi0vQak6Y6gPqSBHLc+ExYTUwEqF/m4b2IRW1iO0= +github.com/livekit/protocol v1.45.2-0.20260403151849-8a360e8d0221 h1:loe7h+z1kOu/ojprFTYSZBbJVly7gdZgQ/ewElGeLPo= +github.com/livekit/protocol v1.45.2-0.20260403151849-8a360e8d0221/go.mod h1:e6QdWDkfot+M2nRh0eitJUS0ZLuwvKCsfiz2pWWSG3s= github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= -github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403040052-6244a381c6d9 h1:nA2mf1zQBIrFRn6GPI1SjXSdrUyep/EFu6bLVGRJ71g= -github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403040052-6244a381c6d9/go.mod h1:oQbYijcbPzfjBAOzoq7tz9Ktqur8JNRCd923VP8xOQQ= +github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403163006-dbb96cc2c416 h1:QrNZ7Klt9wb/w/wS7o+Sgb3qWEomFRiUxeKTfMZss7w= +github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403163006-dbb96cc2c416/go.mod h1:VNVkPtV8HO3MOe5X13ODK20Mvxd5VQTGgKNDSA+KE6Q= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.16.1 h1:j5UwkdA48xTlGs0Hcm1Q3sSAcxBorntQjiewDNMsqlo= From 661d54a23c78e526393984cd4bee6fed61d3881e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 19 Apr 2026 22:50:33 -0700 Subject: [PATCH 11/30] Fix agent watcher exitCh race on restart --- cmd/lk/agent_watcher.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index 69af9a3a..b20bc37d 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -182,6 +182,8 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { var debounceTimer *time.Timer var debounceCh <-chan time.Time + exitCh := aw.agent.exitCh + var changedFile string for { select { @@ -206,6 +208,12 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { _ = aw.watcher.Add(event.Name) } } + // Track the file that triggered the change + if rel, err := filepath.Rel(aw.config.Dir, event.Name); err == nil { + changedFile = rel + } else { + changedFile = event.Name + } // Start or reset debounce timer if debounceTimer == nil { debounceTimer = time.NewTimer(aw.debounce) @@ -217,9 +225,12 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { case <-debounceCh: debounceTimer = nil debounceCh = nil + fmt.Fprintf(os.Stderr, "File changed: %s\n", changedFile) if err := aw.restart(); err != nil { fmt.Fprintf(os.Stderr, "Failed to restart agent: %v\n", err) fmt.Fprintln(os.Stderr, "Waiting for file changes...") + } else { + exitCh = aw.agent.exitCh } case err, ok := <-aw.watcher.Errors: @@ -228,15 +239,16 @@ func (aw *agentWatcher) Run(done <-chan struct{}) error { } fmt.Fprintf(os.Stderr, "Watcher error: %v\n", err) - case <-aw.agent.exitCh: - // Agent crashed — wait for file changes to restart - fmt.Fprintln(os.Stderr, "Agent exited. Waiting for file changes to restart...") - // Drain any pending debounce + case <-exitCh: + // Nil the channel so this case won't fire again (nil channels block forever) + exitCh = nil + // Drain any pending debounce — don't restart immediately if debounceTimer != nil { debounceTimer.Stop() debounceTimer = nil debounceCh = nil } + fmt.Fprintln(os.Stderr, "Agent exited. Waiting for file changes to restart...") } } } From b69df701910eb182b77f46c32884666d59f10a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 19 Apr 2026 22:52:26 -0700 Subject: [PATCH 12/30] Remove default num-simulations, let server decide --- cmd/lk/simulate.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 18ec2d1a..b6b34579 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -52,7 +52,6 @@ var simulateCommand = &cli.Command{ Name: "num-simulations", Aliases: []string{"n"}, Usage: "Number of scenarios to generate", - Value: 5, }, &cli.StringFlag{ Name: "description", From 36373542b49acc55f72aad397a4a24f3c836a2e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Mon, 20 Apr 2026 19:06:45 -0700 Subject: [PATCH 13/30] Show 'Generating scenarios' without count when num-simulations not specified --- cmd/lk/simulate_tui.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 2fa2a2b4..b27069cb 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -586,7 +586,11 @@ func (m *simulateModel) viewSetup() string { // Show generation progress after setup completes if m.setupDone && m.err == nil { elapsed := time.Since(m.genStart).Truncate(time.Second) - b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("●"), m.numSimulations, m.spinner(), dimStyle.Render(elapsed.String()))) + if m.numSimulations > 0 { + b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("●"), m.numSimulations, m.spinner(), dimStyle.Render(elapsed.String()))) + } else { + b.WriteString(fmt.Sprintf(" %s Generating scenarios %s %s\n", yellowStyle.Render("●"), m.spinner(), dimStyle.Render(elapsed.String()))) + } } if m.err != nil { From d98d8965192813e1420f2aad8ecf01b980d150b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Fri, 1 May 2026 09:29:51 -0700 Subject: [PATCH 14/30] Improve agent simulate TUI setup and log display --- cmd/lk/agent_run.go | 1 + cmd/lk/agent_watcher.go | 4 +- cmd/lk/simulate_tui.go | 604 ++++++++++++++++++++++++---------------- 3 files changed, 369 insertions(+), 240 deletions(-) diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 483c36de..13119ac7 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -225,6 +225,7 @@ func runAgentDev(ctx context.Context, cmd *cli.Command) error { if err != nil { return err } + cliArgs = append(cliArgs, "--dev") if cmd.String("log-level") == "" { cliArgs = append(cliArgs, "--log-level", "DEBUG") } diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index b20bc37d..944cb00d 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -86,8 +86,8 @@ func newAgentWatcher(config AgentStartConfig) (*agentWatcher, error) { return nil, err } - // Append --reload-addr to CLI args so the Python process connects back - config.CLIArgs = append(config.CLIArgs, "--reload-addr", rs.addr()) + // Append --dev and --reload-addr to CLI args so the Python process connects back + config.CLIArgs = append(config.CLIArgs, "--dev", "--reload-addr", rs.addr()) return &agentWatcher{ config: config, diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index b27069cb..5c534ce1 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -18,7 +18,6 @@ import ( "bytes" "context" "fmt" - "math/rand" "os" "strings" "time" @@ -58,7 +57,6 @@ type simulationRunMsg struct { type pollTickMsg struct{} type spinnerTickMsg struct{} -type glowTickMsg struct{} type subprocessExitMsg struct { err error @@ -66,6 +64,10 @@ type subprocessExitMsg struct { // --- Filter --- +const ( + agentRegisterTimeout = 20 * time.Second +) + const ( filterAll = iota filterFailed @@ -103,23 +105,24 @@ type simulateModel struct { client *lksdk.AgentSimulationClient runID string agent *AgentProcess + setupCtx context.Context setupCancel context.CancelFunc // Setup phase - steps []step - setupDone bool + steps []step + currentStep int + setupDone bool + stepStart time.Time // Run phase run *livekit.SimulationRun runFinished bool numSimulations int32 startTime time.Time + endTime time.Time genStart time.Time - quoteIdx int - quoteTick int spinnerIdx int - glowIdx int filter int cursor int @@ -128,69 +131,19 @@ type simulateModel struct { showLogs bool showDescription bool + matrix matrixRain + matrixSavedShowLogs bool + width int height int err error } -type quote struct { - text string - glow bool // iconic quotes get a subtle glow - weight int // higher = more likely to appear -} - -var simulationQuotes = []quote{ - // Iconic — glow sweep, high weight - {"There is no spoon.", true, 5}, // Spoon Boy — The Matrix - {"What is real? How do you define real?", true, 5}, // Morpheus — The Matrix - {"Wake up, Neo.", true, 5}, // Trinity — The Matrix - {"Free your mind.", true, 4}, // Morpheus — The Matrix - {"Welcome to the real world.", true, 4}, // Morpheus — The Matrix - {"Shall we play a game?", true, 4}, // WOPR — WarGames - {"Open the pod bay doors, HAL.", true, 4}, // Dave — 2001: A Space Odyssey - {"The Matrix is everywhere. It is all around us.", true, 3}, // Morpheus — The Matrix - // Well-known — no glow, medium weight - {"Do not try and bend the spoon. That's impossible.", false, 3}, // Spoon Boy — The Matrix - {"The only winning move is not to play.", false, 3}, // WarGames - {"These violent delights have violent ends.", false, 3}, // Westworld - {"I think, therefore I am.", false, 3}, // René Descartes - {"Unfortunately, no one can be told what the Matrix is.", false, 2}, // Morpheus — The Matrix - {"Ever had that feeling where you're not sure if you're awake or still dreaming?", false, 2}, // Neo — The Matrix - {"I can only show you the door. You're the one that has to walk through it.", false, 2}, // Morpheus — The Matrix - {"Remember, all I'm offering is the truth. Nothing more.", false, 2}, // Morpheus — The Matrix - // Niche — low weight - {"I don't like the idea that I'm not in control of my life.", false, 1}, // Neo — The Matrix - {"Choice is an illusion created between those with power and those without.", false, 1}, // Merovingian — The Matrix Reloaded - {"The odds that we are in base reality is one in billions.", false, 1}, // Elon Musk - {"The world, then, is a radical illusion.", false, 1}, // Jean Baudrillard - {"That's all it is. Information.", false, 1}, // Ghost in the Shell - {"Not one single bit of it is real.", false, 1}, // The Metamorphosis of Prime Intellect - {"I wish I had a good argument against it.", false, 1}, // Neil deGrasse Tyson - // Playful - {"Warming up the neural pathways...", false, 1}, - {"Reticulating splines...", false, 1}, // SimCity - {"Generating plausible humans...", false, 1}, - {"Convincing the AI to cooperate...", false, 2}, - {"Teaching robots to small talk...", false, 1}, -} - -// weightedQuotePool builds a flat slice with quotes repeated by weight for random selection. -var weightedQuotePool = func() []int { - var pool []int - for i, q := range simulationQuotes { - for range q.weight { - pool = append(pool, i) - } - } - return pool -}() - func newSimulateModel(config *simulateConfig) *simulateModel { return &simulateModel{ config: config, client: config.client, numSimulations: config.numSimulations, - quoteIdx: weightedQuotePool[rand.Intn(len(weightedQuotePool))], width: 80, height: 24, } @@ -198,12 +151,26 @@ func newSimulateModel(config *simulateConfig) *simulateModel { // --- Setup messages --- -type setupStepMsg struct { - stepIdx int - elapsed []time.Duration // elapsed time per completed step - err error - runID string - agent *AgentProcess +type agentStartedMsg struct { + agent *AgentProcess + err error +} + +type agentReadyMsg struct { + elapsed time.Duration + err error +} + +type simulationCreatedMsg struct { + runID string + presigned *livekit.PresignedPostRequest + elapsed time.Duration + err error +} + +type sourceUploadedMsg struct { + elapsed time.Duration + err error } func (m *simulateModel) Init() tea.Cmd { @@ -211,14 +178,12 @@ func (m *simulateModel) Init() tea.Cmd { m.runSetup(), tickCmd(), spinnerTickCmd(), - glowTickCmd(), ) } func (m *simulateModel) runSetup() tea.Cmd { c := m.config - // Determine which steps to show m.steps = []step{ {label: "Starting agent", status: "running"}, {label: "Creating simulation", status: "pending"}, @@ -228,13 +193,38 @@ func (m *simulateModel) runSetup() tea.Cmd { } ctx, cancel := context.WithCancel(c.ctx) + m.setupCtx = ctx m.setupCancel = cancel + m.stepStart = time.Now() - return func() tea.Msg { - var elapsed []time.Duration - stepStart := time.Now() + return m.startAgentCmd() +} + +func (m *simulateModel) failSetupStep(err error) { + m.steps[m.currentStep].status = "failed" + m.err = err + m.setupDone = true + m.runFinished = true +} + +func (m *simulateModel) advanceSetupStep(elapsed time.Duration) { + m.steps[m.currentStep].status = "done" + m.steps[m.currentStep].elapsed = elapsed + m.currentStep++ + m.steps[m.currentStep].status = "running" + m.stepStart = time.Now() +} - // Step 0: Start agent & wait for registration +func (m *simulateModel) completeSetup(elapsed time.Duration) { + m.steps[m.currentStep].status = "done" + m.steps[m.currentStep].elapsed = elapsed + m.setupDone = true + m.genStart = time.Now() +} + +func (m *simulateModel) startAgentCmd() tea.Cmd { + c := m.config + return func() tea.Msg { agent, err := startAgent(AgentStartConfig{ Dir: c.projectDir, Entrypoint: c.entrypoint, @@ -244,6 +234,7 @@ func (m *simulateModel) runSetup() tea.Cmd { "--url", c.pc.URL, "--api-key", c.pc.APIKey, "--api-secret", c.pc.APISecret, + "--log-format", "colored", }, Env: []string{ "LIVEKIT_AGENT_NAME=" + c.agentName, @@ -253,29 +244,36 @@ func (m *simulateModel) runSetup() tea.Cmd { }, ReadySignal: "registered worker", }) - if err != nil { - return setupStepMsg{stepIdx: 0, err: fmt.Errorf("failed to start agent: %w", err)} - } + return agentStartedMsg{agent: agent, err: err} + } +} - // Wait for agent ready - timeout := time.NewTimer(10 * time.Second) +func (m *simulateModel) waitAgentReadyCmd() tea.Cmd { + stepStart := m.stepStart + return func() tea.Msg { + timeout := time.NewTimer(agentRegisterTimeout) defer timeout.Stop() select { - case <-agent.Ready(): - case err := <-agent.Done(): + case <-m.agent.Ready(): + return agentReadyMsg{elapsed: time.Since(stepStart)} + case err := <-m.agent.Done(): if err != nil { - return setupStepMsg{stepIdx: 0, err: fmt.Errorf("agent exited before registering: %w", err), agent: agent} + return agentReadyMsg{err: fmt.Errorf("agent exited before registering: %w", err)} } - return setupStepMsg{stepIdx: 0, err: fmt.Errorf("agent exited before registering"), agent: agent} + return agentReadyMsg{err: fmt.Errorf("agent exited before registering")} case <-timeout.C: - return setupStepMsg{stepIdx: 0, err: fmt.Errorf("timed out waiting for agent to register (10s)"), agent: agent} - case <-ctx.Done(): - return setupStepMsg{stepIdx: 0, err: ctx.Err(), agent: agent} + m.agent.Kill() + return agentReadyMsg{err: fmt.Errorf("timed out waiting for agent to register (%s)", agentRegisterTimeout)} + case <-m.setupCtx.Done(): + return agentReadyMsg{err: m.setupCtx.Err()} } - elapsed = append(elapsed, time.Since(stepStart)) - stepStart = time.Now() + } +} - // Step 1: Create simulation run +func (m *simulateModel) createSimulationCmd() tea.Cmd { + c := m.config + return func() tea.Msg { + start := time.Now() req := &livekit.SimulationRun_Create_Request{ AgentName: c.agentName, AgentDescription: c.description, @@ -303,40 +301,40 @@ func (m *simulateModel) runSetup() tea.Cmd { } } - resp, err := c.client.CreateSimulationRun(ctx, req) + resp, err := c.client.CreateSimulationRun(m.setupCtx, req) if err != nil { - return setupStepMsg{stepIdx: 1, err: fmt.Errorf("failed to create simulation: %w", err), agent: agent} + return simulationCreatedMsg{err: fmt.Errorf("failed to create simulation: %w", err)} } - elapsed = append(elapsed, time.Since(stepStart)) - stepStart = time.Now() - runID := resp.SimulationRunId - - // Step 2: Upload source (if needed) - if c.mode == modeGenerateFromSource { - presigned := resp.PresignedPostRequest - if presigned == nil { - return setupStepMsg{stepIdx: 2, err: fmt.Errorf("server did not return upload URL"), agent: agent, runID: runID} - } + return simulationCreatedMsg{ + runID: resp.SimulationRunId, + presigned: resp.PresignedPostRequest, + elapsed: time.Since(start), + } + } +} - sourceDir, _ := os.Getwd() - var buf bytes.Buffer - if err := cloudagents.CreateSourceTarball(os.DirFS(sourceDir), nil, &buf); err != nil { - return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to create source archive: %w", err), agent: agent, runID: runID} - } - if err := cloudagents.MultipartUpload(presigned.Url, presigned.Values, &buf); err != nil { - return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to upload source: %w", err), agent: agent, runID: runID} - } - if _, err := c.client.ConfirmSimulationSourceUpload(ctx, &livekit.SimulationRun_ConfirmSourceUpload_Request{ - SimulationRunId: runID, - }); err != nil { - return setupStepMsg{stepIdx: 2, err: fmt.Errorf("failed to confirm upload: %w", err), agent: agent, runID: runID} - } - elapsed = append(elapsed, time.Since(stepStart)) +func (m *simulateModel) uploadSourceCmd(presigned *livekit.PresignedPostRequest) tea.Cmd { + c := m.config + return func() tea.Msg { + start := time.Now() + if presigned == nil { + return sourceUploadedMsg{err: fmt.Errorf("server did not return upload URL")} } - // All done - lastStep := len(m.steps) - 1 - return setupStepMsg{stepIdx: lastStep, elapsed: elapsed, agent: agent, runID: runID} + sourceDir, _ := os.Getwd() + var buf bytes.Buffer + if err := cloudagents.CreateSourceTarball(os.DirFS(sourceDir), nil, &buf); err != nil { + return sourceUploadedMsg{err: fmt.Errorf("failed to create source archive: %w", err)} + } + if err := cloudagents.MultipartUpload(presigned.Url, presigned.Values, &buf); err != nil { + return sourceUploadedMsg{err: fmt.Errorf("failed to upload source: %w", err)} + } + if _, err := c.client.ConfirmSimulationSourceUpload(m.setupCtx, &livekit.SimulationRun_ConfirmSourceUpload_Request{ + SimulationRunId: m.runID, + }); err != nil { + return sourceUploadedMsg{err: fmt.Errorf("failed to confirm upload: %w", err)} + } + return sourceUploadedMsg{elapsed: time.Since(start)} } } @@ -352,12 +350,6 @@ func spinnerTickCmd() tea.Cmd { }) } -func glowTickCmd() tea.Cmd { - return tea.Tick(40*time.Millisecond, func(t time.Time) tea.Msg { - return glowTickMsg{} - }) -} - func (m *simulateModel) pollSimulation() tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -387,42 +379,47 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - - case setupStepMsg: - if msg.agent != nil { - m.agent = msg.agent + if m.matrix.active { + m.matrix.active = false + m.showLogs = m.matrixSavedShowLogs } - if msg.runID != "" { - m.runID = msg.runID + + case agentStartedMsg: + if msg.err != nil { + m.failSetupStep(fmt.Errorf("failed to start agent: %w", msg.err)) + return m, nil } + m.agent = msg.agent + return m, m.waitAgentReadyCmd() + + case agentReadyMsg: if msg.err != nil { - // Mark current step as failed - if msg.stepIdx < len(m.steps) { - m.steps[msg.stepIdx].status = "failed" - } - m.err = msg.err - m.setupDone = true - m.runFinished = true + m.failSetupStep(msg.err) return m, nil } - // Mark all steps up to and including this one as done - for i := 0; i <= msg.stepIdx && i < len(m.steps); i++ { - m.steps[i].status = "done" - if i < len(msg.elapsed) { - m.steps[i].elapsed = msg.elapsed[i] - } + m.advanceSetupStep(msg.elapsed) + return m, m.createSimulationCmd() + + case simulationCreatedMsg: + if msg.err != nil { + m.failSetupStep(msg.err) + return m, nil } - // If all steps are done, start polling - if msg.stepIdx >= len(m.steps)-1 { - m.setupDone = true - m.genStart = time.Now() - return m, tea.Batch(m.pollSimulation(), m.waitSubprocess()) + m.runID = msg.runID + if m.config.mode == modeGenerateFromSource { + m.advanceSetupStep(msg.elapsed) + return m, m.uploadSourceCmd(msg.presigned) } - // Mark next step as running - if msg.stepIdx+1 < len(m.steps) { - m.steps[msg.stepIdx+1].status = "running" + m.completeSetup(msg.elapsed) + return m, tea.Batch(m.pollSimulation(), m.waitSubprocess()) + + case sourceUploadedMsg: + if msg.err != nil { + m.failSetupStep(msg.err) + return m, nil } - return m, nil + m.completeSetup(msg.elapsed) + return m, tea.Batch(m.pollSimulation(), m.waitSubprocess()) case simulationRunMsg: if msg.err == nil && msg.run != nil { @@ -433,6 +430,9 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.run.Status == livekit.SimulationRun_STATUS_COMPLETED || msg.run.Status == livekit.SimulationRun_STATUS_FAILED || msg.run.Status == livekit.SimulationRun_STATUS_CANCELLED { + if !m.runFinished { + m.endTime = time.Now() + } m.runFinished = true } } @@ -441,15 +441,18 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinnerIdx++ return m, spinnerTickCmd() - case glowTickMsg: - m.glowIdx++ - return m, glowTickCmd() + case matrixTickMsg: + if !m.matrix.active { + return m, nil + } + m.matrix.step() + if !m.matrix.active { + m.showLogs = m.matrixSavedShowLogs + return m, nil + } + return m, matrixTickCmd() case pollTickMsg: - m.quoteTick++ - if m.quoteTick%60 == 0 { - m.quoteIdx = weightedQuotePool[rand.Intn(len(weightedQuotePool))] - } var cmds []tea.Cmd if m.setupDone && !m.runFinished { cmds = append(cmds, m.pollSimulation()) @@ -467,12 +470,63 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { + key := msg.String() + if m.matrix.active { + // Any keypress cancels rain so the user regains control immediately. + // Pressing 'm' just cancels; every other key falls through to normal + // handling so it can do its usual thing on the same press. + m.matrix.active = false + m.showLogs = m.matrixSavedShowLogs + if key == "m" { + return m, nil + } + } + switch key { case "ctrl+c": if m.setupCancel != nil { m.setupCancel() } return m, tea.Quit + case "m": + if m.detailJobID != "" || m.run == nil || !m.setupDone || len(m.run.Jobs) == 0 || m.width < 10 { + return m, nil + } + rows := m.buildMatrixRows() + width := 0 + for _, r := range rows { + if n := len(r.text); n > width { + width = n + } + } + if width < 1 || len(rows) < 3 { + return m, nil + } + if width > m.width { + width = m.width + } + // Skip columns that are always blank across every row (rain over empty + // air looks noisy) and columns carrying a status icon (the status must + // stay fully visible). + skip := make([]bool, width) + for col := 0; col < width; col++ { + empty := true + for _, r := range rows { + if col < len(r.text) && r.text[col] != ' ' { + empty = false + break + } + } + skip[col] = empty + } + for _, r := range rows { + if r.iconCol >= 0 && r.iconCol < width { + skip[r.iconCol] = true + } + } + m.matrixSavedShowLogs = m.showLogs + m.showLogs = false + m.matrix.start(width, len(rows), skip) + return m, matrixTickCmd() case "ctrl+l": m.showLogs = !m.showLogs case "d": @@ -586,11 +640,7 @@ func (m *simulateModel) viewSetup() string { // Show generation progress after setup completes if m.setupDone && m.err == nil { elapsed := time.Since(m.genStart).Truncate(time.Second) - if m.numSimulations > 0 { - b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("●"), m.numSimulations, m.spinner(), dimStyle.Render(elapsed.String()))) - } else { - b.WriteString(fmt.Sprintf(" %s Generating scenarios %s %s\n", yellowStyle.Render("●"), m.spinner(), dimStyle.Render(elapsed.String()))) - } + b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("●"), m.numSimulations, m.spinner(), dimStyle.Render(elapsed.String()))) } if m.err != nil { @@ -605,52 +655,19 @@ func (m *simulateModel) viewSetup() string { b.WriteString("\n") } else { b.WriteString("\n") - if m.showLogs { + if m.agent != nil { b.WriteString(m.renderLogs()) } - b.WriteString(m.quoteAboveHint(" Ctrl+L logs")) - b.WriteString("\n") } return b.String() } -func (m *simulateModel) spinner() string { - return yellowStyle.Render(simSpinnerFrames[m.spinnerIdx%len(simSpinnerFrames)]) +func (m *simulateModel) hasLogs() bool { + return m.agent != nil && m.agent.LogCount() > 0 } -var quoteStyleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("237")) - -// glowShades are brightness levels for the sweep effect (dark → bright → dark) -var glowShades = []lipgloss.Color{"237", "239", "242", "245", "248", "245", "242", "239", "237"} - -func (m *simulateModel) quote() string { - q := simulationQuotes[m.quoteIdx] - if !q.glow { - return quoteStyleDim.Render(q.text) - } - // Sweep a bright spot across the text, then stay dark for a long pause - runes := []rune(q.text) - sweepLen := len(runes) + len(glowShades) - cycleLen := sweepLen + 250 // ~10s pause at 40ms tick - center := m.glowIdx % cycleLen - if center >= sweepLen { - // In the pause phase — render all dim - return quoteStyleDim.Render(q.text) - } - var b strings.Builder - for i, r := range runes { - dist := center - i - if dist >= 0 && dist < len(glowShades) { - style := lipgloss.NewStyle().Foreground(glowShades[dist]) - if dist >= 2 && dist <= 6 { // italic only for the brightest chars - style = style.Italic(true) - } - b.WriteString(style.Render(string(r))) - } else { - b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("237")).Render(string(r))) - } - } - return b.String() +func (m *simulateModel) spinner() string { + return yellowStyle.Render(simSpinnerFrames[m.spinnerIdx%len(simSpinnerFrames)]) } func (m *simulateModel) renderSteps() string { @@ -664,11 +681,12 @@ func (m *simulateModel) renderSteps() string { } b.WriteString(fmt.Sprintf(" %s %s%s\n", greenStyle.Render("✓"), s.label, elapsed)) case "running": - b.WriteString(fmt.Sprintf(" %s %s\n", yellowStyle.Render("●"), s.label)) + elapsed := time.Since(m.stepStart).Truncate(time.Second) + b.WriteString(fmt.Sprintf(" %s %s %s %s\n", yellowStyle.Render("●"), s.label, m.spinner(), dimStyle.Render(elapsed.String()))) case "failed": b.WriteString(fmt.Sprintf(" %s %s\n", redStyle.Render("✗"), s.label)) default: - b.WriteString(fmt.Sprintf(" %s %s\n", dimStyle.Render("○"), s.label)) + b.WriteString(fmt.Sprintf(" %s %s\n", dimStyle.Render("–"), s.label)) } } return b.String() @@ -700,7 +718,11 @@ func (m *simulateModel) viewFailed() string { if m.showLogs { b.WriteString(m.renderLogs()) } - b.WriteString(dimStyle.Render(" Ctrl+L logs · q quit")) + if m.hasLogs() { + b.WriteString(dimStyle.Render(" Ctrl+L logs · q quit")) + } else { + b.WriteString(dimStyle.Render(" q quit")) + } b.WriteString("\n") return b.String() } @@ -749,6 +771,8 @@ func (m *simulateModel) viewRunning() string { if m.detailJobID != "" { b.WriteString(m.renderDetail()) + } else if m.matrix.active { + b.WriteString(m.matrix.render(m.buildMatrixRows())) } else { b.WriteString(m.renderJobList()) @@ -849,7 +873,12 @@ func (m *simulateModel) renderCounts() string { elapsed := "" if !m.startTime.IsZero() { - d := time.Since(m.startTime) + var d time.Duration + if !m.endTime.IsZero() { + d = m.endTime.Sub(m.startTime) + } else { + d = time.Since(m.startTime) + } secs := int(d.Seconds()) mins := secs / 60 secs = secs % 60 @@ -893,26 +922,20 @@ func (m *simulateModel) renderFilterTabs() string { return " " + strings.Join(parts, " ") } -func (m *simulateModel) renderJobList() string { - jobs := m.filteredJobs() +// visibleWindow clamps m.cursor / m.scrollOff against the current filtered +// job list and returns the visible slice plus overflow counts. +func (m *simulateModel) visibleWindow() (jobs []indexedJob, winStart, winEnd, overflowAbove, overflowBelow int) { + jobs = m.filteredJobs() if len(jobs) == 0 { - return dimStyle.Render(" (no jobs match this filter)") + return } - - // Clamp cursor if m.cursor < 0 { m.cursor = 0 } if m.cursor >= len(jobs) { m.cursor = len(jobs) - 1 } - - // Compute visible window - availHeight := m.height - 14 - if availHeight < 5 { - availHeight = 5 - } - + availHeight := matrixAvailHeight(m.height) if m.cursor < m.scrollOff { m.scrollOff = m.cursor } else if m.cursor >= m.scrollOff+availHeight { @@ -927,17 +950,26 @@ func (m *simulateModel) renderJobList() string { if m.scrollOff < 0 { m.scrollOff = 0 } - - winStart := m.scrollOff - winEnd := m.scrollOff + availHeight + winStart = m.scrollOff + winEnd = m.scrollOff + availHeight if winEnd > len(jobs) { winEnd = len(jobs) } + overflowAbove = winStart + overflowBelow = len(jobs) - winEnd + return +} + +func (m *simulateModel) renderJobList() string { + jobs, winStart, winEnd, above, below := m.visibleWindow() + if len(jobs) == 0 { + return dimStyle.Render(" (no jobs match this filter)") + } var b strings.Builder - if winStart > 0 { - b.WriteString(dimStyle.Render(fmt.Sprintf(" ... %d more above ...", winStart))) + if above > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ... %d more above ...", above))) b.WriteString("\n") } @@ -968,15 +1000,97 @@ func (m *simulateModel) renderJobList() string { b.WriteString("\n") } - remaining := len(jobs) - winEnd - if remaining > 0 { - b.WriteString(dimStyle.Render(fmt.Sprintf(" ... %d more below ...", remaining))) + if below > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ... %d more below ...", below))) b.WriteString("\n") } return b.String() } +// --- Matrix rain row construction --- +// +// The matrix renderer (in matrix_rain.go) consumes a []matrixRow describing +// the underlying text layer and its styled regions (status icon, dim ID range, +// cursor marker). This file provides the mapping from a simulation's job list +// into that neutral data shape. + +func plainJobIcon(job *livekit.SimulationRun_Job) rune { + switch job.Status { + case livekit.SimulationRun_Job_STATUS_COMPLETED: + return '✓' + case livekit.SimulationRun_Job_STATUS_FAILED: + return '✗' + case livekit.SimulationRun_Job_STATUS_RUNNING: + return '●' + default: + return '○' + } +} + +func jobIconStylePtr(job *livekit.SimulationRun_Job) *lipgloss.Style { + switch job.Status { + case livekit.SimulationRun_Job_STATUS_COMPLETED: + return &greenStyle + case livekit.SimulationRun_Job_STATUS_FAILED: + return &redStyle + case livekit.SimulationRun_Job_STATUS_RUNNING: + return &yellowStyle + default: + return &dimStyle + } +} + +// buildMatrixRows produces one matrixRow per visible line of the job list, +// mirroring renderJobList's layout but in a form the matrix frame can consume +// cell-by-cell. +func (m *simulateModel) buildMatrixRows() []matrixRow { + jobs, winStart, winEnd, above, below := m.visibleWindow() + if len(jobs) == 0 { + return []matrixRow{{text: []rune(" (no jobs match this filter)"), iconCol: -1, dimStart: -1}} + } + var rows []matrixRow + if above > 0 { + rows = append(rows, matrixRow{ + text: []rune(fmt.Sprintf(" ... %d more above ...", above)), + iconCol: -1, + dimStart: -1, + }) + } + for i := winStart; i < winEnd; i++ { + ij := jobs[i] + instr := ij.job.Instructions + if len(instr) > 60 { + instr = instr[:60] + "..." + } + if instr == "" { + instr = "—" + } + iconCh := plainJobIcon(ij.job) + line := fmt.Sprintf(" %c %3d. %s %s", iconCh, ij.origIdx, ij.job.Id, instr) + // Layout: " " (2) + icon (1) + " " (1) + "%3d" (3) + ". " (2) = rune offset 9 for ID + idLen := len([]rune(ij.job.Id)) + rows = append(rows, matrixRow{ + text: []rune(line), + iconCol: 2, + iconCh: iconCh, + iconStyle: jobIconStylePtr(ij.job), + dimStart: 9, + dimEnd: 9 + idLen, + dimStyle: &dimStyle, + cursorMarker: i == m.cursor, + }) + } + if below > 0 { + rows = append(rows, matrixRow{ + text: []rune(fmt.Sprintf(" ... %d more below ...", below)), + iconCol: -1, + dimStart: -1, + }) + } + return rows +} + func (m *simulateModel) renderDetail() string { if m.run == nil { return "" @@ -1217,9 +1331,25 @@ func (m *simulateModel) renderLogs() string { if logBudget < 3 { logBudget = 3 } - lines := m.agent.RecentLogs(logBudget) - for _, line := range lines { - b.WriteString(dimStyle.Render(" "+line) + "\n") + maxWidth := m.width - 4 + if maxWidth < 20 { + maxWidth = 20 + } + wrapStyle := lipgloss.NewStyle().Width(maxWidth) + + rawLines := m.agent.RecentLogs(logBudget * 3) + var visualLines []string + for _, line := range rawLines { + wrapped := wrapStyle.Render(line) + for _, wl := range strings.Split(wrapped, "\n") { + visualLines = append(visualLines, wl) + } + } + if len(visualLines) > logBudget { + visualLines = visualLines[len(visualLines)-logBudget:] + } + for _, vl := range visualLines { + b.WriteString(" " + vl + "\n") } return b.String() } @@ -1239,21 +1369,19 @@ func firstMeaningfulLine(text string) string { func (m *simulateModel) renderHint() string { var hint string if m.detailJobID != "" { - hint = " ESC/q back · Ctrl+L logs" + hint = " ESC/q back" + if m.hasLogs() { + hint += " · Ctrl+L logs" + } } else { - hint = " ↑↓/Tab navigate · ENTER detail · ←→ filter · d description · Ctrl+L logs" + hint = " ↑↓/Tab navigate · ENTER detail · ←→ filter · d description" + if m.hasLogs() { + hint += " · Ctrl+L logs" + } if m.runFinished { hint += " · q quit" } } - return m.quoteAboveHint(hint) -} - -func (m *simulateModel) quoteAboveHint(hint string) string { - q := m.quote() - if !m.showLogs && lipgloss.Width(q) < m.width-4 { - return " " + q + "\n" + dimStyle.Render(hint) - } return dimStyle.Render(hint) } From ef2393d1a4fef08e1b1e7d2715a831fa6fbd54ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Fri, 8 May 2026 13:46:04 -0700 Subject: [PATCH 15/30] Read NumSimulations from server response --- cmd/lk/simulate_tui.go | 6 +++- go.mod | 36 ++++++++++---------- go.sum | 76 +++++++++++++++++++++--------------------- 3 files changed, 61 insertions(+), 57 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 5c534ce1..9ad7276b 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -640,7 +640,11 @@ func (m *simulateModel) viewSetup() string { // Show generation progress after setup completes if m.setupDone && m.err == nil { elapsed := time.Since(m.genStart).Truncate(time.Second) - b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("●"), m.numSimulations, m.spinner(), dimStyle.Render(elapsed.String()))) + n := m.numSimulations + if m.run != nil && m.run.GetNumSimulations() > 0 { + n = m.run.GetNumSimulations() + } + b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("●"), n, m.spinner(), dimStyle.Render(elapsed.String()))) } if m.err != nil { diff --git a/go.mod b/go.mod index 8355c147..b5236f35 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/livekit/livekit-cli/v2 -go 1.25.0 +go 1.26 require ( github.com/BurntSushi/toml v1.5.0 @@ -15,7 +15,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/go-task/task/v3 v3.44.1 github.com/joho/godotenv v1.5.1 - github.com/livekit/protocol v1.45.2-0.20260403151849-8a360e8d0221 + github.com/livekit/protocol v1.45.9-0.20260508203311-a249893d6a5d github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403163006-dbb96cc2c416 github.com/mattn/go-isatty v0.0.20 github.com/moby/patternmatcher v0.6.0 @@ -104,7 +104,7 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/in-toto/in-toto-golang v0.9.0 // indirect @@ -182,27 +182,27 @@ require ( go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - golang.org/x/crypto v0.49.0 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect - google.golang.org/grpc v1.79.2 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/grpc v1.80.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect mvdan.cc/sh/v3 v3.12.0 // indirect diff --git a/go.sum b/go.sum index 1a350ea5..1e9a39cc 100644 --- a/go.sum +++ b/go.sum @@ -234,8 +234,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -273,8 +273,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260309115634-0e2e24b36ee8 h1:coWig9fKxdb/nwOaIoGUUAogso12GblAJh/9SA9hcxk= github.com/livekit/mediatransportutil v0.0.0-20260309115634-0e2e24b36ee8/go.mod h1:RCd46PT+6sEztld6XpkCrG1xskb0u3SqxIjy4G897Ss= -github.com/livekit/protocol v1.45.2-0.20260403151849-8a360e8d0221 h1:loe7h+z1kOu/ojprFTYSZBbJVly7gdZgQ/ewElGeLPo= -github.com/livekit/protocol v1.45.2-0.20260403151849-8a360e8d0221/go.mod h1:e6QdWDkfot+M2nRh0eitJUS0ZLuwvKCsfiz2pWWSG3s= +github.com/livekit/protocol v1.45.9-0.20260508203311-a249893d6a5d h1:mE0/AjgGnvsF3q0ipiNGe1HZ3CUKUG7Y+zqey/2JrBE= +github.com/livekit/protocol v1.45.9-0.20260508203311-a249893d6a5d/go.mod h1:KEPIJ/ZdMFQ9tmmfv/uT9TjQEuEcZupCZBabuRGEC1k= github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw= github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk= github.com/livekit/server-sdk-go/v2 v2.16.2-0.20260403163006-dbb96cc2c416 h1:QrNZ7Klt9wb/w/wS7o+Sgb3qWEomFRiUxeKTfMZss7w= @@ -496,20 +496,20 @@ go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0. go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0/go.mod h1:HfvuU0kW9HewH14VCOLImqKvUgONodURG7Alj/IrnGI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -530,8 +530,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -549,10 +549,10 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -577,15 +577,15 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -593,8 +593,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -609,14 +609,14 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI= -google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 6f6edb49e8d37fd33c0851f20c11c8a4f5fc1c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Fri, 8 May 2026 14:42:21 -0700 Subject: [PATCH 16/30] =?UTF-8?q?Use=20larger=20circle=20icon=20(=E2=8F=BA?= =?UTF-8?q?)=20in=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/lk/simulate_tui.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 9ad7276b..6bdf4083 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -644,7 +644,7 @@ func (m *simulateModel) viewSetup() string { if m.run != nil && m.run.GetNumSimulations() > 0 { n = m.run.GetNumSimulations() } - b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("●"), n, m.spinner(), dimStyle.Render(elapsed.String()))) + b.WriteString(fmt.Sprintf(" %s Generating %d scenarios %s %s\n", yellowStyle.Render("⏺"), n, m.spinner(), dimStyle.Render(elapsed.String()))) } if m.err != nil { @@ -686,7 +686,7 @@ func (m *simulateModel) renderSteps() string { b.WriteString(fmt.Sprintf(" %s %s%s\n", greenStyle.Render("✓"), s.label, elapsed)) case "running": elapsed := time.Since(m.stepStart).Truncate(time.Second) - b.WriteString(fmt.Sprintf(" %s %s %s %s\n", yellowStyle.Render("●"), s.label, m.spinner(), dimStyle.Render(elapsed.String()))) + b.WriteString(fmt.Sprintf(" %s %s %s %s\n", yellowStyle.Render("⏺"), s.label, m.spinner(), dimStyle.Render(elapsed.String()))) case "failed": b.WriteString(fmt.Sprintf(" %s %s\n", redStyle.Render("✗"), s.label)) default: @@ -1026,7 +1026,7 @@ func plainJobIcon(job *livekit.SimulationRun_Job) rune { case livekit.SimulationRun_Job_STATUS_FAILED: return '✗' case livekit.SimulationRun_Job_STATUS_RUNNING: - return '●' + return '⏺' default: return '○' } @@ -1396,7 +1396,7 @@ func jobIcon(job *livekit.SimulationRun_Job) string { case livekit.SimulationRun_Job_STATUS_FAILED: return redStyle.Render("✗") case livekit.SimulationRun_Job_STATUS_RUNNING: - return yellowStyle.Render("●") + return yellowStyle.Render("⏺") default: return dimStyle.Render("○") } From 74c34fd8f6c2e898ddad2f414fda12cbe5512389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Fri, 8 May 2026 15:01:18 -0700 Subject: [PATCH 17/30] Integrate save scenario overlay, show labels in job list, fix selected row highlight --- cmd/lk/simulate_matrix.go | 336 ++++++++++++++++++++++++++++++++++++++ cmd/lk/simulate_save.go | 331 +++++++++++++++++++++++++++++++++++++ cmd/lk/simulate_tui.go | 68 ++++++-- 3 files changed, 721 insertions(+), 14 deletions(-) create mode 100644 cmd/lk/simulate_matrix.go create mode 100644 cmd/lk/simulate_save.go diff --git a/cmd/lk/simulate_matrix.go b/cmd/lk/simulate_matrix.go new file mode 100644 index 00000000..6570a2d0 --- /dev/null +++ b/cmd/lk/simulate_matrix.go @@ -0,0 +1,336 @@ +// Copyright 2025 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 ( + "math/rand" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + matrixMaxSweeps = 1 + matrixTickInterval = 50 * time.Millisecond + matrixMinTrail = 3 + matrixMaxTrail = 8 +) + +var matrixCharset = []rune("ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホ0123456789") + +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) +) + +// 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.). +type matrixRow struct { + text []rune + iconCol int // -1 if no icon cell on this row + iconCh rune + iconStyle *lipgloss.Style // applied when rain is not on iconCol + dimStart int // rune offset; -1 if no dim run + dimEnd int // half-open + dimStyle *lipgloss.Style // applied to cells in [dimStart, dimEnd) + cursorMarker bool // render a ▸ in col 0 when true +} + +// Cell category tags used for coalescing same-styled runs within a row. +const ( + mcSpace = iota + mcPlain + mcRainHead + mcRainT1 + mcRainT2 + mcRainT3 + mcIcon + mcDim + mcCursor +) + +type matrixTickMsg struct{} + +func matrixTickCmd() tea.Cmd { + return tea.Tick(matrixTickInterval, func(time.Time) tea.Msg { + return matrixTickMsg{} + }) +} + +// matrixAvailHeight is the shared height calculation used by both the job list +// and the matrix overlay so the rain area lines up with the list rows. +func matrixAvailHeight(h int) int { + availHeight := h - 14 + if availHeight < 5 { + availHeight = 5 + } + return availHeight +} + +type matrixRain struct { + active bool + width int + height int + heads []int // heads[col] = current head row; negative = staggered spawn + speeds []int // ticks per row advance, per column + tickCount []int // per-column tick accumulator + trailLen []int // trail length, per column + sweeps []int // times head has crossed the bottom + grid [][]rune // height × width; frozen glyphs behind the head + skipCol []bool // columns where rain is suppressed entirely +} + +// start seeds the rain state. skipCol (optional, len == width) marks columns +// that should never carry a drop — used to protect the status-icon column and +// to skip columns that contain no text in any row. +func (r *matrixRain) start(width, height int, skipCol []bool) { + if width < 1 || height < 1 { + return + } + r.active = true + r.width = width + r.height = height + r.heads = make([]int, width) + r.speeds = make([]int, width) + r.tickCount = make([]int, width) + r.trailLen = make([]int, width) + r.sweeps = make([]int, width) + r.grid = make([][]rune, height) + for i := range r.grid { + r.grid[i] = make([]rune, width) + } + r.skipCol = make([]bool, width) + if skipCol != nil { + copy(r.skipCol, skipCol) + } + for i := 0; i < width; i++ { + if r.skipCol[i] { + // Parked: counts as already swept so it never gates auto-stop. + r.sweeps[i] = matrixMaxSweeps + continue + } + // Mild initial stagger so columns enter at slightly different times + // without prolonging the overall animation. + r.heads[i] = -rand.Intn(height/2 + 1) + r.speeds[i] = 1 + rand.Intn(3) + r.trailLen[i] = matrixMinTrail + rand.Intn(matrixMaxTrail-matrixMinTrail+1) + } +} + +func (r *matrixRain) step() { + if !r.active { + return + } + for col := 0; col < r.width; col++ { + if r.skipCol[col] { + continue + } + r.tickCount[col]++ + if r.tickCount[col] < r.speeds[col] { + continue + } + r.tickCount[col] = 0 + oldHead := r.heads[col] + if oldHead >= 0 && oldHead < r.height { + r.grid[oldHead][col] = matrixCharset[rand.Intn(len(matrixCharset))] + } + r.heads[col]++ + // Let the head continue past the bottom so the trail drains out one row + // at a time (cellKind naturally clips rows where dist > trailLen). + // Once the whole trail has exited, count the sweep; if the column has + // hit its sweep budget, park it so the effect fades out as each column + // finishes independently. Otherwise respawn for another drop. + if r.heads[col]-r.trailLen[col] >= r.height { + r.sweeps[col]++ + if r.sweeps[col] >= matrixMaxSweeps { + r.skipCol[col] = true + continue + } + r.heads[col] = -r.trailLen[col] - rand.Intn(r.height+1) + r.speeds[col] = 1 + rand.Intn(2) + r.trailLen[col] = matrixMinTrail + rand.Intn(matrixMaxTrail-matrixMinTrail+1) + } + } + minSweeps := r.sweeps[0] + for _, s := range r.sweeps[1:] { + if s < minSweeps { + minSweeps = s + } + } + if minSweeps >= matrixMaxSweeps { + r.active = false + } +} + +// cellKind classifies a given (row, col) against the current drop state. +// +// -1 = no rain here +// 0 = head +// 1 = tier 1 (1-2 above head) +// 2 = tier 2 (3-5 above head) +// 3 = tier 3 (6..trailLen above head) +func (r *matrixRain) cellKind(row, col int) int { + if r.skipCol[col] { + return -1 + } + h := r.heads[col] + if h < 0 || row > h { + return -1 + } + dist := h - row + if dist == 0 { + return 0 + } + if dist > r.trailLen[col] { + return -1 + } + switch { + case dist <= 2: + return 1 + case dist <= 5: + return 2 + default: + return 3 + } +} + +// matrixHeadChar returns a fresh random glyph for a head cell. +func matrixHeadChar() rune { + return matrixCharset[rand.Intn(len(matrixCharset))] +} + +// matrixTrailChar returns the frozen glyph at (row, col) if set, otherwise a +// fresh random glyph. Callers must already know the cell is part of the trail. +func matrixTrailChar(r *matrixRain, row, col int) rune { + if row >= 0 && row < r.height && col >= 0 && col < r.width && r.grid[row][col] != 0 { + return r.grid[row][col] + } + return matrixCharset[rand.Intn(len(matrixCharset))] +} + +// render composites the current rain frame over the supplied text rows. Each +// cell is classified into a rain tier or a text category (icon, dim, plain, +// space, cursor marker) and runs of the same category coalesce into a single +// styled render call to keep ANSI output compact. +func (r *matrixRain) render(rows []matrixRow) string { + if r.width < 1 { + return "" + } + var b strings.Builder + b.Grow(r.height * r.width * 8) + + cats := make([]int, r.width) + runes := make([]rune, r.width) + + for row := 0; row < r.height; row++ { + var rd matrixRow + rd.iconCol = -1 + rd.dimStart = -1 + if row < len(rows) { + rd = rows[row] + } + + for col := 0; col < r.width; col++ { + switch r.cellKind(row, col) { + case 0: + cats[col] = mcRainHead + runes[col] = matrixHeadChar() + continue + case 1: + cats[col] = mcRainT1 + runes[col] = matrixTrailChar(r, row, col) + continue + case 2: + cats[col] = mcRainT2 + runes[col] = matrixTrailChar(r, row, col) + continue + case 3: + cats[col] = mcRainT3 + runes[col] = matrixTrailChar(r, row, col) + continue + } + // Not rain — pick the text-layer category for this cell. + switch { + case col == 0 && rd.cursorMarker: + cats[col] = mcCursor + runes[col] = '▸' + case col == rd.iconCol && rd.iconStyle != nil: + cats[col] = mcIcon + runes[col] = rd.iconCh + case rd.dimStart >= 0 && col >= rd.dimStart && col < rd.dimEnd && col < len(rd.text): + cats[col] = mcDim + runes[col] = rd.text[col] + case col < len(rd.text): + cats[col] = mcPlain + runes[col] = rd.text[col] + default: + cats[col] = mcSpace + runes[col] = ' ' + } + } + + runStart := 0 + for col := 1; col <= r.width; col++ { + if col == r.width || cats[col] != cats[runStart] { + writeMatrixRun(&b, cats[runStart], runes[runStart:col], rd.iconStyle, rd.dimStyle) + runStart = col + } + } + if len(rd.text) > r.width { + b.WriteString(string(rd.text[r.width:])) + } + b.WriteByte('\n') + } + return b.String() +} + +func writeMatrixRun(b *strings.Builder, cat int, rs []rune, iconStyle, dimStyle *lipgloss.Style) { + if len(rs) == 0 { + return + } + s := string(rs) + switch cat { + case mcRainHead: + b.WriteString(matrixHeadStyle.Render(s)) + case mcRainT1: + b.WriteString(matrixTier1Style.Render(s)) + case mcRainT2: + b.WriteString(matrixTier2Style.Render(s)) + case mcRainT3: + b.WriteString(matrixTier3Style.Render(s)) + case mcIcon: + if iconStyle != nil { + b.WriteString(iconStyle.Render(s)) + } else { + b.WriteString(s) + } + case mcDim: + if dimStyle != nil { + b.WriteString(dimStyle.Render(s)) + } else { + b.WriteString(s) + } + case mcCursor: + b.WriteString(matrixCursorMarkerStyle.Render(s)) + default: + b.WriteString(s) + } +} diff --git a/cmd/lk/simulate_save.go b/cmd/lk/simulate_save.go new file mode 100644 index 00000000..318da753 --- /dev/null +++ b/cmd/lk/simulate_save.go @@ -0,0 +1,331 @@ +// Copyright 2025 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" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/livekit/protocol/livekit" + lksdk "github.com/livekit/server-sdk-go/v2" +) + +type savePhase int + +const ( + savePhaseLoading savePhase = iota + savePhaseList + savePhaseNewGroup + savePhaseSaving + savePhaseSuccess + savePhaseError +) + +type saveOverlay struct { + active bool + client *lksdk.AgentSimulationClient + job *livekit.SimulationRun_Job + phase savePhase + groups []*livekit.ScenarioGroup + cursor int + groupInput string + err error + successMsg string + spinnerIdx int + width int +} + +// --- Messages --- + +type saveGroupsLoadedMsg struct { + groups []*livekit.ScenarioGroup + err error +} + +type scenarioSavedMsg struct { + groupLabel string + err error +} + +type saveDismissMsg struct{} + +type saveSpinnerTickMsg struct{} + +func saveSpinnerTickCmd() tea.Cmd { + return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg { + return saveSpinnerTickMsg{} + }) +} + +// --- Lifecycle --- + +func (s *saveOverlay) start(client *lksdk.AgentSimulationClient, job *livekit.SimulationRun_Job, width int) { + *s = saveOverlay{ + active: true, + client: client, + job: job, + phase: savePhaseLoading, + width: width, + } +} + +// --- Async commands --- + +func (s *saveOverlay) fetchGroupsCmd() tea.Cmd { + client := s.client + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + resp, err := client.ListScenarioGroups(ctx, &livekit.ScenarioGroup_List_Request{}) + if err != nil { + return saveGroupsLoadedMsg{err: err} + } + return saveGroupsLoadedMsg{groups: resp.GetScenarioGroups()} + } +} + +func (s *saveOverlay) saveScenarioCmd(groupID, groupLabel string) tea.Cmd { + client := s.client + job := s.job + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := client.CreateScenario(ctx, &livekit.Scenario_Create_Request{ + GroupId: groupID, + Label: job.Label, + Instructions: job.Instructions, + AgentExpectations: job.AgentExpectations, + }) + if err != nil { + return scenarioSavedMsg{err: err} + } + return scenarioSavedMsg{groupLabel: groupLabel} + } +} + +func (s *saveOverlay) createGroupAndSaveCmd(groupLabel string) tea.Cmd { + client := s.client + job := s.job + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + groupResp, err := client.CreateScenarioGroup(ctx, &livekit.ScenarioGroup_Create_Request{ + Label: groupLabel, + }) + if err != nil { + return scenarioSavedMsg{err: fmt.Errorf("create group: %w", err)} + } + _, err = client.CreateScenario(ctx, &livekit.Scenario_Create_Request{ + GroupId: groupResp.GetScenarioGroup().GetId(), + Label: job.Label, + Instructions: job.Instructions, + AgentExpectations: job.AgentExpectations, + }) + if err != nil { + return scenarioSavedMsg{err: err} + } + return scenarioSavedMsg{groupLabel: groupLabel} + } +} + +// --- Message handling --- + +func (s *saveOverlay) handleMsg(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case saveGroupsLoadedMsg: + if msg.err != nil { + s.phase = savePhaseError + s.err = msg.err + return nil + } + s.groups = msg.groups + s.phase = savePhaseList + s.cursor = 0 + return nil + + case scenarioSavedMsg: + if msg.err != nil { + s.phase = savePhaseError + s.err = msg.err + return nil + } + s.phase = savePhaseSuccess + s.successMsg = msg.groupLabel + return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + return saveDismissMsg{} + }) + + case saveDismissMsg: + s.active = false + return nil + + case saveSpinnerTickMsg: + if s.active && (s.phase == savePhaseLoading || s.phase == savePhaseSaving) { + s.spinnerIdx++ + return saveSpinnerTickCmd() + } + return nil + } + return nil +} + +// --- Key handling --- + +func (s *saveOverlay) handleKey(key string) tea.Cmd { + switch s.phase { + case savePhaseLoading, savePhaseSaving: + if key == "esc" { + s.active = false + } + return nil + + case savePhaseSuccess: + s.active = false + return nil + + case savePhaseError: + switch key { + case "esc", "q": + s.active = false + case "r": + s.phase = savePhaseLoading + s.err = nil + return tea.Batch(s.fetchGroupsCmd(), saveSpinnerTickCmd()) + } + return nil + + case savePhaseNewGroup: + switch key { + case "esc": + s.phase = savePhaseList + s.groupInput = "" + case "enter": + name := strings.TrimSpace(s.groupInput) + if name != "" { + s.phase = savePhaseSaving + return tea.Batch(s.createGroupAndSaveCmd(name), saveSpinnerTickCmd()) + } + case "backspace": + if len(s.groupInput) > 0 { + s.groupInput = s.groupInput[:len(s.groupInput)-1] + } + default: + if len(key) == 1 && key[0] >= 32 { + s.groupInput += key + } + } + return nil + + case savePhaseList: + maxIdx := len(s.groups) // extra slot for "+ New Group..." + switch key { + case "esc", "q": + s.active = false + case "up", "shift+tab": + if s.cursor > 0 { + s.cursor-- + } + case "down", "tab": + if s.cursor < maxIdx { + s.cursor++ + } + case "enter": + if s.cursor < len(s.groups) { + group := s.groups[s.cursor] + s.phase = savePhaseSaving + return tea.Batch(s.saveScenarioCmd(group.Id, group.Label), saveSpinnerTickCmd()) + } + // "+ New Group..." selected + s.phase = savePhaseNewGroup + s.groupInput = "" + } + return nil + } + return nil +} + +// --- Rendering --- + +var ( + saveBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("6")). + Padding(0, 1) + saveTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")). + Bold(true) + saveSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("6")). + Bold(true) + saveNewGroupStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")) +) + +func (s *saveOverlay) render() string { + boxWidth := min(max(s.width-8, 40), 60) + + var b strings.Builder + title := saveTitleStyle.Render("Save to Scenario Group") + b.WriteString(title + "\n") + + switch s.phase { + case savePhaseLoading: + spinner := simSpinnerFrames[s.spinnerIdx%len(simSpinnerFrames)] + b.WriteString(fmt.Sprintf("\n %s Loading scenario groups...\n", cyanStyle.Render(spinner))) + b.WriteString(dimStyle.Render("\n ESC cancel")) + + case savePhaseError: + b.WriteString(fmt.Sprintf("\n %s %s\n", redStyle.Render("✗"), redStyle.Render(s.err.Error()))) + b.WriteString(dimStyle.Render("\n r retry · ESC back")) + + case savePhaseList: + b.WriteString("\n") + for i, g := range s.groups { + count := len(g.Scenarios) + label := fmt.Sprintf("%s (%d scenarios)", g.Label, count) + if i == s.cursor { + b.WriteString(fmt.Sprintf(" %s %s\n", saveSelectedStyle.Render(">"), saveSelectedStyle.Render(label))) + } else { + b.WriteString(fmt.Sprintf(" %s\n", label)) + } + } + // "+ New Group..." option + newLabel := "+ New Group..." + if s.cursor == len(s.groups) { + b.WriteString(fmt.Sprintf(" %s %s\n", saveNewGroupStyle.Render(">"), saveNewGroupStyle.Render(newLabel))) + } else { + b.WriteString(fmt.Sprintf(" %s\n", saveNewGroupStyle.Render(newLabel))) + } + b.WriteString(dimStyle.Render("\n ↑↓ navigate · ENTER select · ESC back")) + + case savePhaseNewGroup: + b.WriteString(fmt.Sprintf("\n Group name: %s%s\n", s.groupInput, cyanStyle.Render("│"))) + b.WriteString(dimStyle.Render("\n ENTER create · ESC cancel")) + + case savePhaseSaving: + spinner := simSpinnerFrames[s.spinnerIdx%len(simSpinnerFrames)] + b.WriteString(fmt.Sprintf("\n %s Saving scenario...\n", cyanStyle.Render(spinner))) + + case savePhaseSuccess: + b.WriteString(fmt.Sprintf("\n %s Saved to %s\n", greenStyle.Render("✓"), boldStyle.Render("\""+s.successMsg+"\""))) + } + + return "\n" + saveBoxStyle.Width(boxWidth).Render(b.String()) + "\n" +} diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 6bdf4083..e94c5971 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -131,6 +131,8 @@ type simulateModel struct { showLogs bool showDescription bool + save saveOverlay + matrix matrixRain matrixSavedShowLogs bool @@ -463,6 +465,12 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case subprocessExitMsg: // Subprocess exited — don't quit TUI, just note it + case saveGroupsLoadedMsg, scenarioSavedMsg, saveDismissMsg, saveSpinnerTickMsg: + if m.save.active { + cmd := m.save.handleMsg(msg) + return m, cmd + } + case tea.KeyMsg: return m.handleKey(msg) } @@ -471,6 +479,14 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() + if m.save.active { + if key == "ctrl+c" { + m.save.active = false + } else { + cmd := m.save.handleKey(key) + return m, cmd + } + } if m.matrix.active { // Any keypress cancels rain so the user regains control immediately. // Pressing 'm' just cancels; every other key falls through to normal @@ -556,6 +572,14 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.detailJobID = jobs[m.cursor].job.Id } } + case "s": + if m.detailJobID != "" { + job := m.findJob(m.detailJobID) + if job != nil { + m.save.start(m.client, job, m.width) + return m, tea.Batch(m.save.fetchGroupsCmd(), saveSpinnerTickCmd()) + } + } case "esc", "backspace": if m.detailJobID != "" { m.detailJobID = "" @@ -599,6 +623,18 @@ func (m *simulateModel) filteredJobs() []indexedJob { return result } +func (m *simulateModel) findJob(id string) *livekit.SimulationRun_Job { + if m.run == nil { + return nil + } + for _, j := range m.run.Jobs { + if j.Id == id { + return j + } + } + return nil +} + func (m *simulateModel) View() string { // Setup phase or generating phase — show unified step view if !m.setupDone || m.run == nil || m.run.Status == livekit.SimulationRun_STATUS_GENERATING { @@ -773,7 +809,9 @@ func (m *simulateModel) viewRunning() string { b.WriteString(m.renderFilterTabs()) b.WriteString("\n\n") - if m.detailJobID != "" { + if m.save.active { + b.WriteString(m.save.render()) + } else if m.detailJobID != "" { b.WriteString(m.renderDetail()) } else if m.matrix.active { b.WriteString(m.matrix.render(m.buildMatrixRows())) @@ -979,26 +1017,28 @@ func (m *simulateModel) renderJobList() string { for i := winStart; i < winEnd; i++ { ij := jobs[i] - icon := jobIcon(ij.job) - instr := ij.job.Instructions - if len(instr) > 60 { - instr = instr[:60] + "..." + label := ij.job.Label + if label == "" { + label = ij.job.Instructions + if len(label) > 60 { + label = label[:60] + "..." + } } - if instr == "" { - instr = "—" + if label == "" { + label = "—" } + plainIcon := string(plainJobIcon(ij.job)) var line string if i == m.cursor { - // Build without inner styles so reverse applies cleanly - line = fmt.Sprintf(" %s %3d. %s %s", icon, ij.origIdx, ij.job.Id, instr) - visible := lipgloss.Width(line) - if visible < m.width { - line += strings.Repeat(" ", m.width-visible) + line = fmt.Sprintf(" %s %3d. %s", plainIcon, ij.origIdx+1, label) + if pad := m.width - lipgloss.Width(line); pad > 0 { + line += strings.Repeat(" ", pad) } line = reverseStyle.Render(line) } else { - line = fmt.Sprintf(" %s %3d. %s %s", icon, ij.origIdx, dimStyle.Render(ij.job.Id), instr) + icon := jobIcon(ij.job) + line = fmt.Sprintf(" %s %3d. %s", icon, ij.origIdx+1, label) } b.WriteString(line) b.WriteString("\n") @@ -1373,7 +1413,7 @@ func firstMeaningfulLine(text string) string { func (m *simulateModel) renderHint() string { var hint string if m.detailJobID != "" { - hint = " ESC/q back" + hint = " ESC/q back · s save scenario" if m.hasLogs() { hint += " · Ctrl+L logs" } From f9763a6fb1eafb8def2708764f35fcff8bfd55d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Fri, 8 May 2026 15:04:42 -0700 Subject: [PATCH 18/30] =?UTF-8?q?Softer=20yellow,=20use=20=E2=8F=BA=20for?= =?UTF-8?q?=20pending=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/lk/simulate_tui.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index e94c5971..3f53d725 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -39,7 +39,7 @@ 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("3")) + yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#e5a00d")) dimStyle = lipgloss.NewStyle().Faint(true) boldStyle = lipgloss.NewStyle().Bold(true) reverseStyle = lipgloss.NewStyle().Reverse(true) @@ -1068,7 +1068,7 @@ func plainJobIcon(job *livekit.SimulationRun_Job) rune { case livekit.SimulationRun_Job_STATUS_RUNNING: return '⏺' default: - return '○' + return '⏺' } } @@ -1438,6 +1438,6 @@ func jobIcon(job *livekit.SimulationRun_Job) string { case livekit.SimulationRun_Job_STATUS_RUNNING: return yellowStyle.Render("⏺") default: - return dimStyle.Render("○") + return dimStyle.Render("⏺") } } From ff16c48adc89e5933975f576433297a47056c296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Fri, 8 May 2026 15:07:13 -0700 Subject: [PATCH 19/30] Pad selected row to max label width for consistent highlight --- cmd/lk/simulate_tui.go | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 3f53d725..1d35f26d 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -1015,6 +1015,13 @@ func (m *simulateModel) renderJobList() string { b.WriteString("\n") } + // Compute labels and max width for consistent hover highlight + type rowData struct { + ij indexedJob + label string + } + rows := make([]rowData, 0, winEnd-winStart) + maxWidth := 0 for i := winStart; i < winEnd; i++ { ij := jobs[i] label := ij.job.Label @@ -1027,18 +1034,27 @@ func (m *simulateModel) renderJobList() string { if label == "" { label = "—" } + row := rowData{ij: ij, label: label} + rows = append(rows, row) + w := lipgloss.Width(fmt.Sprintf(" ⏺ %3d. %s", ij.origIdx+1, label)) + if w > maxWidth { + maxWidth = w + } + } - plainIcon := string(plainJobIcon(ij.job)) + for i, row := range rows { + idx := winStart + i + plainIcon := string(plainJobIcon(row.ij.job)) var line string - if i == m.cursor { - line = fmt.Sprintf(" %s %3d. %s", plainIcon, ij.origIdx+1, label) - if pad := m.width - lipgloss.Width(line); pad > 0 { + if idx == m.cursor { + line = fmt.Sprintf(" %s %3d. %s", plainIcon, row.ij.origIdx+1, row.label) + if pad := maxWidth - lipgloss.Width(line); pad > 0 { line += strings.Repeat(" ", pad) } line = reverseStyle.Render(line) } else { - icon := jobIcon(ij.job) - line = fmt.Sprintf(" %s %3d. %s", icon, ij.origIdx+1, label) + icon := jobIcon(row.ij.job) + line = fmt.Sprintf(" %s %3d. %s", icon, row.ij.origIdx+1, row.label) } b.WriteString(line) b.WriteString("\n") From 3a994d9710134d8f151b3eb9094d762bd0fdb3d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Fri, 8 May 2026 15:11:31 -0700 Subject: [PATCH 20/30] Remove q to quit, only ctrl+c exits --- cmd/lk/simulate_tui.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 1d35f26d..a2746420 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -587,8 +587,6 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": if m.detailJobID != "" { m.detailJobID = "" - } else { - return m, tea.Quit } } return m, nil @@ -691,7 +689,7 @@ func (m *simulateModel) viewSetup() string { b.WriteString(m.renderLogs()) } b.WriteString("\n") - b.WriteString(dimStyle.Render(" q quit")) + b.WriteString(dimStyle.Render("")) b.WriteString("\n") } else { b.WriteString("\n") @@ -759,9 +757,9 @@ func (m *simulateModel) viewFailed() string { b.WriteString(m.renderLogs()) } if m.hasLogs() { - b.WriteString(dimStyle.Render(" Ctrl+L logs · q quit")) + b.WriteString(dimStyle.Render(" Ctrl+L logs ")) } else { - b.WriteString(dimStyle.Render(" q quit")) + b.WriteString(dimStyle.Render("")) } b.WriteString("\n") return b.String() @@ -1429,7 +1427,7 @@ func firstMeaningfulLine(text string) string { func (m *simulateModel) renderHint() string { var hint string if m.detailJobID != "" { - hint = " ESC/q back · s save scenario" + hint = " ESC back · s save scenario" if m.hasLogs() { hint += " · Ctrl+L logs" } @@ -1439,7 +1437,7 @@ func (m *simulateModel) renderHint() string { hint += " · Ctrl+L logs" } if m.runFinished { - hint += " · q quit" + hint += " " } } return dimStyle.Render(hint) From 9385f9f1faa20be82adc94225233f70122c75147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 18:46:56 -0700 Subject: [PATCH 21/30] CI mode, TUI improvements, log filtering, and entrypoint fix CLI architecture: - Non-interactive CI mode auto-detected (no TTY or CI env var) - Shared lifecycle functions in simulate.go (no bubbletea dependency) - simulate_ci.go: sequential runner with ::group:: output for GHA TUI improvements: - Detail view: unified scrollable content (instructions, transcript, logs) - Log filtering by room name prefix (latest retry only) - Compact chat transcript rendering - Removed filter tabs, simplified matrix rain - Mouse capture removed (text selection works) - Summary unavailable message, summarizing spinner Agent subprocess: - Debug logging enabled, room-indexed log storage - Per-room log filtering by prefix with latest-room tracking Bug fix: - Send CodeEntrypoint in ConfirmSourceUpload so the generator analyzes the correct file instead of the entire workspace --- .gitignore | 1 + cmd/lk/simulate.go | 232 ++++++++++--- cmd/lk/simulate_ci.go | 400 +++++++++++++++++++++ cmd/lk/simulate_matrix.go | 18 +- cmd/lk/simulate_save.go | 3 +- cmd/lk/simulate_subprocess.go | 112 +++++- cmd/lk/simulate_tui.go | 631 ++++++++++++++++++---------------- 7 files changed, 1014 insertions(+), 383 deletions(-) create mode 100644 cmd/lk/simulate_ci.go diff --git a/.gitignore b/.gitignore index 55ca49b4..0e213432 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ dist/ .task/ .DS_Store +lk diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index b6b34579..e87db260 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -15,26 +15,35 @@ package main import ( + "bytes" "context" "encoding/json" "fmt" + "io" "math/rand" "os" "time" - tea "github.com/charmbracelet/bubbletea" + "github.com/mattn/go-isatty" "github.com/urfave/cli/v3" "github.com/livekit/livekit-cli/v2/pkg/agentfs" "github.com/livekit/livekit-cli/v2/pkg/config" "github.com/livekit/protocol/livekit" lksdk "github.com/livekit/server-sdk-go/v2" + "github.com/livekit/server-sdk-go/v2/pkg/cloudagents" ) var ( simulateProjectConfig *config.ProjectConfig ) +const ( + agentRegisterTimeout = 20 * time.Second + simulationPollInterval = 1 * time.Second + simulationAPITimeout = 10 * time.Second +) + var simulateCommand = &cli.Command{ Name: "simulate", Usage: "Run agent simulations against LiveKit Cloud", @@ -85,6 +94,32 @@ type scenarioConfig struct { Metadata map[string]string `json:"metadata"` } +// simulateConfig holds all parameters needed to run a simulation in either TUI or CI mode. +type simulateConfig struct { + ctx context.Context + client *lksdk.AgentSimulationClient + pc *config.ProjectConfig + numSimulations int32 + mode simulateMode + description string + agentName string + projectDir string + projectType agentfs.ProjectType + entrypoint string + cfg *simulationConfig + scenarioGroupID string +} + +// simulateMode represents how scenarios are sourced. +type simulateMode int + +const ( + modeInlineScenarios simulateMode = iota + modeScenarioGroup + modeGenerateFromDescription + modeGenerateFromSource +) + func loadSimulationConfig(path string) (*simulationConfig, error) { if path == "" { return nil, nil @@ -109,16 +144,6 @@ func generateAgentName() string { return "simulation-" + string(b) } -// simulateMode represents how scenarios are sourced. -type simulateMode int - -const ( - modeInlineScenarios simulateMode = iota - modeScenarioGroup - modeGenerateFromDescription - modeGenerateFromSource -) - func runSimulate(ctx context.Context, cmd *cli.Command) error { pc := simulateProjectConfig @@ -137,7 +162,6 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { scenarioGroupID := cmd.String("scenario-group-id") agentName := generateAgentName() - // Mode detection (checked in priority order) var mode simulateMode switch { case cfg != nil && len(cfg.Scenarios) > 0: @@ -150,7 +174,6 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { mode = modeGenerateFromSource } - // Detect project type, walking up parent directories if needed projectDir, projectType, err := agentfs.DetectProjectRoot(".") if err != nil { return err @@ -159,7 +182,6 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("simulate currently only supports Python agents (detected: %s)", projectType) } - // Resolve entrypoint entrypoint, err := findEntrypoint(projectDir, cmd.String("entrypoint"), projectType) if err != nil { return err @@ -167,54 +189,164 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { simClient := lksdk.NewAgentSimulationClient(serverURL, pc.APIKey, pc.APISecret) - m := newSimulateModel(&simulateConfig{ - ctx: ctx, - client: simClient, - pc: pc, - numSimulations: numSimulations, - mode: mode, - description: description, - agentName: agentName, - projectDir: projectDir, - projectType: projectType, - entrypoint: entrypoint, - cfg: cfg, + simCfg := &simulateConfig{ + ctx: ctx, + client: simClient, + pc: pc, + numSimulations: numSimulations, + mode: mode, + description: description, + agentName: agentName, + projectDir: projectDir, + projectType: projectType, + entrypoint: entrypoint, + cfg: cfg, scenarioGroupID: scenarioGroupID, - }) - - p := tea.NewProgram(m, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - return fmt.Errorf("TUI error: %w", err) } - if m.agent != nil { - m.agent.Kill() - if m.agent.LogPath != "" { - fmt.Fprintf(os.Stderr, "Agent logs: %s\n", m.agent.LogPath) - } + if !isInteractive() { + return runSimulateCI(ctx, simCfg) } + return runSimulateTUI(simCfg) +} - if url := m.getDashboardURL(); url != "" { - fmt.Fprintf(os.Stderr, "Dashboard: %s\n", url) +func isInteractive() bool { + if os.Getenv("CI") != "" { + return false } + return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) +} + +// --- Shared lifecycle functions used by both TUI and CI modes --- - // Cancel the run — server will no-op if already terminal - if m.runID != "" && !m.runFinished { - cancelCtx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - if _, err := simClient.CancelSimulationRun(cancelCtx, &livekit.SimulationRun_Cancel_Request{ - SimulationRunId: m.runID, - }); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to cancel run: %v\n", err) - } else { - fmt.Fprintf(os.Stderr, "Run cancelled\n") +func startSimulationAgent(c *simulateConfig, forwardOutput io.Writer) (*AgentProcess, error) { + return startAgent(AgentStartConfig{ + Dir: c.projectDir, + Entrypoint: c.entrypoint, + ProjectType: c.projectType, + CLIArgs: []string{ + "start", + "--url", c.pc.URL, + "--api-key", c.pc.APIKey, + "--api-secret", c.pc.APISecret, + "--log-level", "DEBUG", + "--log-format", "colored", + }, + Env: []string{ + "LIVEKIT_AGENT_NAME=" + c.agentName, + "LIVEKIT_URL=" + c.pc.URL, + "LIVEKIT_API_KEY=" + c.pc.APIKey, + "LIVEKIT_API_SECRET=" + c.pc.APISecret, + }, + ReadySignal: "registered worker", + ForwardOutput: forwardOutput, + }) +} + +func createSimulationRun(ctx context.Context, c *simulateConfig) (string, *livekit.PresignedPostRequest, error) { + req := &livekit.SimulationRun_Create_Request{ + AgentName: c.agentName, + AgentDescription: c.description, + NumSimulations: c.numSimulations, + } + switch c.mode { + case modeInlineScenarios: + scenarios := make([]*livekit.SimulationRun_Create_Scenario, 0, len(c.cfg.Scenarios)) + for _, sc := range c.cfg.Scenarios { + scenarios = append(scenarios, &livekit.SimulationRun_Create_Scenario{ + Label: sc.Label, + Instructions: sc.Instructions, + AgentExpectations: sc.AgentExpectations, + Metadata: sc.Metadata, + }) + } + req.Source = &livekit.SimulationRun_Create_Request_Scenarios{ + Scenarios: &livekit.SimulationRun_Create_Scenarios{ + Scenarios: scenarios, + }, + } + case modeScenarioGroup: + req.Source = &livekit.SimulationRun_Create_Request_GroupId{ + GroupId: c.scenarioGroupID, } } - if m.err != nil && m.err != context.Canceled { - return m.err + resp, err := c.client.CreateSimulationRun(ctx, req) + if err != nil { + return "", nil, fmt.Errorf("failed to create simulation: %w", err) + } + return resp.SimulationRunId, resp.PresignedPostRequest, nil +} + +func uploadSource(ctx context.Context, client *lksdk.AgentSimulationClient, runID string, presigned *livekit.PresignedPostRequest, projectDir, entrypoint string) error { + if presigned == nil { + return fmt.Errorf("server did not return upload URL") + } + var buf bytes.Buffer + if err := cloudagents.CreateSourceTarball(os.DirFS(projectDir), nil, &buf); err != nil { + return fmt.Errorf("failed to create source archive: %w", err) + } + if err := cloudagents.MultipartUpload(presigned.Url, presigned.Values, &buf); err != nil { + return fmt.Errorf("failed to upload source: %w", err) + } + if _, err := client.ConfirmSimulationSourceUpload(ctx, &livekit.SimulationRun_ConfirmSourceUpload_Request{ + SimulationRunId: runID, + CodeEntrypoint: entrypoint, + }); err != nil { + return fmt.Errorf("failed to confirm upload: %w", err) } return nil } +func getSimulationRun(ctx context.Context, client *lksdk.AgentSimulationClient, runID string) (*livekit.SimulationRun, error) { + resp, err := client.GetSimulationRun(ctx, &livekit.SimulationRun_Get_Request{ + SimulationRunId: runID, + }) + if err != nil { + return nil, err + } + return resp.Run, nil +} + +func isTerminalRunStatus(status livekit.SimulationRun_Status) bool { + return status == livekit.SimulationRun_STATUS_COMPLETED || + status == livekit.SimulationRun_STATUS_FAILED || + status == livekit.SimulationRun_STATUS_CANCELLED +} + +func simulationDashboardURL(projectID, runID string) string { + if projectID == "" || runID == "" { + return "" + } + return fmt.Sprintf("%s/projects/%s/agents/simulations/%s", dashboardURL, projectID, runID) +} +func cancelSimulationRun(client *lksdk.AgentSimulationClient, runID string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := client.CancelSimulationRun(ctx, &livekit.SimulationRun_Cancel_Request{ + SimulationRunId: runID, + }); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to cancel run: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "Run cancelled\n") + } +} + +func simulationJobCounts(run *livekit.SimulationRun) (total, done, passed, failed int) { + if run == nil { + return + } + total = len(run.Jobs) + for _, j := range run.Jobs { + switch j.Status { + case livekit.SimulationRun_Job_STATUS_COMPLETED: + done++ + passed++ + case livekit.SimulationRun_Job_STATUS_FAILED: + done++ + failed++ + } + } + return +} diff --git a/cmd/lk/simulate_ci.go b/cmd/lk/simulate_ci.go new file mode 100644 index 00000000..59656476 --- /dev/null +++ b/cmd/lk/simulate_ci.go @@ -0,0 +1,400 @@ +// Copyright 2025 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" + "fmt" + "io" + "os" + "os/signal" + "strings" + "sync/atomic" + "time" + + "github.com/livekit/protocol/livekit" + agent "github.com/livekit/protocol/livekit/agent" +) + +type toggleWriter struct { + w io.Writer + enabled atomic.Bool +} + +func (tw *toggleWriter) Write(p []byte) (int, error) { + if tw.enabled.Load() { + return tw.w.Write(p) + } + return len(p), nil +} + +func runSimulateCI(ctx context.Context, config *simulateConfig) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt) + defer stop() + + var agent *AgentProcess + var runID string + var runFinished bool + + cleanup := func() { + if agent != nil { + agent.Kill() + if agent.LogPath != "" { + fmt.Fprintf(os.Stderr, "Agent logs: %s\n", agent.LogPath) + } + } + if runID != "" && !runFinished { + cancelSimulationRun(config.client, runID) + } + } + defer cleanup() + + // --- Setup --- + + fmt.Fprintln(os.Stdout, "::group::Setup") + + fmt.Fprintf(os.Stderr, "Starting agent...\n") + start := time.Now() + logFwd := &toggleWriter{w: os.Stderr} + logFwd.enabled.Store(true) + var err error + agent, err = startSimulationAgent(config, logFwd) + if err != nil { + fmt.Fprintf(os.Stdout, "✗ Failed to start agent: %v\n", err) + fmt.Fprintln(os.Stdout, "::endgroup::") + return fmt.Errorf("failed to start agent: %w", err) + } + + fmt.Fprintf(os.Stderr, "Waiting for agent to register...\n") + timeout := time.NewTimer(agentRegisterTimeout) + defer timeout.Stop() + select { + case <-agent.Ready(): + logFwd.enabled.Store(false) + fmt.Fprintf(os.Stdout, "✓ Agent registered (%s)\n", time.Since(start).Round(time.Millisecond)) + case err := <-agent.Done(): + fmt.Fprintln(os.Stdout, "::endgroup::") + if err != nil { + return fmt.Errorf("agent exited before registering: %w", err) + } + return fmt.Errorf("agent exited before registering") + case <-timeout.C: + fmt.Fprintln(os.Stdout, "::endgroup::") + return fmt.Errorf("timed out waiting for agent to register (%s)", agentRegisterTimeout) + case <-ctx.Done(): + fmt.Fprintln(os.Stdout, "::endgroup::") + return ctx.Err() + } + + start = time.Now() + var presigned *livekit.PresignedPostRequest + runID, presigned, err = createSimulationRun(ctx, config) + if err != nil { + fmt.Fprintf(os.Stdout, "✗ %v\n", err) + fmt.Fprintln(os.Stdout, "::endgroup::") + return err + } + fmt.Fprintf(os.Stdout, "✓ Simulation created (%s)\n", time.Since(start).Round(time.Millisecond)) + + if config.mode == modeGenerateFromSource { + start = time.Now() + if err := uploadSource(ctx, config.client, runID, presigned, config.projectDir, config.entrypoint); err != nil { + fmt.Fprintf(os.Stdout, "✗ %v\n", err) + fmt.Fprintln(os.Stdout, "::endgroup::") + return err + } + fmt.Fprintf(os.Stdout, "✓ Source uploaded (%s)\n", time.Since(start).Round(time.Millisecond)) + } + + fmt.Fprintln(os.Stdout, "::endgroup::") + fmt.Fprintln(os.Stdout) + + fmt.Fprintf(os.Stdout, "Run: %s\n", runID) + if url := simulationDashboardURL(config.pc.ProjectId, runID); url != "" { + fmt.Fprintf(os.Stdout, "Dashboard: %s\n", url) + } + fmt.Fprintln(os.Stdout) + + // --- Poll until terminal --- + + var run *livekit.SimulationRun + prevDone := 0 + prevStatus := livekit.SimulationRun_STATUS_GENERATING + ticker := time.NewTicker(simulationPollInterval) + defer ticker.Stop() + + for { + pollCtx, pollCancel := context.WithTimeout(ctx, simulationAPITimeout) + run, err = getSimulationRun(pollCtx, config.client, runID) + pollCancel() + + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + fmt.Fprintf(os.Stderr, "Warning: poll failed: %v\n", err) + } else { + _, done, _, _ := simulationJobCounts(run) + total := len(run.Jobs) + + switch run.Status { + case livekit.SimulationRun_STATUS_GENERATING: + if prevStatus != run.Status { + n := config.numSimulations + if run.GetNumSimulations() > 0 { + n = run.GetNumSimulations() + } + fmt.Fprintf(os.Stderr, "Generating %d scenarios...\n", n) + } + case livekit.SimulationRun_STATUS_RUNNING: + if prevStatus == livekit.SimulationRun_STATUS_GENERATING { + if desc := run.GetAgentDescription(); desc != "" { + fmt.Fprintf(os.Stdout, "Agent: %s\n\n", desc) + } + } + if done != prevDone { + fmt.Fprintf(os.Stderr, "Running simulations... %d/%d completed\n", done, total) + prevDone = done + } + case livekit.SimulationRun_STATUS_SUMMARIZING: + if prevStatus != run.Status { + fmt.Fprintf(os.Stderr, "Summarizing...\n") + } + } + prevStatus = run.Status + + if isTerminalRunStatus(run.Status) { + runFinished = true + break + } + } + + select { + case <-ticker.C: + case <-ctx.Done(): + return ctx.Err() + } + } + + // --- Results --- + + printCIResults(run, agent) + + if agent != nil && agent.LogPath != "" { + fmt.Fprintf(os.Stderr, "Agent logs: %s\n", agent.LogPath) + } + if url := simulationDashboardURL(config.pc.ProjectId, runID); url != "" { + fmt.Fprintf(os.Stderr, "Dashboard: %s\n", url) + } + + _, _, _, failed := simulationJobCounts(run) + if failed > 0 || run.Status == livekit.SimulationRun_STATUS_FAILED { + if isGitHubActions() { + if failed > 0 { + fmt.Fprintf(os.Stdout, "::error::%d simulation(s) failed\n", failed) + } else { + fmt.Fprintf(os.Stdout, "::error::Simulation run failed: %s\n", run.Error) + } + } + if run.Status == livekit.SimulationRun_STATUS_FAILED && len(run.Jobs) == 0 { + return fmt.Errorf("simulation failed: %s", run.Error) + } + return fmt.Errorf("%d of %d simulations failed", failed, len(run.Jobs)) + } + + return nil +} + +func printCIResults(run *livekit.SimulationRun, agent *AgentProcess) { + if run == nil { + return + } + + if run.Status == livekit.SimulationRun_STATUS_FAILED && len(run.Jobs) == 0 { + fmt.Fprintf(os.Stdout, "✗ Simulation failed: %s\n", run.Error) + return + } + + for i, job := range run.Jobs { + icon := "⏺" + switch job.Status { + case livekit.SimulationRun_Job_STATUS_COMPLETED: + icon = "✓" + case livekit.SimulationRun_Job_STATUS_FAILED: + icon = "✗" + } + + label := job.Label + if label == "" { + label = fmt.Sprintf("Job %d", i+1) + } + + fmt.Fprintf(os.Stdout, "::group::%s %s\n", icon, label) + + if job.Instructions != "" { + fmt.Fprintln(os.Stdout, "Instructions:") + for _, line := range strings.Split(job.Instructions, "\n") { + fmt.Fprintf(os.Stdout, " %s\n", line) + } + } + + if job.AgentExpectations != "" { + fmt.Fprintln(os.Stdout, "Expected:") + for _, line := range strings.Split(job.AgentExpectations, "\n") { + fmt.Fprintf(os.Stdout, " %s\n", line) + } + } + + if job.Error != "" { + if job.Status == livekit.SimulationRun_Job_STATUS_COMPLETED { + fmt.Fprintf(os.Stdout, "Result: %s\n", job.Error) + } else { + fmt.Fprintf(os.Stdout, "Error: %s\n", job.Error) + } + } + + if run.Summary != nil && run.Summary.ChatHistory != nil { + printCIChatHistory(run.Summary.ChatHistory[job.Id]) + } + + if agent != nil && job.RoomName != "" { + logs := agent.RecentRoomLogs(0, job.RoomName) + if len(logs) > 0 { + fmt.Fprintln(os.Stdout, "Logs:") + for _, line := range logs { + fmt.Fprintf(os.Stdout, " %s\n", line) + } + } + } + + fmt.Fprintln(os.Stdout, "::endgroup::") + + if job.Status == livekit.SimulationRun_Job_STATUS_FAILED && isGitHubActions() { + firstLine := strings.SplitN(job.Error, "\n", 2)[0] + fmt.Fprintf(os.Stdout, "::error::Job %d failed: %s\n", i+1, firstLine) + } + } + + if run.Summary != nil { + printCISummary(run) + } else { + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, "⚠ The summary for this run is not available") + } +} + +func printCISummary(run *livekit.SimulationRun) { + summary := run.Summary + total, _, passed, failed := simulationJobCounts(run) + + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, "::group::Summary") + fmt.Fprintf(os.Stdout, "%d total, %d passed, %d failed\n", total, passed, failed) + + if summary.GoingWell != "" { + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, "Going well:") + for _, line := range strings.Split(summary.GoingWell, "\n") { + fmt.Fprintf(os.Stdout, " %s\n", line) + } + } + + if summary.ToImprove != "" { + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, "To improve:") + for _, line := range strings.Split(summary.ToImprove, "\n") { + fmt.Fprintf(os.Stdout, " %s\n", line) + } + } + + if len(summary.Issues) > 0 { + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, "Issues:") + for i, issue := range summary.Issues { + fmt.Fprintf(os.Stdout, " %d. %s\n", i+1, issue.Description) + if issue.Suggestion != "" { + fmt.Fprintf(os.Stdout, " Suggestion: %s\n", issue.Suggestion) + } + } + } + + fmt.Fprintln(os.Stdout, "::endgroup::") +} + +func printCIChatHistory(chatCtx *agent.ChatContext) { + if chatCtx == nil || len(chatCtx.Items) == 0 { + return + } + fmt.Fprintln(os.Stdout, "Transcript:") + for _, item := range chatCtx.Items { + switch v := item.Item.(type) { + case *agent.ChatContext_ChatItem_Message: + msg := v.Message + text := ciChatText(msg) + if text == "" { + continue + } + switch msg.Role { + case agent.ChatRole_USER: + fmt.Fprintf(os.Stdout, " ● You\n") + case agent.ChatRole_ASSISTANT: + fmt.Fprintf(os.Stdout, " ● Agent\n") + default: + fmt.Fprintf(os.Stdout, " ● %s\n", msg.Role) + } + for _, tl := range strings.Split(text, "\n") { + fmt.Fprintf(os.Stdout, " %s\n", tl) + } + case *agent.ChatContext_ChatItem_FunctionCall: + fc := v.FunctionCall + args := fc.Arguments + if len(args) > 80 { + args = args[:80] + "..." + } + fmt.Fprintf(os.Stdout, " [call] %s(%s)\n", fc.Name, args) + case *agent.ChatContext_ChatItem_FunctionCallOutput: + fco := v.FunctionCallOutput + output := fco.Output + if len(output) > 80 { + output = output[:80] + "..." + } + label := "output" + if fco.IsError { + label = "error" + } + fmt.Fprintf(os.Stdout, " [%s] %s -> %s\n", label, fco.Name, output) + case *agent.ChatContext_ChatItem_AgentHandoff: + h := v.AgentHandoff + fmt.Fprintf(os.Stdout, " [handoff] -> %s\n", h.NewAgentId) + } + } +} + +func ciChatText(msg *agent.ChatMessage) string { + if msg == nil || len(msg.Content) == 0 { + return "" + } + var parts []string + for _, c := range msg.Content { + if t := c.GetText(); t != "" { + parts = append(parts, t) + } + } + return strings.Join(parts, "") +} + +func isGitHubActions() bool { + return os.Getenv("GITHUB_ACTIONS") != "" +} diff --git a/cmd/lk/simulate_matrix.go b/cmd/lk/simulate_matrix.go index 6570a2d0..7160f277 100644 --- a/cmd/lk/simulate_matrix.go +++ b/cmd/lk/simulate_matrix.go @@ -48,9 +48,6 @@ type matrixRow struct { iconCol int // -1 if no icon cell on this row iconCh rune iconStyle *lipgloss.Style // applied when rain is not on iconCol - dimStart int // rune offset; -1 if no dim run - dimEnd int // half-open - dimStyle *lipgloss.Style // applied to cells in [dimStart, dimEnd) cursorMarker bool // render a ▸ in col 0 when true } @@ -63,7 +60,6 @@ const ( mcRainT2 mcRainT3 mcIcon - mcDim mcCursor ) @@ -243,7 +239,6 @@ func (r *matrixRain) render(rows []matrixRow) string { for row := 0; row < r.height; row++ { var rd matrixRow rd.iconCol = -1 - rd.dimStart = -1 if row < len(rows) { rd = rows[row] } @@ -275,9 +270,6 @@ func (r *matrixRain) render(rows []matrixRow) string { case col == rd.iconCol && rd.iconStyle != nil: cats[col] = mcIcon runes[col] = rd.iconCh - case rd.dimStart >= 0 && col >= rd.dimStart && col < rd.dimEnd && col < len(rd.text): - cats[col] = mcDim - runes[col] = rd.text[col] case col < len(rd.text): cats[col] = mcPlain runes[col] = rd.text[col] @@ -290,7 +282,7 @@ func (r *matrixRain) render(rows []matrixRow) string { runStart := 0 for col := 1; col <= r.width; col++ { if col == r.width || cats[col] != cats[runStart] { - writeMatrixRun(&b, cats[runStart], runes[runStart:col], rd.iconStyle, rd.dimStyle) + writeMatrixRun(&b, cats[runStart], runes[runStart:col], rd.iconStyle) runStart = col } } @@ -302,7 +294,7 @@ func (r *matrixRain) render(rows []matrixRow) string { return b.String() } -func writeMatrixRun(b *strings.Builder, cat int, rs []rune, iconStyle, dimStyle *lipgloss.Style) { +func writeMatrixRun(b *strings.Builder, cat int, rs []rune, iconStyle *lipgloss.Style) { if len(rs) == 0 { return } @@ -322,12 +314,6 @@ func writeMatrixRun(b *strings.Builder, cat int, rs []rune, iconStyle, dimStyle } else { b.WriteString(s) } - case mcDim: - if dimStyle != nil { - b.WriteString(dimStyle.Render(s)) - } else { - b.WriteString(s) - } case mcCursor: b.WriteString(matrixCursorMarkerStyle.Render(s)) default: diff --git a/cmd/lk/simulate_save.go b/cmd/lk/simulate_save.go index 318da753..d67c79b0 100644 --- a/cmd/lk/simulate_save.go +++ b/cmd/lk/simulate_save.go @@ -298,8 +298,7 @@ func (s *saveOverlay) render() string { case savePhaseList: b.WriteString("\n") for i, g := range s.groups { - count := len(g.Scenarios) - label := fmt.Sprintf("%s (%d scenarios)", g.Label, count) + label := g.Label if i == s.cursor { b.WriteString(fmt.Sprintf(" %s %s\n", saveSelectedStyle.Render(">"), saveSelectedStyle.Render(label))) } else { diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 4e7c9985..52e48744 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -16,11 +16,13 @@ package main import ( "bufio" + "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "syscall" @@ -40,11 +42,12 @@ type AgentProcess struct { // LogStream receives log lines in real-time. Nil if not needed. LogStream chan string - mu sync.Mutex - logLines []string - maxLogs int - logFile *os.File - LogPath string + mu sync.Mutex + logLines []string + roomLogs map[string][]string + latestRoomByPx map[string]string // prefix → latest room name seen + logFile *os.File + LogPath string } // findPythonBinary locates a Python binary for the given project type. @@ -160,13 +163,14 @@ func startAgent(cfg AgentStartConfig) (*AgentProcess, error) { } ap := &AgentProcess{ - cmd: cmd, - readyCh: make(chan struct{}), - doneCh: make(chan error, 1), - exitCh: make(chan struct{}), - maxLogs: 200, - logFile: logFile, - LogPath: logFile.Name(), + cmd: cmd, + readyCh: make(chan struct{}), + doneCh: make(chan error, 1), + exitCh: make(chan struct{}), + roomLogs: make(map[string][]string), + latestRoomByPx: make(map[string]string), + logFile: logFile, + LogPath: logFile.Name(), } if err := cmd.Start(); err != nil { @@ -217,8 +221,9 @@ func (ap *AgentProcess) appendLog(line string) { ap.mu.Lock() defer ap.mu.Unlock() ap.logLines = append(ap.logLines, line) - if len(ap.logLines) > ap.maxLogs { - ap.logLines = ap.logLines[len(ap.logLines)-ap.maxLogs:] + if room := extractLogRoom(line); room != "" { + ap.roomLogs[room] = append(ap.roomLogs[room], line) + ap.latestRoomByPx[roomNamePrefix(room)] = room } if ap.logFile != nil { fmt.Fprintln(ap.logFile, line) @@ -241,11 +246,11 @@ func (ap *AgentProcess) Done() <-chan error { return ap.doneCh } -// RecentLogs returns the last n log lines from the subprocess. +// RecentLogs returns the last n log lines from the subprocess. If n <= 0, returns all lines. func (ap *AgentProcess) RecentLogs(n int) []string { ap.mu.Lock() defer ap.mu.Unlock() - if n >= len(ap.logLines) { + if n <= 0 || n >= len(ap.logLines) { result := make([]string, len(ap.logLines)) copy(result, ap.logLines) return result @@ -262,6 +267,81 @@ func (ap *AgentProcess) LogCount() int { return len(ap.logLines) } +// RecentRoomLogs returns the last n log lines for a specific room. If n <= 0, returns all lines. +func (ap *AgentProcess) RecentRoomLogs(n int, roomName string) []string { + ap.mu.Lock() + defer ap.mu.Unlock() + lines := ap.roomLogs[roomName] + if n <= 0 || n >= len(lines) { + result := make([]string, len(lines)) + copy(result, lines) + return result + } + result := make([]string, n) + copy(result, lines[len(lines)-n:]) + return result +} + +// RoomLogCount returns the number of log lines for a specific room. +func (ap *AgentProcess) RoomLogCount(roomName string) int { + ap.mu.Lock() + defer ap.mu.Unlock() + return len(ap.roomLogs[roomName]) +} + +// roomNamePrefix returns the stable part of a simulation room name (before the random suffix). +// e.g. "sim-SRJ_xxx-RANDOM" → "sim-SRJ_xxx-" +func roomNamePrefix(roomName string) string { + idx := strings.LastIndex(roomName, "-") + if idx < 0 { + return roomName + } + return roomName[:idx+1] +} + +// RecentRoomLogsByPrefix returns log lines for the most recent room matching +// the prefix of the given room name. When a job is retried, each attempt gets +// a new room with the same prefix — we show only the latest attempt's logs. +func (ap *AgentProcess) RecentRoomLogsByPrefix(n int, roomName string) []string { + ap.mu.Lock() + defer ap.mu.Unlock() + latest := ap.latestRoomByPx[roomNamePrefix(roomName)] + if latest == "" { + return nil + } + lines := ap.roomLogs[latest] + if n <= 0 || n >= len(lines) { + result := make([]string, len(lines)) + copy(result, lines) + return result + } + result := make([]string, n) + copy(result, lines[len(lines)-n:]) + return result +} + +var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func extractLogRoom(line string) string { + clean := ansiEscapeRe.ReplaceAllString(line, "") + idx := strings.LastIndex(clean, "{") + if idx < 0 { + return "" + } + end := strings.LastIndex(clean, "}") + if end <= idx { + return "" + } + var extra map[string]any + if err := json.Unmarshal([]byte(clean[idx:end+1]), &extra); err != nil { + return "" + } + if room, ok := extra["room"].(string); ok { + return room + } + return "" +} + // Kill sends SIGINT to the process group and SIGKILL after a timeout. // If Shutdown() was already called, it just waits for exit (no duplicate SIGINT). func (ap *AgentProcess) Kill() { diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index a2746420..611701b1 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -15,7 +15,6 @@ package main import ( - "bytes" "context" "fmt" "os" @@ -25,14 +24,38 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/livekit/livekit-cli/v2/pkg/agentfs" - "github.com/livekit/livekit-cli/v2/pkg/config" - "github.com/livekit/server-sdk-go/v2/pkg/cloudagents" agent "github.com/livekit/protocol/livekit/agent" "github.com/livekit/protocol/livekit" - lksdk "github.com/livekit/server-sdk-go/v2" ) +func runSimulateTUI(config *simulateConfig) error { + m := newSimulateModel(config) + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("TUI error: %w", err) + } + + if m.agent != nil { + m.agent.Kill() + if m.agent.LogPath != "" { + fmt.Fprintf(os.Stderr, "Agent logs: %s\n", m.agent.LogPath) + } + } + + if url := m.getDashboardURL(); url != "" { + fmt.Fprintf(os.Stderr, "Dashboard: %s\n", url) + } + + if m.runID != "" && !m.runFinished { + cancelSimulationRun(config.client, m.runID) + } + + if m.err != nil && m.err != context.Canceled { + return m.err + } + return nil +} + // --- Styles --- var ( @@ -64,19 +87,6 @@ type subprocessExitMsg struct { // --- Filter --- -const ( - agentRegisterTimeout = 20 * time.Second -) - -const ( - filterAll = iota - filterFailed - filterPassed - filterRunning -) - -var filterNames = []string{"All", "Failed", "Passed", "Running"} - // --- Model --- type step struct { @@ -85,25 +95,9 @@ type step struct { elapsed time.Duration } -type simulateConfig struct { - ctx context.Context - client *lksdk.AgentSimulationClient - pc *config.ProjectConfig - numSimulations int32 - mode simulateMode - description string - agentName string - projectDir string - projectType agentfs.ProjectType - entrypoint string - cfg *simulationConfig - scenarioGroupID string -} - type simulateModel struct { - config *simulateConfig - client *lksdk.AgentSimulationClient - runID string + config *simulateConfig + runID string agent *AgentProcess setupCtx context.Context setupCancel context.CancelFunc @@ -124,11 +118,14 @@ type simulateModel struct { spinnerIdx int - filter int cursor int scrollOff int detailJobID string + detailScrollOff int showLogs bool + logScrollOff int + logPinned bool + logPinnedTotal int showDescription bool save saveOverlay @@ -144,7 +141,6 @@ type simulateModel struct { func newSimulateModel(config *simulateConfig) *simulateModel { return &simulateModel{ config: config, - client: config.client, numSimulations: config.numSimulations, width: 80, height: 24, @@ -227,25 +223,7 @@ func (m *simulateModel) completeSetup(elapsed time.Duration) { func (m *simulateModel) startAgentCmd() tea.Cmd { c := m.config return func() tea.Msg { - agent, err := startAgent(AgentStartConfig{ - Dir: c.projectDir, - Entrypoint: c.entrypoint, - ProjectType: c.projectType, - CLIArgs: []string{ - "start", - "--url", c.pc.URL, - "--api-key", c.pc.APIKey, - "--api-secret", c.pc.APISecret, - "--log-format", "colored", - }, - Env: []string{ - "LIVEKIT_AGENT_NAME=" + c.agentName, - "LIVEKIT_URL=" + c.pc.URL, - "LIVEKIT_API_KEY=" + c.pc.APIKey, - "LIVEKIT_API_SECRET=" + c.pc.APISecret, - }, - ReadySignal: "registered worker", - }) + agent, err := startSimulationAgent(c, nil) return agentStartedMsg{agent: agent, err: err} } } @@ -273,45 +251,10 @@ func (m *simulateModel) waitAgentReadyCmd() tea.Cmd { } func (m *simulateModel) createSimulationCmd() tea.Cmd { - c := m.config return func() tea.Msg { start := time.Now() - req := &livekit.SimulationRun_Create_Request{ - AgentName: c.agentName, - AgentDescription: c.description, - NumSimulations: c.numSimulations, - } - switch c.mode { - case modeInlineScenarios: - scenarios := make([]*livekit.SimulationRun_Create_Scenario, 0, len(c.cfg.Scenarios)) - for _, sc := range c.cfg.Scenarios { - scenarios = append(scenarios, &livekit.SimulationRun_Create_Scenario{ - Label: sc.Label, - Instructions: sc.Instructions, - AgentExpectations: sc.AgentExpectations, - Metadata: sc.Metadata, - }) - } - req.Source = &livekit.SimulationRun_Create_Request_Scenarios{ - Scenarios: &livekit.SimulationRun_Create_Scenarios{ - Scenarios: scenarios, - }, - } - case modeScenarioGroup: - req.Source = &livekit.SimulationRun_Create_Request_GroupId{ - GroupId: c.scenarioGroupID, - } - } - - resp, err := c.client.CreateSimulationRun(m.setupCtx, req) - if err != nil { - return simulationCreatedMsg{err: fmt.Errorf("failed to create simulation: %w", err)} - } - return simulationCreatedMsg{ - runID: resp.SimulationRunId, - presigned: resp.PresignedPostRequest, - elapsed: time.Since(start), - } + runID, presigned, err := createSimulationRun(m.setupCtx, m.config) + return simulationCreatedMsg{runID: runID, presigned: presigned, elapsed: time.Since(start), err: err} } } @@ -319,29 +262,13 @@ func (m *simulateModel) uploadSourceCmd(presigned *livekit.PresignedPostRequest) c := m.config return func() tea.Msg { start := time.Now() - if presigned == nil { - return sourceUploadedMsg{err: fmt.Errorf("server did not return upload URL")} - } - - sourceDir, _ := os.Getwd() - var buf bytes.Buffer - if err := cloudagents.CreateSourceTarball(os.DirFS(sourceDir), nil, &buf); err != nil { - return sourceUploadedMsg{err: fmt.Errorf("failed to create source archive: %w", err)} - } - if err := cloudagents.MultipartUpload(presigned.Url, presigned.Values, &buf); err != nil { - return sourceUploadedMsg{err: fmt.Errorf("failed to upload source: %w", err)} - } - if _, err := c.client.ConfirmSimulationSourceUpload(m.setupCtx, &livekit.SimulationRun_ConfirmSourceUpload_Request{ - SimulationRunId: m.runID, - }); err != nil { - return sourceUploadedMsg{err: fmt.Errorf("failed to confirm upload: %w", err)} - } - return sourceUploadedMsg{elapsed: time.Since(start)} + err := uploadSource(m.setupCtx, c.client, m.runID, presigned, c.projectDir, c.entrypoint) + return sourceUploadedMsg{elapsed: time.Since(start), err: err} } } func tickCmd() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tea.Tick(simulationPollInterval, func(t time.Time) tea.Msg { return pollTickMsg{} }) } @@ -354,15 +281,10 @@ func spinnerTickCmd() tea.Cmd { func (m *simulateModel) pollSimulation() tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), simulationAPITimeout) defer cancel() - resp, err := m.client.GetSimulationRun(ctx, &livekit.SimulationRun_Get_Request{ - SimulationRunId: m.runID, - }) - if err != nil { - return simulationRunMsg{err: err} - } - return simulationRunMsg{run: resp.Run} + run, err := getSimulationRun(ctx, m.config.client, m.runID) + return simulationRunMsg{run: run, err: err} } } @@ -429,9 +351,7 @@ func (m *simulateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.startTime.IsZero() && msg.run.Status == livekit.SimulationRun_STATUS_RUNNING { m.startTime = time.Now() } - if msg.run.Status == livekit.SimulationRun_STATUS_COMPLETED || - msg.run.Status == livekit.SimulationRun_STATUS_FAILED || - msg.run.Status == livekit.SimulationRun_STATUS_CANCELLED { + if isTerminalRunStatus(msg.run.Status) { if !m.runFinished { m.endTime = time.Now() } @@ -545,48 +465,85 @@ func (m *simulateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, matrixTickCmd() case "ctrl+l": m.showLogs = !m.showLogs + m.logScrollOff = 0 + m.logPinned = false case "d": if m.detailJobID == "" { m.showDescription = !m.showDescription } case "up", "shift+tab": - m.cursor-- + if m.detailJobID != "" { + m.detailScrollOff++ + } else { + jobs := m.filteredJobs() + if len(jobs) > 0 { + m.cursor-- + if m.cursor < 0 { + m.cursor = len(jobs) - 1 + } + } + } case "down", "tab": - m.cursor++ + if m.detailJobID != "" { + if m.detailScrollOff > 0 { + m.detailScrollOff-- + } + } else { + jobs := m.filteredJobs() + if len(jobs) > 0 { + m.cursor++ + if m.cursor >= len(jobs) { + m.cursor = 0 + } + } + } case "pgup": - m.cursor -= 20 + if m.detailJobID != "" { + m.detailScrollOff -= 20 + if m.detailScrollOff < 0 { + m.detailScrollOff = 0 + } + } else if m.showLogs { + m.logScrollOff += 20 + m.logPinned = true + } case "pgdown": - m.cursor += 20 - case "left": - m.filter = (m.filter + len(filterNames) - 1) % len(filterNames) - m.cursor = 0 - m.scrollOff = 0 - case "right": - m.filter = (m.filter + 1) % len(filterNames) - m.cursor = 0 - m.scrollOff = 0 + if m.detailJobID != "" { + m.detailScrollOff += 20 + } else if m.showLogs { + m.logScrollOff -= 20 + if m.logScrollOff < 0 { + m.logScrollOff = 0 + } + if m.logScrollOff == 0 { + m.logPinned = false + } + } case "enter": if m.detailJobID == "" { jobs := m.filteredJobs() if m.cursor >= 0 && m.cursor < len(jobs) { m.detailJobID = jobs[m.cursor].job.Id + m.detailScrollOff = 0 } } case "s": if m.detailJobID != "" { job := m.findJob(m.detailJobID) if job != nil { - m.save.start(m.client, job, m.width) + m.save.start(m.config.client, job, m.width) return m, tea.Batch(m.save.fetchGroupsCmd(), saveSpinnerTickCmd()) } } case "esc", "backspace": if m.detailJobID != "" { m.detailJobID = "" + m.detailScrollOff = 0 } case "q": if m.detailJobID != "" { m.detailJobID = "" + m.detailScrollOff = 0 } } return m, nil @@ -601,22 +558,9 @@ func (m *simulateModel) filteredJobs() []indexedJob { if m.run == nil { return nil } - var result []indexedJob + result := make([]indexedJob, 0, len(m.run.Jobs)) for i, j := range m.run.Jobs { - match := false - switch m.filter { - case filterAll: - match = true - case filterFailed: - match = j.Status == livekit.SimulationRun_Job_STATUS_FAILED - case filterPassed: - match = j.Status == livekit.SimulationRun_Job_STATUS_COMPLETED - case filterRunning: - match = j.Status == livekit.SimulationRun_Job_STATUS_RUNNING - } - if match { - result = append(result, indexedJob{origIdx: i + 1, job: j}) - } + result = append(result, indexedJob{origIdx: i + 1, job: j}) } return result } @@ -686,7 +630,7 @@ func (m *simulateModel) viewSetup() string { b.WriteString(redStyle.Render(" "+m.err.Error()) + "\n") if m.agent != nil { b.WriteString("\n") - b.WriteString(m.renderLogs()) + b.WriteString(m.renderLogs("")) } b.WriteString("\n") b.WriteString(dimStyle.Render("")) @@ -694,7 +638,7 @@ func (m *simulateModel) viewSetup() string { } else { b.WriteString("\n") if m.agent != nil { - b.WriteString(m.renderLogs()) + b.WriteString(m.renderLogs("")) } } return b.String() @@ -731,10 +675,10 @@ func (m *simulateModel) renderSteps() string { } func (m *simulateModel) getDashboardURL() string { - if m.runID == "" || m.config == nil || m.config.pc == nil || m.config.pc.ProjectId == "" { + if m.config == nil || m.config.pc == nil { return "" } - return fmt.Sprintf("%s/projects/%s/agents/simulations/%s", dashboardURL, m.config.pc.ProjectId, m.runID) + return simulationDashboardURL(m.config.pc.ProjectId, m.runID) } func (m *simulateModel) viewFailed() string { @@ -754,7 +698,7 @@ func (m *simulateModel) viewFailed() string { } b.WriteString("\n") if m.showLogs { - b.WriteString(m.renderLogs()) + b.WriteString(m.renderLogs("")) } if m.hasLogs() { b.WriteString(dimStyle.Render(" Ctrl+L logs ")) @@ -777,8 +721,8 @@ func (m *simulateModel) viewRunning() string { } b.WriteString("\n\n") - // Agent description - if m.run != nil && m.run.AgentDescription != "" { + // Agent description (hidden in detail view) + if m.detailJobID == "" && m.run != nil && m.run.AgentDescription != "" { b.WriteString(boldStyle.Render(" Agent Description") + "\n") if m.showDescription { wrapped := dimStyle.Width(m.width - 4).Render(m.run.AgentDescription) @@ -803,28 +747,33 @@ func (m *simulateModel) viewRunning() string { b.WriteString(m.renderCounts()) b.WriteString("\n") - // Filter tabs - b.WriteString(m.renderFilterTabs()) - b.WriteString("\n\n") + b.WriteString("\n") - if m.save.active { + if m.detailJobID != "" { + if m.save.active { + b.WriteString(m.save.render()) + } else { + b.WriteString(m.scrolledDetail()) + } + } else if m.save.active { b.WriteString(m.save.render()) - } else if m.detailJobID != "" { - b.WriteString(m.renderDetail()) } else if m.matrix.active { b.WriteString(m.matrix.render(m.buildMatrixRows())) } else { b.WriteString(m.renderJobList()) - // Show summary when run is completed and summary is available - if m.run.Summary != nil { + if m.run.Status == livekit.SimulationRun_STATUS_SUMMARIZING { + b.WriteString(fmt.Sprintf("\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) { + b.WriteString(fmt.Sprintf("\n %s %s\n", yellowStyle.Render("⚠"), yellowStyle.Render("The summary for this run is not available"))) } } b.WriteString("\n") - if m.showLogs { - b.WriteString(m.renderLogs()) + if m.showLogs && m.detailJobID == "" { + b.WriteString(m.renderLogs("")) } b.WriteString(m.renderHint()) b.WriteString("\n") @@ -834,8 +783,8 @@ func (m *simulateModel) viewRunning() string { func (m *simulateModel) renderHeader() string { var label, style string switch { - case m.run.Status == livekit.SimulationRun_STATUS_COMPLETED || m.run.Status == livekit.SimulationRun_STATUS_FAILED || m.run.Status == livekit.SimulationRun_STATUS_CANCELLED: - total, done, _, _ := m.jobCounts() + case isTerminalRunStatus(m.run.Status): + total, done, _, _ := simulationJobCounts(m.run) allJobsDone := total > 0 && done == total if m.run.Status == livekit.SimulationRun_STATUS_CANCELLED { label = "Cancelled" @@ -846,9 +795,6 @@ func (m *simulateModel) renderHeader() string { } else { label = "Completed" style = "green" - if allJobsDone && m.run.Summary == nil { - label += " — summary unavailable" - } } case m.run.Status == livekit.SimulationRun_STATUS_SUMMARIZING: label = "Summarizing..." @@ -870,26 +816,9 @@ func (m *simulateModel) renderHeader() string { return " " + header } -func (m *simulateModel) jobCounts() (total, done, passed, failed int) { - if m.run == nil { - return - } - total = len(m.run.Jobs) - for _, j := range m.run.Jobs { - switch j.Status { - case livekit.SimulationRun_Job_STATUS_COMPLETED: - done++ - passed++ - case livekit.SimulationRun_Job_STATUS_FAILED: - done++ - failed++ - } - } - return -} func (m *simulateModel) renderCounts() string { - total, done, passed, failed := m.jobCounts() + total, done, passed, failed := simulationJobCounts(m.run) running := 0 if m.run != nil { for _, j := range m.run.Jobs { @@ -936,32 +865,6 @@ func (m *simulateModel) renderCounts() string { return result } -func (m *simulateModel) renderFilterTabs() string { - total, _, passed, failed := m.jobCounts() - running := 0 - if m.run != nil { - for _, j := range m.run.Jobs { - if j.Status == livekit.SimulationRun_Job_STATUS_RUNNING { - running++ - } - } - } - - counts := []int{total, failed, passed, running} - styles := []lipgloss.Style{lipgloss.NewStyle(), redStyle, greenStyle, yellowStyle} - - var parts []string - for i, name := range filterNames { - label := fmt.Sprintf("%s: %d", name, counts[i]) - if i == m.filter { - parts = append(parts, styles[i].Bold(true).Render(label)) - } else { - parts = append(parts, dimStyle.Render(label)) - } - } - return " " + strings.Join(parts, " ") -} - // visibleWindow clamps m.cursor / m.scrollOff against the current filtered // job list and returns the visible slice plus overflow counts. func (m *simulateModel) visibleWindow() (jobs []indexedJob, winStart, winEnd, overflowAbove, overflowBelow int) { @@ -976,6 +879,13 @@ func (m *simulateModel) visibleWindow() (jobs []indexedJob, winStart, winEnd, ov m.cursor = len(jobs) - 1 } availHeight := matrixAvailHeight(m.height) + maxJobListHeight := m.height * 2 / 3 + if maxJobListHeight < 5 { + maxJobListHeight = 5 + } + if availHeight > maxJobListHeight { + availHeight = maxJobListHeight + } if m.cursor < m.scrollOff { m.scrollOff = m.cursor } else if m.cursor >= m.scrollOff+availHeight { @@ -1022,19 +932,9 @@ func (m *simulateModel) renderJobList() string { maxWidth := 0 for i := winStart; i < winEnd; i++ { ij := jobs[i] - label := ij.job.Label - if label == "" { - label = ij.job.Instructions - if len(label) > 60 { - label = label[:60] + "..." - } - } - if label == "" { - label = "—" - } - row := rowData{ij: ij, label: label} + row := rowData{ij: ij, label: jobLabel(ij.job)} rows = append(rows, row) - w := lipgloss.Width(fmt.Sprintf(" ⏺ %3d. %s", ij.origIdx+1, label)) + w := lipgloss.Width(fmt.Sprintf(" ⏺ %3d. %s %s", ij.origIdx+1, ij.job.Id, row.label)) if w > maxWidth { maxWidth = w } @@ -1045,14 +945,14 @@ func (m *simulateModel) renderJobList() string { plainIcon := string(plainJobIcon(row.ij.job)) var line string if idx == m.cursor { - line = fmt.Sprintf(" %s %3d. %s", plainIcon, row.ij.origIdx+1, row.label) + line = fmt.Sprintf(" %s %3d. %s %s", plainIcon, row.ij.origIdx+1, row.ij.job.Id, row.label) if pad := maxWidth - lipgloss.Width(line); pad > 0 { line += strings.Repeat(" ", pad) } line = reverseStyle.Render(line) } else { icon := jobIcon(row.ij.job) - line = fmt.Sprintf(" %s %3d. %s", icon, row.ij.origIdx+1, row.label) + line = fmt.Sprintf(" %s %3d. %s %s", icon, row.ij.origIdx+1, dimStyle.Render(row.ij.job.Id), row.label) } b.WriteString(line) b.WriteString("\n") @@ -1073,6 +973,20 @@ func (m *simulateModel) renderJobList() string { // cursor marker). This file provides the mapping from a simulation's job list // into that neutral data shape. +func jobLabel(job *livekit.SimulationRun_Job) string { + label := job.Label + if label == "" { + label = job.Instructions + if len(label) > 60 { + label = label[:60] + "..." + } + } + if label == "" { + label = "—" + } + return label +} + func plainJobIcon(job *livekit.SimulationRun_Job) rune { switch job.Status { case livekit.SimulationRun_Job_STATUS_COMPLETED: @@ -1100,42 +1014,29 @@ func jobIconStylePtr(job *livekit.SimulationRun_Job) *lipgloss.Style { } // buildMatrixRows produces one matrixRow per visible line of the job list, -// mirroring renderJobList's layout but in a form the matrix frame can consume -// cell-by-cell. +// using the same label logic as renderJobList. func (m *simulateModel) buildMatrixRows() []matrixRow { jobs, winStart, winEnd, above, below := m.visibleWindow() if len(jobs) == 0 { - return []matrixRow{{text: []rune(" (no jobs match this filter)"), iconCol: -1, dimStart: -1}} + return []matrixRow{{text: []rune(" (no jobs)"), iconCol: -1}} } var rows []matrixRow if above > 0 { rows = append(rows, matrixRow{ text: []rune(fmt.Sprintf(" ... %d more above ...", above)), iconCol: -1, - dimStart: -1, }) } for i := winStart; i < winEnd; i++ { ij := jobs[i] - instr := ij.job.Instructions - if len(instr) > 60 { - instr = instr[:60] + "..." - } - if instr == "" { - instr = "—" - } + label := jobLabel(ij.job) iconCh := plainJobIcon(ij.job) - line := fmt.Sprintf(" %c %3d. %s %s", iconCh, ij.origIdx, ij.job.Id, instr) - // Layout: " " (2) + icon (1) + " " (1) + "%3d" (3) + ". " (2) = rune offset 9 for ID - idLen := len([]rune(ij.job.Id)) + line := fmt.Sprintf(" %c %3d. %s %s", iconCh, ij.origIdx+1, ij.job.Id, label) rows = append(rows, matrixRow{ text: []rune(line), iconCol: 2, iconCh: iconCh, iconStyle: jobIconStylePtr(ij.job), - dimStart: 9, - dimEnd: 9 + idLen, - dimStyle: &dimStyle, cursorMarker: i == m.cursor, }) } @@ -1143,7 +1044,6 @@ func (m *simulateModel) buildMatrixRows() []matrixRow { rows = append(rows, matrixRow{ text: []rune(fmt.Sprintf(" ... %d more below ...", below)), iconCol: -1, - dimStart: -1, }) } return rows @@ -1223,6 +1123,77 @@ func (m *simulateModel) renderDetail() string { // Show chat transcript if available b.WriteString(m.renderChatTranscript(job.Id)) + // Logs for this job's room (toggle with Ctrl+L) + if m.showLogs && m.agent != nil { + roomFilter := "" + if job.RoomName != "" { + roomFilter = job.RoomName + } + var rawLines []string + if roomFilter != "" { + rawLines = m.agent.RecentRoomLogsByPrefix(0, roomFilter) + } else { + rawLines = m.agent.RecentLogs(0) + } + if len(rawLines) > 0 { + b.WriteString("\n") + b.WriteString("\n") + b.WriteString(boldStyle.Render(" Logs:")) + b.WriteString("\n") + maxWidth := m.width - 4 + if maxWidth < 20 { + maxWidth = 20 + } + wrapLogStyle := lipgloss.NewStyle().Width(maxWidth) + for _, line := range rawLines { + wrapped := wrapLogStyle.Render(line) + for _, wl := range strings.Split(wrapped, "\n") { + b.WriteString(" " + wl + "\n") + } + } + } + } + + return b.String() +} + +func (m *simulateModel) scrolledDetail() string { + content := m.renderDetail() + lines := strings.Split(content, "\n") + budget := m.height - 12 + if budget < 5 { + budget = 5 + } + if len(lines) <= budget { + m.detailScrollOff = 0 + return content + } + + maxScroll := len(lines) - budget + if m.detailScrollOff > maxScroll { + m.detailScrollOff = maxScroll + } + if m.detailScrollOff < 0 { + m.detailScrollOff = 0 + } + + start := m.detailScrollOff + end := start + budget + if end > len(lines) { + end = len(lines) + } + + var b strings.Builder + if start > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more lines above", start))) + b.WriteString("\n") + } + b.WriteString(strings.Join(lines[start:end], "\n")) + b.WriteString("\n") + if end < len(lines) { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more lines below", len(lines)-end))) + b.WriteString("\n") + } return b.String() } @@ -1234,8 +1205,6 @@ func (m *simulateModel) renderSummary() string { var b strings.Builder b.WriteString("\n") - b.WriteString(dimStyle.Render(" " + strings.Repeat("─", 40))) - b.WriteString("\n\n") b.WriteString(" " + boldStyle.Render("Summary")) b.WriteString(fmt.Sprintf(" %s %s\n\n", greenStyle.Render(fmt.Sprintf("%d passed", summary.Passed)), @@ -1297,6 +1266,11 @@ 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 "" @@ -1306,65 +1280,72 @@ func (m *simulateModel) renderChatTranscript(jobID string) string { return "" } + userStyle := lipgloss.NewStyle().Foreground(lkCyanColor).Bold(true) + agentStyle := lipgloss.NewStyle().Foreground(lkGreenColor).Bold(true) + var b strings.Builder b.WriteString("\n") b.WriteString(boldStyle.Render(" Transcript:")) - b.WriteString("\n\n") + b.WriteString("\n") + + wrapWidth := m.width - 8 + if wrapWidth < 40 { + wrapWidth = 40 + } + wrapStyle := lipgloss.NewStyle().Width(wrapWidth) for _, item := range chatCtx.Items { switch v := item.Item.(type) { case *agent.ChatContext_ChatItem_Message: msg := v.Message - role := chatRoleLabel(msg.Role) text := chatMessageText(msg) - b.WriteString(fmt.Sprintf(" %s: %s\n", role, text)) + if text == "" { + continue + } + b.WriteString("\n") + switch msg.Role { + case agent.ChatRole_USER: + b.WriteString(fmt.Sprintf(" %s\n", userStyle.Render("You"))) + case agent.ChatRole_ASSISTANT: + b.WriteString(fmt.Sprintf(" %s\n", agentStyle.Render("Agent"))) + default: + b.WriteString(fmt.Sprintf(" %s\n", dimStyle.Render(string(msg.Role)))) + } + for _, line := range strings.Split(wrapStyle.Render(text), "\n") { + b.WriteString(" " + line + "\n") + } case *agent.ChatContext_ChatItem_FunctionCall: fc := v.FunctionCall args := fc.Arguments if len(args) > 80 { args = args[:80] + "..." } - b.WriteString(dimStyle.Render(fmt.Sprintf(" [call] %s(%s)", fc.Name, args))) + b.WriteString(dimStyle.Render(fmt.Sprintf(" ƒ %s(%s)", fc.Name, args))) b.WriteString("\n") case *agent.ChatContext_ChatItem_FunctionCallOutput: fco := v.FunctionCallOutput - output := fco.Output + output := strings.TrimSpace(fco.Output) + if output == "" { + continue + } if len(output) > 80 { output = output[:80] + "..." } - label := "output" - if fco.IsError { - label = "error" - } - b.WriteString(dimStyle.Render(fmt.Sprintf(" [%s] %s -> %s", label, fco.Name, output))) + b.WriteString(dimStyle.Render(fmt.Sprintf(" → %s", output))) b.WriteString("\n") case *agent.ChatContext_ChatItem_AgentHandoff: h := v.AgentHandoff - b.WriteString(dimStyle.Render(fmt.Sprintf(" [handoff] -> %s", h.NewAgentId))) - b.WriteString("\n") - case *agent.ChatContext_ChatItem_AgentConfigUpdate: - b.WriteString(dimStyle.Render(" [config update]")) + old := "" + if h.OldAgentId != nil && *h.OldAgentId != "" { + old = *h.OldAgentId + " → " + } + b.WriteString(dimStyle.Render(fmt.Sprintf(" ⤳ %s%s", old, h.NewAgentId))) b.WriteString("\n") } } return b.String() } -func chatRoleLabel(role agent.ChatRole) string { - switch role { - case agent.ChatRole_USER: - return cyanStyle.Render("User") - case agent.ChatRole_ASSISTANT: - return greenStyle.Render("Agent") - case agent.ChatRole_SYSTEM: - return dimStyle.Render("System") - case agent.ChatRole_DEVELOPER: - return dimStyle.Render("Developer") - default: - return dimStyle.Render("Unknown") - } -} - func chatMessageText(msg *agent.ChatMessage) string { if msg == nil || len(msg.Content) == 0 { return "" @@ -1375,17 +1356,15 @@ func chatMessageText(msg *agent.ChatMessage) string { parts = append(parts, t) } } - return strings.Join(parts, " ") + return strings.Join(parts, "") } -func (m *simulateModel) renderLogs() string { +func (m *simulateModel) renderLogs(roomName string) string { if m.agent == nil { return "" } var b strings.Builder - b.WriteString(dimStyle.Render(" " + strings.Repeat("─", 40))) - b.WriteString("\n") - logBudget := m.height - 15 + logBudget := m.height / 3 if logBudget < 3 { logBudget = 3 } @@ -1395,7 +1374,12 @@ func (m *simulateModel) renderLogs() string { } wrapStyle := lipgloss.NewStyle().Width(maxWidth) - rawLines := m.agent.RecentLogs(logBudget * 3) + var rawLines []string + if roomName != "" { + rawLines = m.agent.RecentRoomLogsByPrefix(0, roomName) + } else { + rawLines = m.agent.RecentLogs(0) + } var visualLines []string for _, line := range rawLines { wrapped := wrapStyle.Render(line) @@ -1403,12 +1387,56 @@ func (m *simulateModel) renderLogs() string { visualLines = append(visualLines, wl) } } - if len(visualLines) > logBudget { - visualLines = visualLines[len(visualLines)-logBudget:] + + total := len(visualLines) + if total == 0 { + return b.String() + } + + maxScroll := total - logBudget + if maxScroll < 0 { + maxScroll = 0 + } + + if m.logPinned { + // Convert from-bottom offset to stable from-top position + // When pinned, new lines arriving shouldn't move the viewport + pinnedStart := m.logPinnedTotal - logBudget - m.logScrollOff + newOffset := total - logBudget - pinnedStart + if newOffset < 0 { + newOffset = 0 + } + m.logScrollOff = newOffset + } + m.logPinnedTotal = total + + if m.logScrollOff > maxScroll { + m.logScrollOff = maxScroll + } + if m.logScrollOff < 0 { + m.logScrollOff = 0 } - for _, vl := range visualLines { + + start := total - logBudget - m.logScrollOff + if start < 0 { + start = 0 + } + end := start + logBudget + if end > total { + end = total + } + + if m.logScrollOff > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more lines above", start))) + b.WriteString("\n") + } + for _, vl := range visualLines[start:end] { b.WriteString(" " + vl + "\n") } + if end < total { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more lines below", total-end))) + b.WriteString("\n") + } return b.String() } @@ -1425,22 +1453,27 @@ func firstMeaningfulLine(text string) string { } func (m *simulateModel) renderHint() string { - var hint string + var parts []string if m.detailJobID != "" { - hint = " ESC back · s save scenario" + parts = append(parts, "↑↓ scroll · ESC back · s save scenario") if m.hasLogs() { - hint += " · Ctrl+L logs" + if m.showLogs { + parts = append(parts, "Ctrl+L hide logs") + } else { + parts = append(parts, "Ctrl+L logs") + } } } else { - hint = " ↑↓/Tab navigate · ENTER detail · ←→ filter · d description" + parts = append(parts, "↑↓ navigate · ENTER detail · d description") if m.hasLogs() { - hint += " · Ctrl+L logs" - } - if m.runFinished { - hint += " " + if m.showLogs { + parts = append(parts, "PgUp/PgDn scroll logs · Ctrl+L hide logs") + } else { + parts = append(parts, "Ctrl+L logs") + } } } - return dimStyle.Render(hint) + return dimStyle.Render(" " + strings.Join(parts, " · ")) } func jobIcon(job *livekit.SimulationRun_Job) string { From 6cf8fe0593b57c526960ffac78b7b87f4dc97ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:11:46 -0700 Subject: [PATCH 22/30] Use positional arg for entrypoint instead of --entrypoint flag --- cmd/lk/agent_run.go | 20 +++++++++----------- cmd/lk/console.go | 11 ++++------- cmd/lk/simulate.go | 11 ++++------- 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 13119ac7..bfdfc81e 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -33,10 +33,6 @@ func init() { } var agentRunFlags = []cli.Flag{ - &cli.StringFlag{ - Name: "entrypoint", - Usage: "Agent entrypoint `FILE` (default: auto-detect)", - }, &cli.StringFlag{ Name: "url", Usage: "LiveKit server `URL`", @@ -59,15 +55,17 @@ var agentRunFlags = []cli.Flag{ } var startCommand = &cli.Command{ - Name: "start", - Usage: "Run an agent in production mode", - Flags: agentRunFlags, - Action: runAgentStart, + Name: "start", + Usage: "Run an agent in production mode", + ArgsUsage: "[entrypoint]", + Flags: agentRunFlags, + Action: runAgentStart, } var devCommand = &cli.Command{ - Name: "dev", - Usage: "Run an agent in development mode with auto-reload", + Name: "dev", + Usage: "Run an agent in development mode with auto-reload", + ArgsUsage: "[entrypoint]", Flags: append(agentRunFlags, &cli.BoolFlag{ Name: "no-reload", Usage: "Disable auto-reload on file changes", @@ -122,7 +120,7 @@ func noAgentError() error { } func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error) { - explicit := cmd.String("entrypoint") + explicit := cmd.Args().First() detectFrom := "." if explicit != "" { diff --git a/cmd/lk/console.go b/cmd/lk/console.go index 51d62f59..11d5b86a 100644 --- a/cmd/lk/console.go +++ b/cmd/lk/console.go @@ -41,9 +41,10 @@ func init() { } var consoleCommand = &cli.Command{ - Name: "console", - Usage: "Voice chat with an agent via mic/speakers", - Category: "Core", + Name: "console", + Usage: "Voice chat with an agent via mic/speakers", + ArgsUsage: "[entrypoint]", + Category: "Core", Flags: []cli.Flag{ &cli.IntFlag{ Name: "port", @@ -76,10 +77,6 @@ var consoleCommand = &cli.Command{ Name: "record", Usage: "Record audio and session report to console-recordings/", }, - &cli.StringFlag{ - Name: "entrypoint", - Usage: "Agent entrypoint `FILE` (default: auto-detect)", - }, }, Action: runConsole, } diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index e87db260..907f1a2d 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -45,8 +45,9 @@ const ( ) var simulateCommand = &cli.Command{ - Name: "simulate", - Usage: "Run agent simulations against LiveKit Cloud", + Name: "simulate", + Usage: "Run agent simulations against LiveKit Cloud", + ArgsUsage: "[entrypoint]", Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { pc, err := loadProjectDetails(cmd) if err != nil { @@ -74,10 +75,6 @@ var simulateCommand = &cli.Command{ Name: "config", Usage: "Path to simulation config `FILE`", }, - &cli.StringFlag{ - Name: "entrypoint", - Usage: "Agent entrypoint `FILE` (default: agent.py)", - }, }, } @@ -182,7 +179,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("simulate currently only supports Python agents (detected: %s)", projectType) } - entrypoint, err := findEntrypoint(projectDir, cmd.String("entrypoint"), projectType) + entrypoint, err := findEntrypoint(projectDir, cmd.Args().First(), projectType) if err != nil { return err } From 127ac17792f58636b8232ed090a7e5c85835a8e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:23:57 -0700 Subject: [PATCH 23/30] Address PR review: remove redundant flags, fix console text mode - Remove --url, --api-key, --api-secret flags from agent run commands (already available as global flags) - Fix console text mode: suppress UserInputTranscribed events in text mode to prevent duplicate/out-of-order user messages - Clear partial transcript when switching to text mode mid-call --- cmd/lk/agent_run.go | 15 --------------- cmd/lk/console_tui.go | 4 ++++ 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index bfdfc81e..800f0090 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -33,21 +33,6 @@ func init() { } var agentRunFlags = []cli.Flag{ - &cli.StringFlag{ - Name: "url", - Usage: "LiveKit server `URL`", - Sources: cli.EnvVars("LIVEKIT_URL"), - }, - &cli.StringFlag{ - Name: "api-key", - Usage: "LiveKit API `KEY`", - Sources: cli.EnvVars("LIVEKIT_API_KEY"), - }, - &cli.StringFlag{ - Name: "api-secret", - Usage: "LiveKit API `SECRET`", - Sources: cli.EnvVars("LIVEKIT_API_SECRET"), - }, &cli.StringFlag{ Name: "log-level", Usage: "Log level (TRACE, DEBUG, INFO, WARN, ERROR)", diff --git a/cmd/lk/console_tui.go b/cmd/lk/console_tui.go index cd4f5c70..67009912 100644 --- a/cmd/lk/console_tui.go +++ b/cmd/lk/console_tui.go @@ -214,6 +214,7 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+t": m.textMode = true m.showShortcuts = false + m.partialTranscript = "" m.textInput.Focus() return m, textinput.Blink case "?": @@ -389,6 +390,9 @@ func (m *consoleModel) handleSessionEvent(ev *agent.AgentSessionEvent) []tea.Cmd } case *agent.AgentSessionEvent_UserInputTranscribed_: + if m.textMode { + break + } if e.UserInputTranscribed.IsFinal { m.partialTranscript = "" if text := e.UserInputTranscribed.Transcript; text != "" { From 32b9e0e3947e8ec83add2b9db949c8e97b31ff48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:34:18 -0700 Subject: [PATCH 24/30] Put start/dev/console/simulate behind console build tag, revert CI changes - All agent subprocess commands (start, dev, console, simulate) require -tags console to build. Without the tag, they don't appear in lk agent. - Revert .github/workflows/release.yaml and banner to main - Remove .goreleaser.yaml (not on main) - Move noAgentError to agent.go (non-tagged) - Add init() registration for simulate command --- .github/banner_dark.png | Bin 113462 -> 113055 bytes .github/workflows/release.yaml | 217 +++++++++++++++++++++++++++++---- .goreleaser.yaml | 66 ---------- cmd/lk/agent.go | 8 +- cmd/lk/agent_run.go | 9 +- cmd/lk/agent_watcher.go | 2 + cmd/lk/console_stub.go | 22 +--- cmd/lk/simulate.go | 6 + cmd/lk/simulate_ci.go | 2 + cmd/lk/simulate_matrix.go | 2 + cmd/lk/simulate_save.go | 2 + cmd/lk/simulate_subprocess.go | 2 + cmd/lk/simulate_tui.go | 2 + 13 files changed, 222 insertions(+), 118 deletions(-) delete mode 100644 .goreleaser.yaml diff --git a/.github/banner_dark.png b/.github/banner_dark.png index ddaf653651ebc5b49579f8eedcb57f5daeacb0e2..36206242cf39dc3d8bc10fa4e9ee8a564d1996a5 100644 GIT binary patch literal 113055 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�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 U17=o{I%1;F=D34Zxus9sA9Lp~&;S4c literal 113462 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 ap()4t(5ohR> "$GITHUB_ENV" + LIB_DIR="${ZIG_LIB}/libc/mingw/lib-common" + # Zig bundles MinGW .def files but lld needs .a import libraries. + # Go's compiled objects embed COFF /DEFAULTLIB directives (e.g. dbghelp, + # bcrypt) that lld resolves directly, bypassing Zig's lazy .def→.a + # generation. Pre-generate all import libraries so lld can find them. + MACHINE=${{ matrix.goarch == 'amd64' && 'i386:x86-64' || 'arm64' }} + for def in "${LIB_DIR}"/*.def; do + lib=$(basename "$def" .def) + [ -f "${LIB_DIR}/lib${lib}.a" ] && continue + zig dlltool -d "$def" -l "${LIB_DIR}/lib${lib}.a" -m "$MACHINE" 2>/dev/null || true + done + + - name: Build + env: + CGO_ENABLED: ${{ (matrix.goos && !matrix.zig_target) && '0' || '1' }} + CC: ${{ matrix.zig_target && format('zig cc -target {0}', matrix.zig_target) || '' }} + CXX: ${{ matrix.zig_target && format('zig c++ -target {0}', matrix.zig_target) || '' }} + # Zig uses its own sysroot; point it at the system ALSA headers and libraries + CGO_CFLAGS: ${{ matrix.alsa_triple && format('-isystem /usr/include -isystem /usr/include/{0}', matrix.alsa_triple) || '' }} + CGO_LDFLAGS: ${{ matrix.alsa_triple && format('-L/usr/lib/{0}', matrix.alsa_triple) || '' }} + # -fms-extensions: enable __try/__except (SEH) used by WebRTC + # -DNTDDI_VERSION: target Windows 10 base to skip WinRT includes absent from MinGW + CGO_CXXFLAGS: ${{ matrix.goos == 'windows' && '-fms-extensions -DNTDDI_VERSION=0x0A000000' || '' }} + GOOS: ${{ matrix.goos || '' }} + GOARCH: ${{ matrix.goarch || '' }} + GOARM: ${{ matrix.goarm || '' }} + shell: bash + run: | + EXT=""; if [ "${GOOS:-}" = "windows" ]; then EXT=".exe"; fi + TAGS="" + if [ "$CGO_ENABLED" = "1" ]; then TAGS="-tags console"; fi + # Force external linking for Windows so Go uses zig cc (CC) as the linker, + # and add Zig's MinGW lib path so lld can find the generated import libraries. + EXTLD="" + if [ "${GOOS:-}" = "windows" ] && [ "$CGO_ENABLED" = "1" ]; then + EXTLD="-linkmode=external -extldflags '-L${ZIG_LIB}/libc/mingw/lib-common'" + fi + go build $TAGS -ldflags "-w -s $EXTLD" -o "dist/lk${EXT}" ./cmd/lk + + - name: Package and upload + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + NAME="lk_${VERSION}_${{ matrix.suffix }}" + cp LICENSE dist/ + cp -r autocomplete dist/ + if [[ "${{ matrix.suffix }}" == windows_* ]]; then + cd dist && zip -r "../${NAME}.zip" lk.exe LICENSE autocomplete && cd .. + gh release upload "$TAG" "${NAME}.zip" --clobber + else + tar -czf "${NAME}.tar.gz" -C dist lk LICENSE autocomplete + gh release upload "$TAG" "${NAME}.tar.gz" --clobber + fi + + checksums: + needs: build + runs-on: ubuntu-latest + steps: + - name: Generate checksums + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + gh release download "$TAG" -R livekit/livekit-cli -D artifacts + cd artifacts + sha256sum * > ../checksums.txt + cd .. + gh release upload "$TAG" checksums.txt --clobber -R livekit/livekit-cli + + update-homebrew: + needs: checksums + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Update Homebrew formula env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + + # Download checksums + gh release download "$TAG" -R livekit/livekit-cli -p checksums.txt -D . + get_sha() { grep "$1" checksums.txt | awk '{print $1}'; } + + # Fill in the formula template + sed \ + -e "s/VERSION/${VERSION}/g" \ + -e "s/SHA256_DARWIN_ARM64/$(get_sha darwin_arm64)/g" \ + -e "s/SHA256_LINUX_AMD64/$(get_sha linux_amd64)/g" \ + -e "s/SHA256_LINUX_ARM64/$(get_sha linux_arm64)/g" \ + -e "s/SHA256_LINUX_ARM/$(get_sha linux_arm.tar)/g" \ + Formula/lk.rb > lk.rb + + # Push to homebrew tap + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/livekit/homebrew-livekit.git" tap + mkdir -p tap/Formula + cp lk.rb tap/Formula/lk.rb + cd tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/lk.rb + git commit -m "lk ${VERSION}" + git push diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 19aebb7d..00000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2023 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. - -version: 2 - -before: - hooks: - - go mod tidy -builds: - - id: lk - env: - - CGO_ENABLED=0 - main: ./cmd/lk - binary: lk - goarm: - - "7" - goarch: - - amd64 - - arm64 - - arm - goos: - - linux - - windows - - darwin - ignore: - - goos: windows - goarch: arm - ldflags: - - -s -w -archives: - - name_template: "lk_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - format_overrides: - - goos: windows - formats: [zip] - files: - - LICENSE - - 'autocomplete/*' -release: - github: - owner: livekit - name: livekit-cli - draft: true - prerelease: auto -changelog: - sort: asc - filters: - exclude: - - '^docs:' - - '^test:' -gomod: - proxy: false -checksum: - name_template: 'checksums.txt' -snapshot: - version_template: "{{ incpatch .Version }}-next" diff --git a/cmd/lk/agent.go b/cmd/lk/agent.go index ce7867d7..7f130595 100644 --- a/cmd/lk/agent.go +++ b/cmd/lk/agent.go @@ -347,7 +347,6 @@ var ( ArgsUsage: "[working-dir]", }, privateLinkCommands, - simulateCommand, }, }, } @@ -360,6 +359,13 @@ var ( } ) +func noAgentError() error { + return fmt.Errorf("no agent project detected in the current directory\n\n" + + "Make sure you are running this command from an agent project directory\n" + + "containing one of: pyproject.toml, requirements.txt, uv.lock, package.json, or lock files.\n\n" + + "To get started, see: https://docs.livekit.io/agents/quickstart") +} + func createAgentClient(ctx context.Context, cmd *cli.Command) (context.Context, error) { return createAgentClientWithOpts(ctx, cmd) } diff --git a/cmd/lk/agent_run.go b/cmd/lk/agent_run.go index 800f0090..a4286beb 100644 --- a/cmd/lk/agent_run.go +++ b/cmd/lk/agent_run.go @@ -1,3 +1,5 @@ +//go:build console + // Copyright 2025 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -97,13 +99,6 @@ func resolveCredentials(cmd *cli.Command, loadOpts ...loadOption) ([]string, err return args, nil } -func noAgentError() error { - return fmt.Errorf("no agent project detected in the current directory\n\n" + - "Make sure you are running this command from an agent project directory\n" + - "containing one of: pyproject.toml, requirements.txt, uv.lock, package.json, or lock files.\n\n" + - "To get started, see: https://docs.livekit.io/agents/quickstart") -} - func detectProject(cmd *cli.Command) (string, agentfs.ProjectType, string, error) { explicit := cmd.Args().First() diff --git a/cmd/lk/agent_watcher.go b/cmd/lk/agent_watcher.go index 944cb00d..d81f9e5f 100644 --- a/cmd/lk/agent_watcher.go +++ b/cmd/lk/agent_watcher.go @@ -1,3 +1,5 @@ +//go:build console + // Copyright 2021-2024 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/cmd/lk/console_stub.go b/cmd/lk/console_stub.go index 452bf181..9b3deb31 100644 --- a/cmd/lk/console_stub.go +++ b/cmd/lk/console_stub.go @@ -2,23 +2,5 @@ package main -import ( - "context" - "fmt" - - "github.com/urfave/cli/v3" -) - -func init() { - AgentCommands[0].Commands = append(AgentCommands[0].Commands, &cli.Command{ - Name: "console", - Usage: "Voice chat with an agent via mic/speakers", - Action: func(ctx context.Context, cmd *cli.Command) error { - return fmt.Errorf("console is not included in this build (requires -tags console).\n\n" + - "Install with console support:\n" + - " https://docs.livekit.io/intro/basics/cli/start/\n\n" + - "Or build from source:\n" + - " go build -tags console ./cmd/lk") - }, - }) -} +// No-op: start, dev, console, and simulate commands are only available +// when built with the console tag (go build -tags console). diff --git a/cmd/lk/simulate.go b/cmd/lk/simulate.go index 907f1a2d..d2b7374a 100644 --- a/cmd/lk/simulate.go +++ b/cmd/lk/simulate.go @@ -1,3 +1,5 @@ +//go:build console + // Copyright 2025 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -34,6 +36,10 @@ import ( "github.com/livekit/server-sdk-go/v2/pkg/cloudagents" ) +func init() { + AgentCommands[0].Commands = append(AgentCommands[0].Commands, simulateCommand) +} + var ( simulateProjectConfig *config.ProjectConfig ) diff --git a/cmd/lk/simulate_ci.go b/cmd/lk/simulate_ci.go index 59656476..ce7d185f 100644 --- a/cmd/lk/simulate_ci.go +++ b/cmd/lk/simulate_ci.go @@ -1,3 +1,5 @@ +//go:build console + // Copyright 2025 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/cmd/lk/simulate_matrix.go b/cmd/lk/simulate_matrix.go index 7160f277..2621a7c9 100644 --- a/cmd/lk/simulate_matrix.go +++ b/cmd/lk/simulate_matrix.go @@ -1,3 +1,5 @@ +//go:build console + // Copyright 2025 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/cmd/lk/simulate_save.go b/cmd/lk/simulate_save.go index d67c79b0..8ae7928d 100644 --- a/cmd/lk/simulate_save.go +++ b/cmd/lk/simulate_save.go @@ -1,3 +1,5 @@ +//go:build console + // Copyright 2025 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/cmd/lk/simulate_subprocess.go b/cmd/lk/simulate_subprocess.go index 52e48744..65d6a074 100644 --- a/cmd/lk/simulate_subprocess.go +++ b/cmd/lk/simulate_subprocess.go @@ -1,3 +1,5 @@ +//go:build console + // Copyright 2025 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/cmd/lk/simulate_tui.go b/cmd/lk/simulate_tui.go index 611701b1..30cef4d5 100644 --- a/cmd/lk/simulate_tui.go +++ b/cmd/lk/simulate_tui.go @@ -1,3 +1,5 @@ +//go:build console + // Copyright 2025 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); From 4746a5443f21a735069de54377bc83458dd0a195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:36:12 -0700 Subject: [PATCH 25/30] Add CI job for no-console, no-CGO build verification --- .github/workflows/build.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 99b8baae..2b54668d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -24,6 +24,25 @@ permissions: contents: read jobs: + build-no-console: + name: Build (no console, no CGO) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + + - name: Build without console tag + env: + CGO_ENABLED: "0" + run: go build -o bin/lk ./cmd/lk + + - name: Verify binary + run: bin/lk --help > /dev/null + lint-and-test: runs-on: ubuntu-latest steps: From 08618d40b35fd5dfd7b6fe70e1d6f711a617c78d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:37:12 -0700 Subject: [PATCH 26/30] Remove build-binaries.yaml workflow --- .github/workflows/build-binaries.yaml | 170 -------------------------- 1 file changed, 170 deletions(-) delete mode 100644 .github/workflows/build-binaries.yaml diff --git a/.github/workflows/build-binaries.yaml b/.github/workflows/build-binaries.yaml deleted file mode 100644 index acefc9a6..00000000 --- a/.github/workflows/build-binaries.yaml +++ /dev/null @@ -1,170 +0,0 @@ -# Copyright 2023 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. - -name: Build binaries - -on: - workflow_call: - inputs: - output_dir: - type: string - default: bin - upload_release: - type: boolean - default: false - -jobs: - build: - strategy: - fail-fast: false - matrix: - include: - - os: macos-latest - suffix: darwin_arm64 - - os: ubuntu-latest - suffix: linux_amd64 - zig_target: x86_64-linux-gnu.2.28 - alsa_arch: amd64 - alsa_triple: x86_64-linux-gnu - - os: ubuntu-latest - suffix: linux_arm64 - zig_target: aarch64-linux-gnu.2.28 - alsa_arch: arm64 - alsa_triple: aarch64-linux-gnu - goarch: arm64 - - os: ubuntu-latest - suffix: linux_arm - zig_target: arm-linux-gnueabihf.2.28 - alsa_arch: armhf - alsa_triple: arm-linux-gnueabihf - goarch: arm - goarm: "7" - - os: ubuntu-latest - suffix: windows_amd64 - zig_target: x86_64-windows-gnu - goos: windows - goarch: amd64 - - os: ubuntu-latest - suffix: windows_arm64 - zig_target: aarch64-windows-gnu - goos: windows - goarch: arm64 - - os: ubuntu-latest - suffix: windows_arm - goos: windows - goarch: arm - goarm: "7" - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - with: - lfs: 'true' - submodules: true - - - run: git lfs pull - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" - - - name: Install Zig - if: matrix.zig_target - uses: mlugg/setup-zig@v2 - with: - version: 0.14.1 - - - name: Install ALSA headers - if: matrix.alsa_arch - run: | - sudo dpkg --add-architecture ${{ matrix.alsa_arch }} - if [ "${{ matrix.alsa_arch }}" != "amd64" ]; then - CODENAME=$(lsb_release -cs) - # Restrict existing sources to amd64 to avoid 404s for foreign arch - for f in /etc/apt/sources.list.d/*.sources; do - grep -q '^Architectures:' "$f" || sudo sed -i '/^Types:/a Architectures: amd64 i386' "$f" - done - # Add ports.ubuntu.com for the foreign architecture - printf 'Types: deb\nURIs: http://ports.ubuntu.com/ubuntu-ports\nSuites: %s %s-updates\nComponents: main universe\nArchitectures: %s\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' \ - "$CODENAME" "$CODENAME" "${{ matrix.alsa_arch }}" | sudo tee /etc/apt/sources.list.d/ports.sources - fi - sudo apt-get update - sudo apt-get install -y libasound2-dev:${{ matrix.alsa_arch }} - - - name: Generate Windows import libraries - if: matrix.goos == 'windows' && matrix.zig_target - run: | - ZIG_LIB=$(zig env | jq -r '.lib_dir') - echo "ZIG_LIB=${ZIG_LIB}" >> "$GITHUB_ENV" - LIB_DIR="${ZIG_LIB}/libc/mingw/lib-common" - # Zig bundles MinGW .def files but lld needs .a import libraries. - # Go's compiled objects embed COFF /DEFAULTLIB directives (e.g. dbghelp, - # bcrypt) that lld resolves directly, bypassing Zig's lazy .def→.a - # generation. Pre-generate all import libraries so lld can find them. - MACHINE=${{ matrix.goarch == 'amd64' && 'i386:x86-64' || 'arm64' }} - for def in "${LIB_DIR}"/*.def; do - lib=$(basename "$def" .def) - [ -f "${LIB_DIR}/lib${lib}.a" ] && continue - zig dlltool -d "$def" -l "${LIB_DIR}/lib${lib}.a" -m "$MACHINE" 2>/dev/null || true - done - - - name: Build - env: - CGO_ENABLED: ${{ (matrix.goos && !matrix.zig_target) && '0' || '1' }} - CC: ${{ matrix.zig_target && format('zig cc -target {0}', matrix.zig_target) || '' }} - CXX: ${{ matrix.zig_target && format('zig c++ -target {0}', matrix.zig_target) || '' }} - # Zig uses its own sysroot; point it at the system ALSA headers and libraries - CGO_CFLAGS: ${{ matrix.alsa_triple && format('-isystem /usr/include -isystem /usr/include/{0}', matrix.alsa_triple) || '' }} - CGO_LDFLAGS: ${{ matrix.alsa_triple && format('-L/usr/lib/{0}', matrix.alsa_triple) || '' }} - # -fms-extensions: enable __try/__except (SEH) used by WebRTC - # -DNTDDI_VERSION: target Windows 10 base to skip WinRT includes absent from MinGW - CGO_CXXFLAGS: ${{ matrix.goos == 'windows' && '-fms-extensions -DNTDDI_VERSION=0x0A000000' || '' }} - GOOS: ${{ matrix.goos || '' }} - GOARCH: ${{ matrix.goarch || '' }} - GOARM: ${{ matrix.goarm || '' }} - shell: bash - run: | - EXT=""; if [ "${GOOS:-}" = "windows" ]; then EXT=".exe"; fi - TAGS="" - if [ "$CGO_ENABLED" = "1" ]; then TAGS="-tags console"; fi - # Force external linking for Windows so Go uses zig cc (CC) as the linker, - # and add Zig's MinGW lib path so lld can find the generated import libraries. - EXTLD="" - if [ "${GOOS:-}" = "windows" ] && [ "$CGO_ENABLED" = "1" ]; then - EXTLD="-linkmode=external -extldflags '-L${ZIG_LIB}/libc/mingw/lib-common'" - fi - go build $TAGS -ldflags "-w -s $EXTLD" -o "${{ inputs.output_dir }}/lk${EXT}" ./cmd/lk - - - name: Verify binary - if: "!matrix.goos && !matrix.goarch" - run: ${{ inputs.output_dir }}/lk --help > /dev/null - - - name: Package and upload - if: inputs.upload_release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - TAG="${GITHUB_REF#refs/tags/}" - VERSION="${TAG#v}" - NAME="lk_${VERSION}_${{ matrix.suffix }}" - cp LICENSE ${{ inputs.output_dir }}/ - cp -r autocomplete ${{ inputs.output_dir }}/ - if [[ "${{ matrix.suffix }}" == windows_* ]]; then - cd ${{ inputs.output_dir }} && zip -r "../${NAME}.zip" lk.exe LICENSE autocomplete && cd .. - gh release upload "$TAG" "${NAME}.zip" --clobber - else - tar -czf "${NAME}.tar.gz" -C ${{ inputs.output_dir }} lk LICENSE autocomplete - gh release upload "$TAG" "${NAME}.tar.gz" --clobber - fi From 5b998c51ecc4de89203294e7a6afe822e45337c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:37:56 -0700 Subject: [PATCH 27/30] Remove Homebrew formula --- Formula/lk.rb | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 Formula/lk.rb diff --git a/Formula/lk.rb b/Formula/lk.rb deleted file mode 100644 index 841854fe..00000000 --- a/Formula/lk.rb +++ /dev/null @@ -1,47 +0,0 @@ -# typed: false -# frozen_string_literal: true - -# This formula is meant for a custom Homebrew tap (e.g. livekit/homebrew-livekit). -# It installs a prebuilt binary with console support (PortAudio + WebRTC AEC). -# Usage: brew install livekit/livekit/lk -class Lk < Formula - desc "Command-line interface to LiveKit (with console support)" - homepage "https://livekit.io" - license "Apache-2.0" - version "VERSION" - - on_macos do - if Hardware::CPU.arm? - url "https://github.com/livekit/livekit-cli/releases/download/vVERSION/lk_VERSION_darwin_arm64.tar.gz" - sha256 "SHA256_DARWIN_ARM64" - end - end - - on_linux do - if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? - url "https://github.com/livekit/livekit-cli/releases/download/vVERSION/lk_VERSION_linux_arm64.tar.gz" - sha256 "SHA256_LINUX_ARM64" - elsif Hardware::CPU.arm? - url "https://github.com/livekit/livekit-cli/releases/download/vVERSION/lk_VERSION_linux_arm.tar.gz" - sha256 "SHA256_LINUX_ARM" - else - url "https://github.com/livekit/livekit-cli/releases/download/vVERSION/lk_VERSION_linux_amd64.tar.gz" - sha256 "SHA256_LINUX_AMD64" - end - end - - def install - bin.install "lk" - bin.install_symlink "lk" => "livekit-cli" - - bash_completion.install "autocomplete/bash_autocomplete" => "lk" - fish_completion.install "autocomplete/fish_autocomplete" => "lk.fish" - zsh_completion.install "autocomplete/zsh_autocomplete" => "_lk" - end - - test do - output = shell_output("#{bin}/lk token create --list --api-key key --api-secret secret") - assert_match "valid for (mins): 5", output - assert_match "lk version #{version}", shell_output("#{bin}/lk --version") - end -end From 220f67b873c080082a52e39ce1c4689adff23e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:38:44 -0700 Subject: [PATCH 28/30] Restore .goreleaser.yml --- .goreleaser.yml | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .goreleaser.yml diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 00000000..19aebb7d --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,66 @@ +# Copyright 2023 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. + +version: 2 + +before: + hooks: + - go mod tidy +builds: + - id: lk + env: + - CGO_ENABLED=0 + main: ./cmd/lk + binary: lk + goarm: + - "7" + goarch: + - amd64 + - arm64 + - arm + goos: + - linux + - windows + - darwin + ignore: + - goos: windows + goarch: arm + ldflags: + - -s -w +archives: + - name_template: "lk_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: [zip] + files: + - LICENSE + - 'autocomplete/*' +release: + github: + owner: livekit + name: livekit-cli + draft: true + prerelease: auto +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' +gomod: + proxy: false +checksum: + name_template: 'checksums.txt' +snapshot: + version_template: "{{ incpatch .Version }}-next" From 22e399f219166da8c1b275349834d80e9be471bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:39:42 -0700 Subject: [PATCH 29/30] Rename .goreleaser.yml to .goreleaser.yaml --- .goreleaser.yml => .goreleaser.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .goreleaser.yml => .goreleaser.yaml (100%) diff --git a/.goreleaser.yml b/.goreleaser.yaml similarity index 100% rename from .goreleaser.yml rename to .goreleaser.yaml From feef68fb0416b6c906c2da03bf45b2bfbaa53cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Sun, 10 May 2026 22:40:16 -0700 Subject: [PATCH 30/30] Remove console build section from README --- README.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/README.md b/README.md index eb7b8a93..e2ebb1f4 100644 --- a/README.md +++ b/README.md @@ -54,29 +54,6 @@ git clone https://github.com/livekit/livekit-cli && cd livekit-cli make install ``` -### Building with console support - -The `lk agent console` command (voice chat with an agent via mic/speakers) requires native dependencies (PortAudio, WebRTC audio processing) and is built separately with a build tag. - -This repo uses git submodules for vendored native sources. Make sure to clone with submodules: - -```shell -git clone --recurse-submodules https://github.com/livekit/livekit-cli && cd livekit-cli -``` - -Or if you've already cloned: - -```shell -git submodule update --init --recursive -``` - -Then build with the `console` tag: - -```shell -make console -``` - -This produces a `bin/lk` binary with console support enabled. # Usage