diff --git a/.github/workflows/build_tui.yml b/.github/workflows/build_tui.yml new file mode 100644 index 000000000..604eb3f89 --- /dev/null +++ b/.github/workflows/build_tui.yml @@ -0,0 +1,120 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +# This workflow builds and tests the Go TUI binary in tui/ +# Produces cross-compiled binaries for 6 platform targets: +# linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64 + +name: TUI Build & Test + +on: + push: + branches: [ main ] + paths: + - 'tui/**' + - '.github/workflows/build_tui.yml' + pull_request: + branches: [ main ] + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'tui/**' + - '.github/workflows/build_tui.yml' + merge_group: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: tui + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache-dependency-path: tui/go.sum + + - name: Run tests + run: go test ./... -v -count=1 + + - name: Vet + run: go vet ./... + + build: + name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) + needs: test + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + - goos: windows + goarch: arm64 + defaults: + run: + working-directory: tui + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache-dependency-path: tui/go.sum + + - name: Build + env: + CGO_ENABLED: '0' + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + EXT="" + if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi + PKG=github.com/amd/gaia/tui/internal/cli + go build -ldflags="-s -w -X ${PKG}.version=${{ github.ref_name }} -X ${PKG}.commit=${{ github.sha }} -X ${PKG}.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o ../bin/gaia-${{ matrix.goos }}-${{ matrix.goarch }}${EXT} \ + ./cmd/gaia + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: gaia-${{ matrix.goos }}-${{ matrix.goarch }} + path: bin/gaia-* + retention-days: 14 + + size-check: + name: Binary size check + needs: build + runs-on: ubuntu-latest + steps: + - name: Download linux/amd64 artifact + uses: actions/download-artifact@v4 + with: + name: gaia-linux-amd64 + + - name: Check size + run: | + SIZE=$(stat --printf="%s" gaia-linux-amd64) + SIZE_MB=$((SIZE / 1024 / 1024)) + echo "Binary size: ${SIZE_MB}MB (${SIZE} bytes)" + if [ "$SIZE" -gt 15728640 ]; then + echo "::warning::Binary size ${SIZE_MB}MB exceeds 15MB target" + fi diff --git a/tui/.gitignore b/tui/.gitignore new file mode 100644 index 000000000..beae965b7 --- /dev/null +++ b/tui/.gitignore @@ -0,0 +1,3 @@ +bin/ +*.exe +*.exe~ diff --git a/tui/Makefile b/tui/Makefile new file mode 100644 index 000000000..c3e41a29e --- /dev/null +++ b/tui/Makefile @@ -0,0 +1,39 @@ +.PHONY: build build-release test lint clean cross-compile mock-agent dev + +VERSION ?= dev +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +PKG = github.com/amd/gaia/tui/internal/cli +LDFLAGS = -ldflags="-s -w -X $(PKG).version=$(VERSION) -X $(PKG).commit=$(COMMIT) -X $(PKG).date=$(DATE)" + +build: + go build -o bin/gaia ./cmd/gaia + +build-release: + CGO_ENABLED=0 go build $(LDFLAGS) -o bin/gaia ./cmd/gaia + +test: + go test ./... -v + +lint: + go vet ./... + +mock-agent: + go build -o bin/mock-agent ./test/mockagent + +dev: build mock-agent + @echo "Built gaia + mock-agent. Run: ./bin/gaia" + +clean: + rm -rf bin/ + +# Cross-compile all 6 platform targets +cross-compile: + @mkdir -p bin + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o bin/gaia-linux-amd64 ./cmd/gaia + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build $(LDFLAGS) -o bin/gaia-linux-arm64 ./cmd/gaia + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o bin/gaia-darwin-amd64 ./cmd/gaia + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build $(LDFLAGS) -o bin/gaia-darwin-arm64 ./cmd/gaia + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build $(LDFLAGS) -o bin/gaia-windows-amd64.exe ./cmd/gaia + GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build $(LDFLAGS) -o bin/gaia-windows-arm64.exe ./cmd/gaia + @echo "Built 6 binaries:" && ls -lh bin/gaia-* diff --git a/tui/cmd/gaia/main.go b/tui/cmd/gaia/main.go new file mode 100644 index 000000000..7a2377553 --- /dev/null +++ b/tui/cmd/gaia/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + + "github.com/amd/gaia/tui/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/tui/find_pdfs.sh b/tui/find_pdfs.sh new file mode 100644 index 000000000..1b9abf7f9 --- /dev/null +++ b/tui/find_pdfs.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +# find_pdfs.sh +# Finds all PDF files starting from the current directory. + +set -euo pipefail + +# Define the starting directory. Using '.' searches the current directory and all subdirectories. +START_DIR="." + +echo "Searching for PDF files starting from: $START_DIR" + +# Use find to locate files ending in .pdf. +# -type f: ensures only files are matched. +# -iname: performs a case-insensitive match for '.pdf'. +find "$START_DIR" -type f -iname "*.pdf" \ No newline at end of file diff --git a/tui/go.mod b/tui/go.mod new file mode 100644 index 000000000..acda08a5b --- /dev/null +++ b/tui/go.mod @@ -0,0 +1,49 @@ +module github.com/amd/gaia/tui + +go 1.26.3 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect +) diff --git a/tui/go.sum b/tui/go.sum new file mode 100644 index 000000000..f0a29d59b --- /dev/null +++ b/tui/go.sum @@ -0,0 +1,105 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/tui/internal/catalog/agent.go b/tui/internal/catalog/agent.go new file mode 100644 index 000000000..4d6671bda --- /dev/null +++ b/tui/internal/catalog/agent.go @@ -0,0 +1,83 @@ +package catalog + +import "strings" + +// AgentStatus represents the lifecycle state of an agent. +type AgentStatus int + +const ( + StatusInstalled AgentStatus = iota // downloaded and ready to use + StatusActive // currently in a chat session + StatusIdle // used this session, back at hub + StatusAvailable // in registry but not downloaded + StatusComingSoon // placeholder, voteable +) + +// String returns a human-readable status label. +func (s AgentStatus) String() string { + switch s { + case StatusInstalled: + return "installed" + case StatusActive: + return "active" + case StatusIdle: + return "idle" + case StatusAvailable: + return "available" + case StatusComingSoon: + return "coming soon" + default: + return "unknown" + } +} + +// StatusDot returns the dot indicator for this status. +func (s AgentStatus) StatusDot() string { + switch s { + case StatusActive: + return "●" // render green + case StatusIdle: + return "●" // render yellow + case StatusInstalled: + return "●" // render dim + case StatusAvailable: + return "○" + case StatusComingSoon: + return "◌" + default: + return " " + } +} + +// IsLaunchable returns true if the agent can be launched for chat. +func (s AgentStatus) IsLaunchable() bool { + return s == StatusInstalled || s == StatusActive || s == StatusIdle +} + +// Agent represents a GAIA agent in the catalog. +type Agent struct { + ID string + Name string + Description string + Category string + Tags []string + Icon string // emoji + Version string // semver, e.g. "0.1.0" + Status AgentStatus + BinaryPath string // e.g. "gaia-bash" + BinaryArgs []string // e.g. ["--json-events"] + Votes int // for coming-soon agents +} + +// FilterValue returns a searchable string for fuzzy matching. +// Implements the bubbles/list.Item interface. +func (a Agent) FilterValue() string { + parts := []string{a.Name, a.Description, a.Category} + parts = append(parts, a.Tags...) + return strings.Join(parts, " ") +} + +// Title returns the display title for list rendering. +func (a Agent) Title() string { + return a.Icon + " " + a.Name +} diff --git a/tui/internal/catalog/catalog.go b/tui/internal/catalog/catalog.go new file mode 100644 index 000000000..94dd973f1 --- /dev/null +++ b/tui/internal/catalog/catalog.go @@ -0,0 +1,253 @@ +package catalog + +import ( + "os" + "os/exec" + "path/filepath" +) + +// Section represents a tab/section in the hub UI. +type Section string + +const ( + SectionDashboard Section = "Dashboard" + SectionInstalled Section = "Installed" + SectionAvailable Section = "Available" + SectionComingSoon Section = "Coming Soon" +) + +// AllSections returns the tab order for the hub. +func AllSections() []Section { + return []Section{SectionInstalled, SectionAvailable, SectionComingSoon} +} + +// Catalog manages the agent registry. +type Catalog struct { + agents []Agent +} + +// NewCatalog creates a catalog with hardcoded seed agents. +func NewCatalog() *Catalog { + return &Catalog{agents: seedAgents()} +} + +// All returns all agents. +func (c *Catalog) All() []Agent { + result := make([]Agent, len(c.agents)) + copy(result, c.agents) + return result +} + +// Get returns an agent by ID, or nil if not found. +func (c *Catalog) Get(id string) *Agent { + for i := range c.agents { + if c.agents[i].ID == id { + return &c.agents[i] + } + } + return nil +} + +// DiscoverBinaries searches for agent executables on PATH and in common build locations. +func (c *Catalog) DiscoverBinaries() { + for i := range c.agents { + if c.agents[i].BinaryPath == "" { + continue + } + name := c.agents[i].BinaryPath + // Check if already on PATH + if p, err := exec.LookPath(name); err == nil { + c.agents[i].BinaryPath = p + continue + } + if p, err := exec.LookPath(name + ".exe"); err == nil { + c.agents[i].BinaryPath = p + continue + } + // Walk up from cwd looking for cpp/build/{Debug,Release}/.exe + if found := findBinaryInRepo(name); found != "" { + c.agents[i].BinaryPath = found + } + } +} + +// findBinaryInRepo walks up the directory tree from cwd looking for the agent binary +// in common build output locations (cpp/build/Debug/, cpp/build/Release/). +func findBinaryInRepo(name string) string { + dir, _ := os.Getwd() + for i := 0; i < 8; i++ { + for _, buildDir := range []string{"Debug", "Release", ""} { + var candidate string + if buildDir != "" { + candidate = filepath.Join(dir, "cpp", "build", buildDir, name+".exe") + } else { + candidate = filepath.Join(dir, "cpp", "build", name+".exe") + } + if _, err := os.Stat(candidate); err == nil { + abs, _ := filepath.Abs(candidate) + return abs + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +// SetMockBinary overrides all installed agent binary paths with a mock binary for testing. +func (c *Catalog) SetMockBinary(binaryPath string) { + for i := range c.agents { + if c.agents[i].Status == StatusInstalled || c.agents[i].Status == StatusActive || c.agents[i].Status == StatusIdle { + c.agents[i].BinaryPath = binaryPath + c.agents[i].BinaryArgs = nil + } + } +} + +// BySection returns agents filtered by their install status section. +func (c *Catalog) BySection(section Section) []Agent { + var result []Agent + for _, a := range c.agents { + switch section { + case SectionInstalled: + if a.Status == StatusInstalled || a.Status == StatusActive || a.Status == StatusIdle { + result = append(result, a) + } + case SectionAvailable: + if a.Status == StatusAvailable { + result = append(result, a) + } + case SectionComingSoon: + if a.Status == StatusComingSoon { + result = append(result, a) + } + } + } + return result +} + +// DashboardStats returns counts for the hub dashboard. +func (c *Catalog) DashboardStats() (installed, active, idle int) { + for _, a := range c.agents { + switch a.Status { + case StatusInstalled: + installed++ + case StatusActive: + active++ + case StatusIdle: + idle++ + } + } + return +} + +// SetStatus updates an agent's status. +func (c *Catalog) SetStatus(id string, status AgentStatus) { + for i := range c.agents { + if c.agents[i].ID == id { + c.agents[i].Status = status + return + } + } +} + +// Remove removes an agent by setting it back to Available and clearing binary path. +func (c *Catalog) Remove(id string) { + for i := range c.agents { + if c.agents[i].ID == id { + c.agents[i].Status = StatusAvailable + c.agents[i].BinaryPath = "" + c.agents[i].BinaryArgs = nil + return + } + } +} + +// IncrementVotes bumps the vote count for a coming-soon agent. +func (c *Catalog) IncrementVotes(id string) { + for i := range c.agents { + if c.agents[i].ID == id { + c.agents[i].Votes++ + return + } + } +} + +func seedAgents() []Agent { + return []Agent{ + // --- Installed --- + { + ID: "bash", Name: "Bash", Description: "Shell command execution and automation", + Category: "DevOps", Tags: []string{"shell", "bash", "terminal", "cli"}, + Icon: "🖥️", Version: "0.1.0", Status: StatusInstalled, + BinaryPath: "gaia-bash", BinaryArgs: []string{"--json-events", "--model", "Gemma-4-E4B-it-GGUF"}, + }, + + // --- Available (Python agents — need API client mode) --- + { + ID: "chat", Name: "Chat", Description: "General conversation and Q&A", + Category: "Conversation", Tags: []string{"chat", "general", "qa"}, + Icon: "💬", Version: "0.1.0", Status: StatusAvailable, + }, + { + ID: "doc", Name: "Doc", Description: "Document analysis with RAG", + Category: "Documents", Tags: []string{"documents", "rag", "pdf", "search"}, + Icon: "📄", Version: "0.1.0", Status: StatusAvailable, + }, + { + ID: "file", Name: "File", Description: "File system navigation and operations", + Category: "Productivity", Tags: []string{"files", "filesystem", "io"}, + Icon: "📁", Version: "0.1.0", Status: StatusAvailable, + }, + { + ID: "code", Name: "Code", Description: "Code generation and editing", + Category: "Code", Tags: []string{"code", "programming", "developer"}, + Icon: "🔧", Version: "0.1.0", Status: StatusAvailable, + }, + { + ID: "blender", Name: "Blender", Description: "3D scene automation and modeling", + Category: "Creative", Tags: []string{"3d", "blender", "modeling", "animation"}, + Icon: "🎨", Version: "0.1.0", Status: StatusAvailable, + }, + { + ID: "jira", Name: "Jira", Description: "Issue tracking and project management", + Category: "Productivity", Tags: []string{"jira", "issues", "project", "agile"}, + Icon: "🎫", Version: "0.1.0", Status: StatusAvailable, + }, + { + ID: "docker", Name: "Docker", Description: "Container management and orchestration", + Category: "DevOps", Tags: []string{"docker", "containers", "kubernetes"}, + Icon: "🐳", Version: "0.1.0", Status: StatusAvailable, + }, + { + ID: "summarize", Name: "Summarize", Description: "Document and text summarization", + Category: "Documents", Tags: []string{"summarize", "text", "tldr"}, + Icon: "📝", Version: "0.1.0", Status: StatusAvailable, + }, + { + ID: "email", Name: "Email", Description: "Email triage, drafting, and calendar", + Category: "Productivity", Tags: []string{"email", "gmail", "calendar", "communication"}, + Icon: "📧", Version: "0.1.0", Status: StatusAvailable, + }, + + // --- Coming Soon --- + { + ID: "routing", Name: "Routing", Description: "Intelligent agent selection and orchestration", + Category: "Infrastructure", Tags: []string{"routing", "orchestration", "multi-agent"}, + Icon: "🔀", Version: "0.1.0", Status: StatusComingSoon, + }, + { + ID: "browser", Name: "Browser", Description: "Web browsing and automation", + Category: "Research", Tags: []string{"browser", "web", "scraping", "automation"}, + Icon: "🌐", Version: "0.1.0", Status: StatusComingSoon, + }, + { + ID: "data-analyst", Name: "Data Analyst", Description: "Data analysis and visualization", + Category: "Data", Tags: []string{"data", "analysis", "charts", "csv", "excel"}, + Icon: "📊", Version: "0.1.0", Status: StatusComingSoon, + }, + } +} diff --git a/tui/internal/catalog/catalog_test.go b/tui/internal/catalog/catalog_test.go new file mode 100644 index 000000000..a28eded5c --- /dev/null +++ b/tui/internal/catalog/catalog_test.go @@ -0,0 +1,297 @@ +package catalog + +import ( + "strings" + "testing" +) + +func TestNewCatalog(t *testing.T) { + c := NewCatalog() + if c == nil { + t.Fatal("NewCatalog() returned nil") + } + if len(c.agents) == 0 { + t.Fatal("NewCatalog() returned empty catalog") + } +} + +func TestAll(t *testing.T) { + c := NewCatalog() + all := c.All() + if len(all) != len(c.agents) { + t.Fatalf("All() returned %d agents, want %d", len(all), len(c.agents)) + } + // Verify it's a copy, not a reference + all[0].Name = "MUTATED" + if c.agents[0].Name == "MUTATED" { + t.Fatal("All() returned a reference, not a copy") + } +} + +func TestGetValidID(t *testing.T) { + c := NewCatalog() + agent := c.Get("chat") + if agent == nil { + t.Fatal("Get('chat') returned nil") + } + if agent.ID != "chat" { + t.Fatalf("Get('chat').ID = %q, want 'chat'", agent.ID) + } + if agent.Name != "Chat" { + t.Fatalf("Get('chat').Name = %q, want 'Chat'", agent.Name) + } +} + +func TestGetMissingID(t *testing.T) { + c := NewCatalog() + agent := c.Get("nonexistent") + if agent != nil { + t.Fatalf("Get('nonexistent') returned %+v, want nil", agent) + } +} + +func TestBySectionInstalled(t *testing.T) { + c := NewCatalog() + installed := c.BySection(SectionInstalled) + if len(installed) == 0 { + t.Fatal("BySection(Installed) returned empty") + } + for _, a := range installed { + if !a.Status.IsLaunchable() { + t.Fatalf("BySection(Installed) included non-launchable agent %q with status %s", a.ID, a.Status) + } + } +} + +func TestBySectionAvailable(t *testing.T) { + c := NewCatalog() + available := c.BySection(SectionAvailable) + if len(available) == 0 { + t.Fatal("BySection(Available) returned empty") + } + for _, a := range available { + if a.Status != StatusAvailable { + t.Fatalf("BySection(Available) included agent %q with status %s", a.ID, a.Status) + } + } +} + +func TestBySectionComingSoon(t *testing.T) { + c := NewCatalog() + comingSoon := c.BySection(SectionComingSoon) + if len(comingSoon) == 0 { + t.Fatal("BySection(ComingSoon) returned empty") + } + for _, a := range comingSoon { + if a.Status != StatusComingSoon { + t.Fatalf("BySection(ComingSoon) included agent %q with status %s", a.ID, a.Status) + } + } +} + +func TestBySectionCoverage(t *testing.T) { + c := NewCatalog() + all := c.All() + installed := c.BySection(SectionInstalled) + available := c.BySection(SectionAvailable) + comingSoon := c.BySection(SectionComingSoon) + total := len(installed) + len(available) + len(comingSoon) + if total != len(all) { + t.Fatalf("sections sum to %d agents, want %d", total, len(all)) + } +} + +func TestDashboardStats(t *testing.T) { + c := NewCatalog() + installed, active, idle := c.DashboardStats() + if installed != 1 { + t.Fatalf("DashboardStats() installed = %d, want 1 (bash only)", installed) + } + if active != 0 { + t.Fatalf("DashboardStats() active = %d, want 0", active) + } + if idle != 0 { + t.Fatalf("DashboardStats() idle = %d, want 0", idle) + } +} + +func TestSetStatus(t *testing.T) { + c := NewCatalog() + c.SetStatus("bash", StatusActive) + agent := c.Get("bash") + if agent.Status != StatusActive { + t.Fatalf("after SetStatus, status = %s, want active", agent.Status) + } + + // Verify dashboard stats updated + installed, active, _ := c.DashboardStats() + if active != 1 { + t.Fatalf("after SetStatus(active), active = %d, want 1", active) + } + if installed != 0 { + t.Fatalf("after SetStatus(active), installed = %d, want 0", installed) + } +} + +func TestSetStatusNonexistent(t *testing.T) { + c := NewCatalog() + // Should not panic + c.SetStatus("nonexistent", StatusActive) +} + +func TestRemove(t *testing.T) { + c := NewCatalog() + agent := c.Get("bash") + if agent.BinaryPath == "" { + t.Fatal("bash agent should have a BinaryPath before Remove") + } + + c.Remove("bash") + agent = c.Get("bash") + if agent.Status != StatusAvailable { + t.Fatalf("after Remove, status = %s, want available", agent.Status) + } + if agent.BinaryPath != "" { + t.Fatalf("after Remove, BinaryPath = %q, want empty", agent.BinaryPath) + } + if agent.BinaryArgs != nil { + t.Fatalf("after Remove, BinaryArgs = %v, want nil", agent.BinaryArgs) + } +} + +func TestRemoveNonexistent(t *testing.T) { + c := NewCatalog() + // Should not panic + c.Remove("nonexistent") +} + +func TestIncrementVotes(t *testing.T) { + c := NewCatalog() + agent := c.Get("routing") + if agent.Votes != 0 { + t.Fatalf("initial Votes = %d, want 0", agent.Votes) + } + + c.IncrementVotes("routing") + if agent.Votes != 1 { + t.Fatalf("after IncrementVotes, Votes = %d, want 1", agent.Votes) + } + + c.IncrementVotes("routing") + if agent.Votes != 2 { + t.Fatalf("after second IncrementVotes, Votes = %d, want 2", agent.Votes) + } +} + +func TestIncrementVotesNonexistent(t *testing.T) { + c := NewCatalog() + // Should not panic + c.IncrementVotes("nonexistent") +} + +func TestFilterValue(t *testing.T) { + a := Agent{ + Name: "Chat", + Description: "General conversation and Q&A", + Category: "Conversation", + Tags: []string{"chat", "general", "qa"}, + } + fv := a.FilterValue() + if !strings.Contains(fv, "Chat") { + t.Fatalf("FilterValue() missing Name, got %q", fv) + } + if !strings.Contains(fv, "General conversation") { + t.Fatalf("FilterValue() missing Description, got %q", fv) + } + if !strings.Contains(fv, "Conversation") { + t.Fatalf("FilterValue() missing Category, got %q", fv) + } + if !strings.Contains(fv, "qa") { + t.Fatalf("FilterValue() missing tag 'qa', got %q", fv) + } +} + +func TestTitle(t *testing.T) { + a := Agent{Icon: "💬", Name: "Chat"} + title := a.Title() + want := "💬 Chat" + if title != want { + t.Fatalf("Title() = %q, want %q", title, want) + } +} + +func TestStatusDot(t *testing.T) { + tests := []struct { + status AgentStatus + want string + }{ + {StatusActive, "●"}, + {StatusIdle, "●"}, + {StatusInstalled, "●"}, + {StatusAvailable, "○"}, + {StatusComingSoon, "◌"}, + {AgentStatus(99), " "}, + } + for _, tt := range tests { + got := tt.status.StatusDot() + if got != tt.want { + t.Errorf("StatusDot(%s) = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestIsLaunchable(t *testing.T) { + tests := []struct { + status AgentStatus + want bool + }{ + {StatusInstalled, true}, + {StatusActive, true}, + {StatusIdle, true}, + {StatusAvailable, false}, + {StatusComingSoon, false}, + {AgentStatus(99), false}, + } + for _, tt := range tests { + got := tt.status.IsLaunchable() + if got != tt.want { + t.Errorf("IsLaunchable(%s) = %v, want %v", tt.status, got, tt.want) + } + } +} + +func TestStatusString(t *testing.T) { + tests := []struct { + status AgentStatus + want string + }{ + {StatusInstalled, "installed"}, + {StatusActive, "active"}, + {StatusIdle, "idle"}, + {StatusAvailable, "available"}, + {StatusComingSoon, "coming soon"}, + {AgentStatus(99), "unknown"}, + } + for _, tt := range tests { + got := tt.status.String() + if got != tt.want { + t.Errorf("String(%d) = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestAllSections(t *testing.T) { + sections := AllSections() + if len(sections) != 3 { + t.Fatalf("AllSections() returned %d sections, want 3", len(sections)) + } + if sections[0] != SectionInstalled { + t.Fatalf("AllSections()[0] = %q, want %q", sections[0], SectionInstalled) + } + if sections[1] != SectionAvailable { + t.Fatalf("AllSections()[1] = %q, want %q", sections[1], SectionAvailable) + } + if sections[2] != SectionComingSoon { + t.Fatalf("AllSections()[2] = %q, want %q", sections[2], SectionComingSoon) + } +} diff --git a/tui/internal/cli/chat.go b/tui/internal/cli/chat.go new file mode 100644 index 000000000..59ec44a88 --- /dev/null +++ b/tui/internal/cli/chat.go @@ -0,0 +1,32 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/amd/gaia/tui/internal/ui" +) + +var ( + subprocess string + query string +) + +var chatCmd = &cobra.Command{ + Use: "chat", + Short: "Start interactive chat with an agent", + Long: "Launch the Bubble Tea chat TUI connected to an agent via subprocess or API.", + RunE: func(cmd *cobra.Command, args []string) error { + if subprocess == "" { + return fmt.Errorf("--subprocess flag is required\n\nUsage: gaia chat --subprocess \"./gaia-bash --json-events\"") + } + return ui.RunChat(subprocess, query, debug) + }, +} + +func init() { + chatCmd.Flags().StringVar(&subprocess, "subprocess", "", "command to spawn agent subprocess (e.g. \"./gaia-bash --json-events\")") + chatCmd.Flags().StringVar(&query, "query", "", "single query to send (non-interactive mode)") + rootCmd.AddCommand(chatCmd) +} diff --git a/tui/internal/cli/hub.go b/tui/internal/cli/hub.go new file mode 100644 index 000000000..5396393b8 --- /dev/null +++ b/tui/internal/cli/hub.go @@ -0,0 +1,23 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "github.com/amd/gaia/tui/internal/ui" +) + +var mockAgent string + +var hubCmd = &cobra.Command{ + Use: "hub", + Short: "Browse and launch GAIA agents", + Long: "Open the Agent Hub to discover, search, and launch GAIA agents.", + RunE: func(cmd *cobra.Command, args []string) error { + return ui.RunHub(debug, mockAgent) + }, +} + +func init() { + hubCmd.Flags().StringVar(&mockAgent, "mock", "", "path to mock agent binary for testing (overrides all agent binaries)") + rootCmd.AddCommand(hubCmd) +} diff --git a/tui/internal/cli/root.go b/tui/internal/cli/root.go new file mode 100644 index 000000000..867e2eea3 --- /dev/null +++ b/tui/internal/cli/root.go @@ -0,0 +1,36 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/amd/gaia/tui/internal/ui" +) + +var debug bool + +var rootCmd = &cobra.Command{ + Use: "gaia", + Short: "GAIA Terminal Agent Hub", + Long: "Terminal-native hub for browsing, launching, and chatting with GAIA agents.", + RunE: func(cmd *cobra.Command, args []string) error { + return ui.RunHub(debug, mockAgent) + }, +} + +func init() { + rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug logging to stderr") + rootCmd.Flags().StringVar(&mockAgent, "mock", "", "path to mock agent binary for testing (overrides all agent binaries)") +} + +func Execute() error { + return rootCmd.Execute() +} + +func debugLog(format string, args ...interface{}) { + if debug { + fmt.Fprintf(os.Stderr, "[DEBUG] "+format+"\n", args...) + } +} diff --git a/tui/internal/cli/version.go b/tui/internal/cli/version.go new file mode 100644 index 000000000..4c8b3d53b --- /dev/null +++ b/tui/internal/cli/version.go @@ -0,0 +1,25 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + version = "dev" + commit = "unknown" + date = "unknown" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("gaia %s (commit: %s, built: %s)\n", version, commit, date) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/tui/internal/client/client.go b/tui/internal/client/client.go new file mode 100644 index 000000000..f5fe03fcf --- /dev/null +++ b/tui/internal/client/client.go @@ -0,0 +1,16 @@ +package client + +import ( + "context" +) + +// AgentClient is the interface for communicating with an agent backend. +// Both subprocess (JSONL) and API (SSE) modes implement this interface. +type AgentClient interface { + // Send starts a conversation turn. Events stream on the returned channel. + // The channel is closed when the turn is complete (answer/done/status-complete event). + Send(ctx context.Context, query string) (<-chan interface{}, error) + + // Close terminates the connection or process. + Close() error +} diff --git a/tui/internal/client/subprocess.go b/tui/internal/client/subprocess.go new file mode 100644 index 000000000..a163ab6ae --- /dev/null +++ b/tui/internal/client/subprocess.go @@ -0,0 +1,227 @@ +package client + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/amd/gaia/tui/internal/event" +) + +// detectLemonadeURL probes common Lemonade Server ports and returns the first reachable URL. +func detectLemonadeURL() string { + ports := []string{"13305", "8000"} + client := &http.Client{Timeout: 2 * time.Second} + + for _, port := range ports { + url := "http://localhost:" + port + "/api/v1" + resp, err := client.Get(url + "/models") + if err == nil { + resp.Body.Close() + if resp.StatusCode == 200 { + return url + } + } + } + return "" +} + +// SubprocessClient communicates with a C++ agent via stdin/stdout JSONL. +// Send() calls must be serialized — do not overlap two Send() calls. +type SubprocessClient struct { + cmdLine string + debug bool + + mu sync.Mutex + cmd *exec.Cmd + stdin io.WriteCloser + stdout *bufio.Scanner + stderr *bytes.Buffer + started bool +} + +// NewSubprocessClient creates a client from a command string like "./gaia-bash --json-events". +func NewSubprocessClient(cmdLine string, debug bool) *SubprocessClient { + return &SubprocessClient{ + cmdLine: cmdLine, + debug: debug, + } +} + +// start spawns the subprocess if not already running. +func (s *SubprocessClient) start() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.started { + return nil + } + + parts := strings.Fields(s.cmdLine) + if len(parts) == 0 { + return fmt.Errorf("empty subprocess command") + } + + s.cmd = exec.Command(parts[0], parts[1:]...) + s.stderr = &bytes.Buffer{} + s.cmd.Stderr = s.stderr + + // Auto-detect Lemonade URL if not set in environment + if os.Getenv("LEMONADE_BASE_URL") == "" { + if url := detectLemonadeURL(); url != "" { + s.cmd.Env = append(os.Environ(), "LEMONADE_BASE_URL="+url) + if s.debug { + fmt.Fprintf(os.Stderr, "[DEBUG] Auto-detected Lemonade at %s\n", url) + } + } + } + + stdinPipe, err := s.cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to create stdin pipe: %w", err) + } + s.stdin = stdinPipe + + stdoutPipe, err := s.cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + scanner := bufio.NewScanner(stdoutPipe) + // 1MB buffer for large tool outputs + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) + s.stdout = scanner + + if err := s.cmd.Start(); err != nil { + return fmt.Errorf("failed to start subprocess %q: %w", parts[0], err) + } + + s.started = true + return nil +} + +// Send writes a query to stdin and returns a channel of parsed events. +func (s *SubprocessClient) Send(ctx context.Context, query string) (<-chan interface{}, error) { + if err := s.start(); err != nil { + return nil, err + } + + if _, err := fmt.Fprintln(s.stdin, query); err != nil { + return nil, fmt.Errorf("failed to write to subprocess stdin: %w", err) + } + + // Capture references under lock so the goroutine doesn't race with Close(). + s.mu.Lock() + scanner := s.stdout + cmd := s.cmd + stderrBuf := s.stderr + debug := s.debug + s.mu.Unlock() + + ch := make(chan interface{}, 32) + + go func() { + defer close(ch) + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + evt, err := event.ParseEvent(line) + if err != nil { + if debug { + fmt.Fprintf(os.Stderr, "[DEBUG] parse error: %v (line: %s)\n", err, string(line)) + } + continue + } + + // Skip stale "complete" status from a previous turn's trailing event + if se, ok := evt.(event.StatusEvent); ok && se.Status == "complete" { + continue + } + + select { + case ch <- evt: + case <-ctx.Done(): + return + } + + // Turn boundary — stop reading after terminal events. + switch evt.(type) { + case event.AnswerEvent: + return + case event.AgentErrorEvent: + return + case event.DoneEvent: + return + } + } + + // Scanner stopped — check for read errors or unexpected process exit. + if err := scanner.Err(); err != nil { + select { + case ch <- event.AgentErrorEvent{ + Type: "agent_error", + Content: fmt.Sprintf("subprocess stdout read error: %v", err), + }: + case <-ctx.Done(): + } + return + } + + // Process exited — wait to get exit code, then report if non-zero. + _ = cmd.Wait() + if cmd.ProcessState != nil && !cmd.ProcessState.Success() { + stderrContent := stderrBuf.String() + msg := fmt.Sprintf("agent process exited with code %d", cmd.ProcessState.ExitCode()) + if stderrContent != "" { + msg += "\n" + stderrContent + } + select { + case ch <- event.AgentErrorEvent{ + Type: "agent_error", + Content: msg, + }: + case <-ctx.Done(): + } + } + }() + + return ch, nil +} + +// Close terminates the subprocess. +func (s *SubprocessClient) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.started { + return nil + } + + // Close stdin to signal EOF to the child process. + if s.stdin != nil { + s.stdin.Close() + } + + if s.cmd != nil { + s.cmd.Wait() + } + + s.stdin = nil + s.stdout = nil + s.stderr = nil + s.cmd = nil + s.started = false + return nil +} diff --git a/tui/internal/client/subprocess_test.go b/tui/internal/client/subprocess_test.go new file mode 100644 index 000000000..854b69f3c --- /dev/null +++ b/tui/internal/client/subprocess_test.go @@ -0,0 +1,241 @@ +package client + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/amd/gaia/tui/internal/event" +) + +// buildMockAgent compiles a small Go program that reads stdin lines +// and emits JSONL events to stdout, simulating an agent backend. +func buildMockAgent(t *testing.T) string { + t.Helper() + + src := `package main + +import ( + "bufio" + "fmt" + "os" +) + +func main() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + query := scanner.Text() + fmt.Fprintf(os.Stderr, "got query: %s\n", query) + + fmt.Println("{\"type\":\"step\",\"step\":1,\"total\":3,\"status\":\"running\"}") + fmt.Println("{\"type\":\"thinking\",\"content\":\"Let me think about this...\"}") + fmt.Println("{\"type\":\"tool_start\",\"tool\":\"bash\",\"detail\":\"echo hello\"}") + fmt.Println("{\"type\":\"tool_end\",\"success\":true}") + fmt.Println("{\"type\":\"answer\",\"content\":\"Here is my answer\",\"steps\":1,\"tools_used\":1}") + } +} +` + tmpDir := t.TempDir() + srcPath := filepath.Join(tmpDir, "mock_agent.go") + if err := os.WriteFile(srcPath, []byte(src), 0644); err != nil { + t.Fatalf("write mock agent source: %v", err) + } + + binName := "mock_agent" + if runtime.GOOS == "windows" { + binName = "mock_agent.exe" + } + binPath := filepath.Join(tmpDir, binName) + + goExe := "go" + if p, err := exec.LookPath("go"); err == nil { + goExe = p + } + + cmd := exec.Command(goExe, "build", "-o", binPath, srcPath) + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build mock agent: %v\n%s", err, out) + } + + return binPath +} + +func TestSubprocessClient_SendReceivesEvents(t *testing.T) { + bin := buildMockAgent(t) + + c := NewSubprocessClient(bin, true) + defer c.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ch, err := c.Send(ctx, "hello world") + if err != nil { + t.Fatalf("Send: %v", err) + } + + var events []interface{} + for evt := range ch { + events = append(events, evt) + } + + if len(events) != 5 { + t.Fatalf("expected 5 events, got %d: %+v", len(events), events) + } + + // Verify types in order. + if _, ok := events[0].(event.StepEvent); !ok { + t.Errorf("event[0]: expected StepEvent, got %T", events[0]) + } + if _, ok := events[1].(event.ThinkingEvent); !ok { + t.Errorf("event[1]: expected ThinkingEvent, got %T", events[1]) + } + if _, ok := events[2].(event.ToolStartEvent); !ok { + t.Errorf("event[2]: expected ToolStartEvent, got %T", events[2]) + } + if _, ok := events[3].(event.ToolEndEvent); !ok { + t.Errorf("event[3]: expected ToolEndEvent, got %T", events[3]) + } + if ans, ok := events[4].(event.AnswerEvent); !ok { + t.Errorf("event[4]: expected AnswerEvent, got %T", events[4]) + } else { + if ans.Content != "Here is my answer" { + t.Errorf("answer content = %q, want %q", ans.Content, "Here is my answer") + } + if ans.Steps != 1 { + t.Errorf("answer steps = %d, want 1", ans.Steps) + } + if ans.ToolsUsed != 1 { + t.Errorf("answer tools_used = %d, want 1", ans.ToolsUsed) + } + } +} + +func TestSubprocessClient_MultiTurn(t *testing.T) { + bin := buildMockAgent(t) + + c := NewSubprocessClient(bin, false) + defer c.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // First turn. + ch1, err := c.Send(ctx, "turn one") + if err != nil { + t.Fatalf("Send turn 1: %v", err) + } + count1 := 0 + for range ch1 { + count1++ + } + if count1 != 5 { + t.Errorf("turn 1: expected 5 events, got %d", count1) + } + + // Second turn — same process, reused. + ch2, err := c.Send(ctx, "turn two") + if err != nil { + t.Fatalf("Send turn 2: %v", err) + } + count2 := 0 + for range ch2 { + count2++ + } + if count2 != 5 { + t.Errorf("turn 2: expected 5 events, got %d", count2) + } +} + +func TestSubprocessClient_InvalidCommand(t *testing.T) { + c := NewSubprocessClient("nonexistent_binary_xyz_12345", false) + defer c.Close() + + ctx := context.Background() + _, err := c.Send(ctx, "hello") + if err == nil { + t.Fatal("expected error for invalid command, got nil") + } +} + +func TestSubprocessClient_EmptyCommand(t *testing.T) { + c := NewSubprocessClient("", false) + + ctx := context.Background() + _, err := c.Send(ctx, "hello") + if err == nil { + t.Fatal("expected error for empty command, got nil") + } +} + +func TestSubprocessClient_CloseBeforeSend(t *testing.T) { + c := NewSubprocessClient("echo", false) + + // Close without ever starting should be a no-op. + if err := c.Close(); err != nil { + t.Fatalf("Close before Send: %v", err) + } +} + +func TestSubprocessClient_ProcessExitWithError(t *testing.T) { + // Build a mock that exits with code 1 immediately. + src := `package main +import "os" +func main() { os.Exit(1) } +` + tmpDir := t.TempDir() + srcPath := filepath.Join(tmpDir, "exit_agent.go") + if err := os.WriteFile(srcPath, []byte(src), 0644); err != nil { + t.Fatalf("write source: %v", err) + } + + binName := "exit_agent" + if runtime.GOOS == "windows" { + binName = "exit_agent.exe" + } + binPath := filepath.Join(tmpDir, binName) + + goExe := "go" + if p, err := exec.LookPath("go"); err == nil { + goExe = p + } + + cmd := exec.Command(goExe, "build", "-o", binPath, srcPath) + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build exit agent: %v\n%s", err, out) + } + + c := NewSubprocessClient(binPath, false) + defer c.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ch, err := c.Send(ctx, "hello") + if err != nil { + t.Fatalf("Send: %v", err) + } + + var gotError bool + for evt := range ch { + if ae, ok := evt.(event.AgentErrorEvent); ok { + gotError = true + if ae.Content == "" { + t.Error("expected non-empty error content") + } + } + } + + if !gotError { + t.Error("expected an AgentErrorEvent for process exit with code 1") + } +} + +// Verify the interface is satisfied at compile time. +var _ AgentClient = (*SubprocessClient)(nil) diff --git a/tui/internal/event/parser.go b/tui/internal/event/parser.go new file mode 100644 index 000000000..d57faf341 --- /dev/null +++ b/tui/internal/event/parser.go @@ -0,0 +1,98 @@ +package event + +import ( + "encoding/json" + "fmt" +) + +// ParseEvent parses a JSONL line into a concrete event type. +// Returns the typed event or an error if the line is invalid JSON or has an unknown type. +func ParseEvent(line []byte) (interface{}, error) { + var base Event + if err := json.Unmarshal(line, &base); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + + switch base.Type { + case "step": + var e StepEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid step event: %w", err) + } + return e, nil + case "thinking": + var e ThinkingEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid thinking event: %w", err) + } + return e, nil + case "status": + var e StatusEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid status event: %w", err) + } + return e, nil + case "tool_start": + var e ToolStartEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid tool_start event: %w", err) + } + return e, nil + case "tool_args": + var e ToolArgsEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid tool_args event: %w", err) + } + return e, nil + case "tool_result": + var e ToolResultEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid tool_result event: %w", err) + } + return e, nil + case "tool_end": + var e ToolEndEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid tool_end event: %w", err) + } + return e, nil + case "answer": + var e AnswerEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid answer event: %w", err) + } + return e, nil + case "chunk": + var e ChunkEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid chunk event: %w", err) + } + return e, nil + case "error": + var e ErrorEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid error event: %w", err) + } + return e, nil + case "agent_error": + var e AgentErrorEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid agent_error event: %w", err) + } + return e, nil + case "plan": + var e PlanEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid plan event: %w", err) + } + return e, nil + case "done": + var e DoneEvent + if err := json.Unmarshal(line, &e); err != nil { + return nil, fmt.Errorf("invalid done event: %w", err) + } + return e, nil + default: + return nil, fmt.Errorf("unknown event type: %q", base.Type) + } +} diff --git a/tui/internal/event/parser_test.go b/tui/internal/event/parser_test.go new file mode 100644 index 000000000..1abbcdc7a --- /dev/null +++ b/tui/internal/event/parser_test.go @@ -0,0 +1,237 @@ +package event + +import ( + "encoding/json" + "testing" +) + +func TestParseStepEvent(t *testing.T) { + line := []byte(`{"type":"step","step":1,"total":10,"status":"started"}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + step, ok := e.(StepEvent) + if !ok { + t.Fatalf("expected StepEvent, got %T", e) + } + if step.Step != 1 || step.Total != 10 || step.Status != "started" { + t.Errorf("unexpected values: %+v", step) + } +} + +func TestParseThinkingEvent(t *testing.T) { + line := []byte(`{"type":"thinking","content":"I need to check the system logs."}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + thinking, ok := e.(ThinkingEvent) + if !ok { + t.Fatalf("expected ThinkingEvent, got %T", e) + } + if thinking.Content != "I need to check the system logs." { + t.Errorf("unexpected content: %q", thinking.Content) + } +} + +func TestParseStatusEvent(t *testing.T) { + line := []byte(`{"type":"status","status":"working","message":"Analyzing files"}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + status, ok := e.(StatusEvent) + if !ok { + t.Fatalf("expected StatusEvent, got %T", e) + } + if status.Status != "working" || status.Message != "Analyzing files" { + t.Errorf("unexpected values: %+v", status) + } +} + +func TestParseStatusCompleteEvent(t *testing.T) { + line := []byte(`{"type":"status","status":"complete","steps":3,"total":10}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + status, ok := e.(StatusEvent) + if !ok { + t.Fatalf("expected StatusEvent, got %T", e) + } + if status.Status != "complete" || status.Steps != 3 || status.Total != 10 { + t.Errorf("unexpected values: %+v", status) + } +} + +func TestParseToolStartEvent(t *testing.T) { + line := []byte(`{"type":"tool_start","tool":"bash_execute"}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ts, ok := e.(ToolStartEvent) + if !ok { + t.Fatalf("expected ToolStartEvent, got %T", e) + } + if ts.Tool != "bash_execute" { + t.Errorf("unexpected tool: %q", ts.Tool) + } +} + +func TestParseToolArgsEvent(t *testing.T) { + line := []byte(`{"type":"tool_args","tool":"bash_execute","args":{"command":"ls -la /tmp"}}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ta, ok := e.(ToolArgsEvent) + if !ok { + t.Fatalf("expected ToolArgsEvent, got %T", e) + } + if ta.Tool != "bash_execute" { + t.Errorf("unexpected tool: %q", ta.Tool) + } + var args map[string]string + if err := json.Unmarshal(ta.Args, &args); err != nil { + t.Fatalf("failed to parse args: %v", err) + } + if args["command"] != "ls -la /tmp" { + t.Errorf("unexpected command: %q", args["command"]) + } +} + +func TestParseToolResultEvent(t *testing.T) { + line := []byte(`{"type":"tool_result","title":"bash_execute","success":true,"command_output":{"stdout":"file1.txt\nfile2.txt"},"summary":"Listed 2 files","result_data":{"status":"success","exit_code":0}}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tr, ok := e.(ToolResultEvent) + if !ok { + t.Fatalf("expected ToolResultEvent, got %T", e) + } + if tr.Title != "bash_execute" || !tr.Success || tr.Summary != "Listed 2 files" { + t.Errorf("unexpected values: %+v", tr) + } +} + +func TestParseToolEndEvent(t *testing.T) { + line := []byte(`{"type":"tool_end","success":true}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + te, ok := e.(ToolEndEvent) + if !ok { + t.Fatalf("expected ToolEndEvent, got %T", e) + } + if !te.Success { + t.Errorf("expected success=true") + } +} + +func TestParseAnswerEvent(t *testing.T) { + line := []byte(`{"type":"answer","content":"Here are the files in /tmp.","steps":2,"tools_used":1}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + a, ok := e.(AnswerEvent) + if !ok { + t.Fatalf("expected AnswerEvent, got %T", e) + } + if a.Content != "Here are the files in /tmp." || a.Steps != 2 || a.ToolsUsed != 1 { + t.Errorf("unexpected values: %+v", a) + } +} + +func TestParseAgentErrorEvent(t *testing.T) { + line := []byte(`{"type":"agent_error","content":"Model load failed"}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ae, ok := e.(AgentErrorEvent) + if !ok { + t.Fatalf("expected AgentErrorEvent, got %T", e) + } + if ae.Content != "Model load failed" { + t.Errorf("unexpected content: %q", ae.Content) + } +} + +func TestParsePlanEvent(t *testing.T) { + line := []byte(`{"type":"plan","steps":[{"tool":"bash_execute","tool_args":{"command":"ls"}}],"current_step":0}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + p, ok := e.(PlanEvent) + if !ok { + t.Fatalf("expected PlanEvent, got %T", e) + } + if p.CurrentStep != 0 { + t.Errorf("unexpected current_step: %d", p.CurrentStep) + } +} + +func TestParseDoneEvent(t *testing.T) { + line := []byte(`{"type":"done","message_id":"abc123","content":"completed"}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + d, ok := e.(DoneEvent) + if !ok { + t.Fatalf("expected DoneEvent, got %T", e) + } + if d.MessageID != "abc123" { + t.Errorf("unexpected message_id: %q", d.MessageID) + } +} + +func TestParseChunkEvent(t *testing.T) { + line := []byte(`{"type":"chunk","content":"Hello "}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + c, ok := e.(ChunkEvent) + if !ok { + t.Fatalf("expected ChunkEvent, got %T", e) + } + if c.Content != "Hello " { + t.Errorf("unexpected content: %q", c.Content) + } +} + +func TestParseErrorEvent(t *testing.T) { + line := []byte(`{"type":"error","content":"connection timeout"}`) + e, err := ParseEvent(line) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ev, ok := e.(ErrorEvent) + if !ok { + t.Fatalf("expected ErrorEvent, got %T", e) + } + if ev.Content != "connection timeout" { + t.Errorf("unexpected content: %q", ev.Content) + } +} + +func TestParseInvalidJSON(t *testing.T) { + _, err := ParseEvent([]byte(`not json`)) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestParseUnknownType(t *testing.T) { + _, err := ParseEvent([]byte(`{"type":"unknown_event"}`)) + if err == nil { + t.Fatal("expected error for unknown type") + } +} diff --git a/tui/internal/event/types.go b/tui/internal/event/types.go new file mode 100644 index 000000000..103569b6a --- /dev/null +++ b/tui/internal/event/types.go @@ -0,0 +1,101 @@ +package event + +import "encoding/json" + +// Event is the base for all events. Use ParseEvent() to get concrete types. +type Event struct { + Type string `json:"type"` +} + +// StepEvent — agent loop iteration (step N of M) +type StepEvent struct { + Type string `json:"type"` + Step int `json:"step"` + Total int `json:"total"` + Status string `json:"status"` +} + +// ThinkingEvent — agent reasoning/chain-of-thought +type ThinkingEvent struct { + Type string `json:"type"` + Content string `json:"content"` +} + +// StatusEvent — agent state change (working, warning, info, complete) +type StatusEvent struct { + Type string `json:"type"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + Steps int `json:"steps,omitempty"` + Total int `json:"total,omitempty"` +} + +// ToolStartEvent — tool invocation begins +type ToolStartEvent struct { + Type string `json:"type"` + Tool string `json:"tool"` + Detail string `json:"detail,omitempty"` +} + +// ToolArgsEvent — tool arguments +type ToolArgsEvent struct { + Type string `json:"type"` + Tool string `json:"tool"` + Args json.RawMessage `json:"args"` +} + +// ToolResultEvent — tool execution output +type ToolResultEvent struct { + Type string `json:"type"` + Title string `json:"title"` + Success bool `json:"success"` + CommandOutput json.RawMessage `json:"command_output,omitempty"` + Summary string `json:"summary,omitempty"` + ResultData json.RawMessage `json:"result_data,omitempty"` +} + +// ToolEndEvent — tool invocation complete +type ToolEndEvent struct { + Type string `json:"type"` + Success bool `json:"success"` +} + +// AnswerEvent — final agent response +type AnswerEvent struct { + Type string `json:"type"` + Content string `json:"content"` + Steps int `json:"steps"` + ToolsUsed int `json:"tools_used"` +} + +// ChunkEvent — streaming LLM token (disabled in v1 json-events mode) +type ChunkEvent struct { + Type string `json:"type"` + Content string `json:"content"` +} + +// ErrorEvent — transport/system error +type ErrorEvent struct { + Type string `json:"type"` + Content string `json:"content"` +} + +// AgentErrorEvent — agent-level error +type AgentErrorEvent struct { + Type string `json:"type"` + Content string `json:"content"` +} + +// PlanEvent — multi-step plan +type PlanEvent struct { + Type string `json:"type"` + Steps json.RawMessage `json:"steps"` + CurrentStep int `json:"current_step"` +} + +// DoneEvent — stream complete marker +type DoneEvent struct { + Type string `json:"type"` + MessageID string `json:"message_id,omitempty"` + Content string `json:"content,omitempty"` +} diff --git a/tui/internal/ui/app.go b/tui/internal/ui/app.go new file mode 100644 index 000000000..4dc9477d0 --- /dev/null +++ b/tui/internal/ui/app.go @@ -0,0 +1,57 @@ +package ui + +import ( + "fmt" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/amd/gaia/tui/internal/catalog" + "github.com/amd/gaia/tui/internal/client" + "github.com/amd/gaia/tui/internal/ui/chat" + "github.com/amd/gaia/tui/internal/ui/root" +) + +// RunHub launches the Agent Hub TUI — the main entry point for browsing and launching agents. +// If mockAgent is non-empty, all agent binary paths are overridden with it for testing. +func RunHub(debug bool, mockAgent string) error { + cat := catalog.NewCatalog() + if mockAgent != "" { + cat.SetMockBinary(mockAgent) + } else { + cat.DiscoverBinaries() + } + model := root.NewRootModel(cat, debug) + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("TUI error: %w", err) + } + return nil +} + +// RunChat launches the chat TUI directly with a subprocess agent (standalone mode). +func RunChat(subprocess string, query string, debug bool) error { + c := client.NewSubprocessClient(subprocess, debug) + defer c.Close() + + agentName := extractAgentName(subprocess) + model := chat.NewChatModel(c, agentName, query, debug) + + p := tea.NewProgram(model, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return fmt.Errorf("TUI error: %w", err) + } + return nil +} + +func extractAgentName(cmdLine string) string { + parts := strings.Fields(cmdLine) + if len(parts) == 0 { + return "agent" + } + name := filepath.Base(parts[0]) + name = strings.TrimSuffix(name, ".exe") + return name +} diff --git a/tui/internal/ui/chat/message.go b/tui/internal/ui/chat/message.go new file mode 100644 index 000000000..f1d5c4965 --- /dev/null +++ b/tui/internal/ui/chat/message.go @@ -0,0 +1,32 @@ +package chat + +import "time" + +type MessageRole string + +const ( + RoleUser MessageRole = "user" + RoleAssistant MessageRole = "assistant" + RoleTool MessageRole = "tool" + RoleError MessageRole = "error" + RoleStatus MessageRole = "status" +) + +type Message struct { + Role MessageRole + Content string + Rendered string + ToolName string + Success *bool + Duration time.Duration // time from query to answer + TTFT time.Duration // time to first event (model load + first inference) + Steps int // agent steps taken + ToolsUsed int // tools invoked +} + +type ActivityItem struct { + Kind string // "thinking", "tool", "step", "status" + Content string + Done bool + Success *bool +} diff --git a/tui/internal/ui/chat/model.go b/tui/internal/ui/chat/model.go new file mode 100644 index 000000000..47dc5556f --- /dev/null +++ b/tui/internal/ui/chat/model.go @@ -0,0 +1,815 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/amd/gaia/tui/internal/client" + "github.com/amd/gaia/tui/internal/event" + "github.com/amd/gaia/tui/internal/ui/components" +) + +type eventMsg struct{ event interface{} } +type errMsg struct{ err error } +type doneMsg struct{} +type sendQueryMsg struct{ query string } +type channelReadyMsg struct{ ch <-chan interface{} } + +// ReturnToHubMsg signals the root model to switch back to the hub view. +type ReturnToHubMsg struct{ AgentID string } + +// ToggleHelpMsg signals the root model to toggle help overlay. +type ToggleHelpMsg struct{} + +var ( + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("150")). + Padding(0, 1) + + userStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")) + + assistantStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")) + + activityStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")) + + toolNameStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("75")) + + successStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")) + + failStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")) + + dividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("238")) + + thinkingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("42")) + + stepStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")) + + statusMsgStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")). + Italic(true) + + answerPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("42")). + Padding(0, 1) + + errorPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("196")). + Padding(0, 1) +) + +type ChatModel struct { + messages []Message + activity []ActivityItem + streaming bool + buffer strings.Builder + + input textarea.Model + viewport viewport.Model + spinner spinner.Model + + client client.AgentClient + events <-chan interface{} + cancelFn context.CancelFunc + agentName string + agentID string + debug bool + fromHub bool + + width int + height int + + connected bool + totalSteps int + initialQuery string + err error + queryStart time.Time // tracks when the current query started + firstEvent bool // whether we've received the first event this turn + ttft time.Duration +} + +func NewChatModel(c client.AgentClient, agentName string, initialQuery string, debug bool) ChatModel { + ti := textarea.New() + ti.Placeholder = "Ask anything... (Enter to send, Ctrl+C to quit)" + ti.Focus() + ti.CharLimit = 4096 + ti.SetHeight(1) + ti.ShowLineNumbers = false + + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + vp := viewport.New(80, 20) + vp.SetContent("") + + return ChatModel{ + client: c, + agentName: agentName, + agentID: agentName, + initialQuery: initialQuery, + debug: debug, + input: ti, + spinner: sp, + viewport: vp, + connected: true, + } +} + +// NewChatModelFromHub creates a ChatModel launched from the hub, enabling Esc-to-return behavior. +func NewChatModelFromHub(c client.AgentClient, agentID, agentName string, debug bool) ChatModel { + m := NewChatModel(c, agentName, "", debug) + m.agentID = agentID + m.fromHub = true + return m +} + +func (m ChatModel) Init() tea.Cmd { + cmds := []tea.Cmd{ + m.spinner.Tick, + textarea.Blink, + } + if m.initialQuery != "" { + cmds = append(cmds, func() tea.Msg { + return sendQueryMsg{query: m.initialQuery} + }) + } + return tea.Batch(cmds...) +} + +func (m ChatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + return m.handleKey(msg) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resize() + return m, nil + + case sendQueryMsg: + return m.sendQuery(msg.query) + + case channelReadyMsg: + m.events = msg.ch + return m, waitForEvent(m.events) + + case eventMsg: + return m.handleEvent(msg.event) + + case doneMsg: + m.streaming = false + m.events = nil + m.cancelFn = nil + m.flushBuffer() + m.activity = nil + m.updateViewport() + return m, nil + + case errMsg: + m.streaming = false + m.events = nil + m.cancelFn = nil + m.err = msg.err + m.messages = append(m.messages, Message{ + Role: RoleError, + Content: msg.err.Error(), + }) + m.activity = nil + m.updateViewport() + return m, nil + + case spinner.TickMsg: + if m.streaming { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + // Re-render viewport to update elapsed time display + m.updateViewport() + } + return m, tea.Batch(cmds...) + } + + if !m.streaming { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m ChatModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC: + if m.streaming && m.cancelFn != nil { + m.cancelFn() + m.streaming = false + m.events = nil + m.cancelFn = nil + m.activity = nil + m.messages = append(m.messages, Message{ + Role: RoleStatus, + Content: "cancelled", + }) + m.updateViewport() + return m, nil + } + return m, tea.Quit + + case tea.KeyEsc: + if m.streaming && m.cancelFn != nil { + m.cancelFn() + m.streaming = false + m.events = nil + m.cancelFn = nil + m.activity = nil + m.messages = append(m.messages, Message{ + Role: RoleStatus, + Content: "cancelled", + }) + m.updateViewport() + return m, nil + } + if m.fromHub { + return m, func() tea.Msg { + return ReturnToHubMsg{AgentID: m.agentID} + } + } + return m, tea.Quit + + case tea.KeyEnter: + if m.streaming { + return m, nil + } + if msg.Alt { + return m, nil + } + query := strings.TrimSpace(m.input.Value()) + if query == "" { + return m, nil + } + m.input.Reset() + + // Handle slash commands + switch { + case query == "/help": + return m, func() tea.Msg { return ToggleHelpMsg{} } + case query == "/hub": + if m.fromHub { + return m, func() tea.Msg { + return ReturnToHubMsg{AgentID: m.agentID} + } + } + m.messages = append(m.messages, Message{ + Role: RoleStatus, + Content: "Not launched from hub. Use Ctrl+C to quit.", + }) + m.updateViewport() + return m, nil + case query == "/init": + m.messages = append(m.messages, Message{ + Role: RoleStatus, + Content: fmt.Sprintf("Initializing %s...", m.agentName), + }) + m.updateViewport() + return m, nil + case query == "/clear": + m.messages = nil + m.updateViewport() + return m, nil + } + + return m.sendQuery(query) + + case tea.KeyPgUp: + m.viewport.HalfViewUp() + return m, nil + + case tea.KeyPgDown: + m.viewport.HalfViewDown() + return m, nil + } + + if !m.streaming { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m ChatModel) sendQuery(query string) (tea.Model, tea.Cmd) { + m.messages = append(m.messages, Message{ + Role: RoleUser, + Content: query, + }) + m.streaming = true + m.activity = nil + m.buffer.Reset() + m.queryStart = time.Now() + m.firstEvent = false + m.ttft = 0 + m.updateViewport() + + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFn = cancel + + c := m.client + return m, tea.Batch( + m.spinner.Tick, + func() tea.Msg { + ch, err := c.Send(ctx, query) + if err != nil { + return errMsg{err: err} + } + return channelReadyMsg{ch: ch} + }, + ) +} + +func waitForEvent(ch <-chan interface{}) tea.Cmd { + return func() tea.Msg { + if ch == nil { + return doneMsg{} + } + evt, ok := <-ch + if !ok { + return doneMsg{} + } + return eventMsg{event: evt} + } +} + +func (m ChatModel) handleEvent(evt interface{}) (tea.Model, tea.Cmd) { + if !m.firstEvent { + m.firstEvent = true + m.ttft = time.Since(m.queryStart) + } + + switch e := evt.(type) { + case event.ThinkingEvent: + m.activity = append(m.activity, ActivityItem{ + Kind: "thinking", + Content: e.Content, + }) + + case event.ToolStartEvent: + m.activity = append(m.activity, ActivityItem{ + Kind: "tool", + Content: e.Tool, + }) + + case event.ToolArgsEvent: + if len(m.activity) > 0 { + last := &m.activity[len(m.activity)-1] + if last.Kind == "tool" { + // Try to extract a clean command from the args JSON + argStr := extractCommandFromArgs(e.Args) + if argStr != "" { + last.Content = e.Tool + ": " + argStr + } + } + } + + case event.ToolResultEvent: + summary := e.Summary + if summary == "" { + summary = e.Title + } + // Truncate long summaries (stdout can be very long) + if len(summary) > 60 { + summary = summary[:60] + "..." + } + // Clean up newlines in summary + summary = strings.ReplaceAll(summary, "\n", " ") + if len(m.activity) > 0 { + last := &m.activity[len(m.activity)-1] + if last.Kind == "tool" { + last.Done = true + last.Success = &e.Success + if summary != "" { + last.Content += " → " + summary + } + } + } + + case event.ToolEndEvent: + if len(m.activity) > 0 { + last := &m.activity[len(m.activity)-1] + if last.Kind == "tool" && !last.Done { + last.Done = true + last.Success = &e.Success + } + } + + case event.StepEvent: + m.totalSteps = e.Step + m.activity = append(m.activity, ActivityItem{ + Kind: "step", + Content: fmt.Sprintf("Step %d/%d", e.Step, e.Total), + }) + + case event.StatusEvent: + if e.Status == "complete" { + m.flushBuffer() + m.streaming = false + m.activity = nil + m.updateViewport() + return m, nil + } + // Filter out redundant status messages that duplicate thinking/tool events + msg := e.Message + if msg == "Thinking" || strings.HasPrefix(msg, "Executing ") { + // Already shown by ThinkingEvent/ToolStartEvent — skip + } else if msg != "" { + m.activity = append(m.activity, ActivityItem{ + Kind: "status", + Content: msg, + }) + } + + case event.AnswerEvent: + m.flushBuffer() + duration := time.Since(m.queryStart) + rendered := components.RenderMarkdown(e.Content) + m.messages = append(m.messages, Message{ + Role: RoleAssistant, + Content: e.Content, + Rendered: rendered, + Duration: duration, + TTFT: m.ttft, + Steps: e.Steps, + ToolsUsed: e.ToolsUsed, + }) + m.streaming = false + m.activity = nil + m.totalSteps = e.Steps + m.updateViewport() + return m, nil + + case event.ChunkEvent: + m.buffer.WriteString(e.Content) + + case event.AgentErrorEvent: + m.messages = append(m.messages, Message{ + Role: RoleError, + Content: e.Content, + }) + m.streaming = false + m.activity = nil + m.updateViewport() + return m, nil + + case event.ErrorEvent: + m.messages = append(m.messages, Message{ + Role: RoleError, + Content: e.Content, + }) + m.streaming = false + m.activity = nil + m.updateViewport() + return m, nil + + case event.DoneEvent: + m.flushBuffer() + m.streaming = false + m.activity = nil + m.updateViewport() + return m, nil + } + + m.updateViewport() + return m, waitForEvent(m.events) +} + +func (m *ChatModel) flushBuffer() { + content := m.buffer.String() + if content == "" { + return + } + rendered := components.RenderMarkdown(content) + m.messages = append(m.messages, Message{ + Role: RoleAssistant, + Content: content, + Rendered: rendered, + }) + m.buffer.Reset() +} + +func (m *ChatModel) resize() { + headerH := 1 + statusH := 1 + inputH := 3 + padding := 2 + + vpHeight := m.height - headerH - statusH - inputH - padding + if vpHeight < 1 { + vpHeight = 1 + } + vpWidth := m.width + if vpWidth < 10 { + vpWidth = 10 + } + + m.viewport.Width = vpWidth + m.viewport.Height = vpHeight + m.input.SetWidth(vpWidth - 2) + + components.SetWordWrap(vpWidth - 4) + m.updateViewport() +} + +func (m *ChatModel) updateViewport() { + var sb strings.Builder + + // Show welcome message if no messages yet + if len(m.messages) == 0 && !m.streaming { + sb.WriteString(m.renderWelcome()) + sb.WriteString("\n") + } + + for _, msg := range m.messages { + sb.WriteString(m.renderMessage(msg)) + sb.WriteString("\n") + } + + // Live region: show a compact summary of current streaming state + if m.streaming && len(m.activity) > 0 { + sb.WriteString(m.renderLiveRegion()) + sb.WriteString("\n") + } + + buf := m.buffer.String() + if m.streaming && buf != "" { + sb.WriteString(assistantStyle.Render(buf)) + sb.WriteString("\n") + } + + m.viewport.SetContent(sb.String()) + m.viewport.GotoBottom() +} + +func (m ChatModel) renderWelcome() string { + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("150")). + Render("Welcome to GAIA") + + agent := lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + Render("Connected to: " + m.agentName) + + hint := activityStyle.Render("Type a message and press Enter to start chatting.\nType /help for available commands.") + + return title + "\n" + agent + "\n\n" + hint +} + +func (m ChatModel) renderMessage(msg Message) string { + switch msg.Role { + case RoleUser: + return userStyle.Render("▶ You: ") + msg.Content + + case RoleAssistant: + content := msg.Content + if msg.Rendered != "" { + content = msg.Rendered + } + panelWidth := m.width - 4 + if panelWidth < 20 { + panelWidth = 20 + } + panel := answerPanelStyle.Width(panelWidth).Render(content) + + // Perf stats line below the panel + if msg.Duration > 0 { + var stats []string + stats = append(stats, fmt.Sprintf("%.1fs", msg.Duration.Seconds())) + if msg.TTFT > 0 { + stats = append(stats, fmt.Sprintf("ttft %.1fs", msg.TTFT.Seconds())) + } + // Approximate output tokens (~4 chars per token for English) + outputTokens := len(msg.Content) / 4 + if outputTokens > 0 { + stats = append(stats, fmt.Sprintf("~%d tokens", outputTokens)) + // Tokens per second (output only) + inferTime := msg.Duration - msg.TTFT + if inferTime > 0 { + tps := float64(outputTokens) / inferTime.Seconds() + stats = append(stats, fmt.Sprintf("%.1f tok/s", tps)) + } + } + if msg.Steps > 0 { + stats = append(stats, fmt.Sprintf("%d steps", msg.Steps)) + } + if msg.ToolsUsed > 0 { + stats = append(stats, fmt.Sprintf("%d tools", msg.ToolsUsed)) + } + statsLine := activityStyle.Render(" " + strings.Join(stats, " · ")) + panel += "\n" + statsLine + } + return panel + + case RoleError: + panelWidth := m.width - 4 + if panelWidth < 20 { + panelWidth = 20 + } + return errorPanelStyle.Width(panelWidth).Render("⚠️ " + msg.Content) + + case RoleStatus: + return statusMsgStyle.Render(" " + msg.Content) + + default: + return msg.Content + } +} + +// renderLiveRegion renders a compact multi-line summary of current streaming state. +// Shows step progress + latest activity with spinner. +func (m ChatModel) renderLiveRegion() string { + var lines []string + + // Find the latest step and latest non-step activity + var latestStep *ActivityItem + var latestAction *ActivityItem + for i := len(m.activity) - 1; i >= 0; i-- { + item := &m.activity[i] + if item.Kind == "step" && latestStep == nil { + latestStep = item + } else if item.Kind != "step" && latestAction == nil { + latestAction = item + } + if latestStep != nil && latestAction != nil { + break + } + } + + // Step progress line + if latestStep != nil { + lines = append(lines, " "+stepStyle.Render(m.spinner.View()+" "+latestStep.Content)) + } + + // Current action line + if latestAction != nil { + line := m.renderActivityItem(*latestAction) + lines = append(lines, line) + } else if latestStep == nil { + // No activity yet — show generic spinner + lines = append(lines, " "+activityStyle.Render(m.spinner.View()+" Connecting...")) + } + + return strings.Join(lines, "\n") +} + +// renderActivityItem renders a single activity item with appropriate styling. +func (m ChatModel) renderActivityItem(item ActivityItem) string { + switch item.Kind { + case "thinking": + content := item.Content + if len(content) > 80 { + content = content[:80] + "..." + } + return " " + thinkingStyle.Render("🧠 "+content) + + case "tool": + if item.Done { + if item.Success != nil && *item.Success { + return " " + successStyle.Render("✓ ") + toolNameStyle.Render(item.Content) + } else if item.Success != nil { + return " " + failStyle.Render("✗ ") + toolNameStyle.Render(item.Content) + } + } + return " " + toolNameStyle.Render("🔧 "+item.Content) + + case "status": + return " " + lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("🎯 "+item.Content) + + default: + return " " + activityStyle.Render(item.Content) + } +} + +func (m ChatModel) View() string { + if m.width == 0 { + return m.renderWelcome() + } + + header := m.renderHeader() + divider := dividerStyle.Render(strings.Repeat("─", m.width)) + vpView := m.viewport.View() + + inputView := m.input.View() + if m.streaming { + elapsed := time.Since(m.queryStart) + elapsedStr := fmt.Sprintf("%.0fs", elapsed.Seconds()) + + label := "Waiting for agent..." + if len(m.activity) > 0 { + last := m.activity[len(m.activity)-1] + switch last.Kind { + case "tool": + parts := strings.SplitN(last.Content, ":", 2) + label = "Using " + parts[0] + "..." + case "thinking": + label = "Thinking..." + case "step": + label = last.Content + case "status": + label = last.Content + } + } + inputView = m.spinner.View() + " ◆ " + label + " " + activityStyle.Render(elapsedStr) + } + + hint := "Ctrl+C quit" + if m.streaming { + hint = "Esc cancel" + } else if m.fromHub { + hint = "Esc back · Ctrl+C quit" + } + + statusBar := components.RenderStatusBar(components.StatusBarState{ + AgentName: m.agentName, + Connected: m.connected, + Steps: m.totalSteps, + Streaming: m.streaming, + Hint: hint, + }, m.width) + + return lipgloss.JoinVertical(lipgloss.Left, + header, + divider, + vpView, + divider, + inputView, + statusBar, + ) +} + +// extractCommandFromArgs tries to extract a clean command string from tool args JSON. +func extractCommandFromArgs(raw json.RawMessage) string { + var args map[string]interface{} + if err := json.Unmarshal(raw, &args); err != nil { + s := string(raw) + if len(s) > 60 { + s = s[:60] + "..." + } + return s + } + // Look for common command fields + for _, key := range []string{"command", "cmd", "query", "path", "file"} { + if v, ok := args[key]; ok { + s := fmt.Sprintf("%v", v) + if len(s) > 60 { + s = s[:60] + "..." + } + return s + } + } + // Fallback: show first value + for _, v := range args { + s := fmt.Sprintf("%v", v) + if len(s) > 60 { + s = s[:60] + "..." + } + return s + } + return "" +} + +func (m ChatModel) renderHeader() string { + title := headerStyle.Render("GAIA") + name := lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Render(" │ " + m.agentName) + return title + name +} diff --git a/tui/internal/ui/components/confirm.go b/tui/internal/ui/components/confirm.go new file mode 100644 index 000000000..8854e88fa --- /dev/null +++ b/tui/internal/ui/components/confirm.go @@ -0,0 +1,112 @@ +package components + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ConfirmResult int + +const ( + ConfirmPending ConfirmResult = iota + ConfirmYes + ConfirmNo +) + +type ConfirmMsg struct { + ID string + Result ConfirmResult +} + +type ConfirmModel struct { + id string + title string + message string + yesLabel string + noLabel string + focused bool // true = Yes is focused + width int +} + +func NewConfirmModel(id, title, message string) ConfirmModel { + return ConfirmModel{ + id: id, + title: title, + message: message, + yesLabel: "Yes", + noLabel: "No", + focused: false, // default to No (safer) + width: 50, + } +} + +var ( + confirmBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("114")). + Padding(1, 2) + + confirmTitle = lipgloss.NewStyle().Bold(true) + + btnFocused = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("230")). + Background(lipgloss.Color("114")). + Padding(0, 2) + + btnUnfocused = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + Padding(0, 2) +) + +func (m ConfirmModel) Init() tea.Cmd { return nil } + +func (m ConfirmModel) Update(msg tea.Msg) (ConfirmModel, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "left", "right", "tab", "shift+tab": + m.focused = !m.focused + case "enter": + result := ConfirmNo + if m.focused { + result = ConfirmYes + } + return m, func() tea.Msg { + return ConfirmMsg{ID: m.id, Result: result} + } + case "y", "Y": + return m, func() tea.Msg { + return ConfirmMsg{ID: m.id, Result: ConfirmYes} + } + case "n", "N", "esc": + return m, func() tea.Msg { + return ConfirmMsg{ID: m.id, Result: ConfirmNo} + } + } + } + return m, nil +} + +func (m ConfirmModel) View() string { + title := confirmTitle.Render(m.title) + msg := m.message + + var yesBtn, noBtn string + if m.focused { + yesBtn = btnFocused.Render(m.yesLabel) + noBtn = btnUnfocused.Render(m.noLabel) + } else { + yesBtn = btnUnfocused.Render(m.yesLabel) + noBtn = btnFocused.Render(m.noLabel) + } + + buttons := yesBtn + " " + noBtn + + content := title + "\n\n" + msg + "\n\n" + buttons + return confirmBorder.Width(m.width).Render(content) +} + +func (m ConfirmModel) Overlay(background string, width, height int) string { + dialog := m.View() + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, dialog) +} diff --git a/tui/internal/ui/components/helpoverlay.go b/tui/internal/ui/components/helpoverlay.go new file mode 100644 index 000000000..c507cff01 --- /dev/null +++ b/tui/internal/ui/components/helpoverlay.go @@ -0,0 +1,67 @@ +package components + +import "github.com/charmbracelet/lipgloss" + +type HelpContext int + +const ( + HelpContextHub HelpContext = iota + HelpContextChat +) + +var helpBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("114")). + Padding(1, 2) + +// RenderHelpOverlay renders a help panel centered over a background view. +func RenderHelpOverlay(ctx HelpContext, background string, width, height int) string { + var content string + switch ctx { + case HelpContextHub: + content = hubHelpText + case HelpContextChat: + content = chatHelpText + } + + boxWidth := width - 4 + if boxWidth > 60 { + boxWidth = 60 + } + + box := helpBoxStyle.Width(boxWidth).Render(content) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} + +const hubHelpText = ` GAIA Agent Hub + + Keyboard Shortcuts + ────────────────── + Enter Launch selected agent + / Search agents + Tab Next category + Shift+Tab Previous category + d Delete/uninstall agent + v Vote for coming-soon agent + r Request a new agent + ? Toggle this help + q, Ctrl+C Quit + + Data: Votes send only the agent ID + to amd-gaia.ai — no personal data.` + +const chatHelpText = ` GAIA Chat + + Keyboard Shortcuts + ────────────────── + Enter Send message + Esc Cancel streaming / Return to hub + Ctrl+C Quit + PgUp/PgDn Scroll conversation + + Commands + ────────────────── + /help Show this help + /hub Return to Agent Hub + /init Initialize agent LLM + /clear Clear conversation` diff --git a/tui/internal/ui/components/markdown.go b/tui/internal/ui/components/markdown.go new file mode 100644 index 000000000..89cbd1fbd --- /dev/null +++ b/tui/internal/ui/components/markdown.go @@ -0,0 +1,41 @@ +package components + +import ( + "sync" + + "github.com/charmbracelet/glamour" +) + +var ( + renderer *glamour.TermRenderer + rendererOnce sync.Once + wordWrap = 100 +) + +func initRenderer() { + var err error + renderer, err = glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(wordWrap), + ) + if err != nil { + renderer = nil + } +} + +func SetWordWrap(width int) { + wordWrap = width + rendererOnce = sync.Once{} +} + +func RenderMarkdown(content string) string { + rendererOnce.Do(initRenderer) + if renderer == nil || content == "" { + return content + } + out, err := renderer.Render(content) + if err != nil { + return content + } + return out +} diff --git a/tui/internal/ui/components/statusbar.go b/tui/internal/ui/components/statusbar.go new file mode 100644 index 000000000..dadd508e2 --- /dev/null +++ b/tui/internal/ui/components/statusbar.go @@ -0,0 +1,65 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +type StatusBarState struct { + AgentName string + Connected bool + Steps int + Streaming bool + Hint string +} + +var ( + statusBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Foreground(lipgloss.Color("252")). + Padding(0, 1) + + connectedDot = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("●") + disconnectedDot = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") +) + +func RenderStatusBar(state StatusBarState, width int) string { + dot := disconnectedDot + status := "disconnected" + if state.Connected { + dot = connectedDot + status = "connected" + } + if state.Streaming { + status = "streaming" + } + + // Build left and right content + leftText := fmt.Sprintf("%s %s", state.AgentName, status) + rightText := "" + if state.Hint != "" { + rightText = state.Hint + } else if state.Steps > 0 { + rightText = fmt.Sprintf("steps: %d", state.Steps) + } + + // Calculate padding (accounting for dot + spaces + padding(0,1) = 2 chars) + // left: " ● agentname status" — dot is 1 visible char + // right: "hint " + leftVisibleLen := 3 + len(leftText) // " ● " + text + rightVisibleLen := len(rightText) + if rightVisibleLen > 0 { + rightVisibleLen++ // trailing space + } + + innerWidth := width - 2 // padding(0,1) adds 1 on each side + gap := innerWidth - leftVisibleLen - rightVisibleLen + if gap < 1 { + gap = 1 + } + + content := " " + dot + " " + leftText + strings.Repeat(" ", gap) + rightText + return statusBarStyle.Width(width).Render(content) +} diff --git a/tui/internal/ui/hub/delegate.go b/tui/internal/ui/hub/delegate.go new file mode 100644 index 000000000..6ce68a76b --- /dev/null +++ b/tui/internal/ui/hub/delegate.go @@ -0,0 +1,104 @@ +package hub + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/amd/gaia/tui/internal/catalog" +) + +var ( + categoryStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")). + Italic(true) + + versionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("238")) + + selectedCursor = lipgloss.NewStyle(). + Foreground(lipgloss.Color("212")). + Bold(true). + Render("▸ ") + + normalCursor = " " +) + +type agentDelegate struct{} + +func newAgentDelegate() agentDelegate { + return agentDelegate{} +} + +func (d agentDelegate) Height() int { return 3 } +func (d agentDelegate) Spacing() int { return 1 } +func (d agentDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { + return nil +} + +func (d agentDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + agent, ok := item.(catalog.Agent) + if !ok { + return + } + + isSelected := index == m.Index() + + dot := statusDotFor(agent.Status) + + // Line 1: cursor + dot + icon + name + version + ver := "" + if agent.Version != "" { + ver = " " + versionStyle.Render("v"+agent.Version) + } + + name := agent.Icon + " " + agent.Name + if isSelected { + name = selectedItemStyle.Render(name) + } else { + name = normalItemStyle.Render(name) + } + + cursor := normalCursor + if isSelected { + cursor = selectedCursor + } + + line1 := cursor + dot + " " + name + ver + + // Line 2: description + desc := agent.Description + if agent.Status == catalog.StatusComingSoon && agent.Votes > 0 { + desc += voteStyle.Render(fmt.Sprintf(" ▲ %d", agent.Votes)) + } + if isSelected { + desc = " " + selectedDescStyle.Render(desc) + } else { + desc = " " + descriptionStyle.Render(desc) + } + + // Line 3: category tag + cat := " " + categoryStyle.Render(agent.Category) + + fmt.Fprintf(w, "%s\n%s\n%s", line1, desc, cat) +} + +func statusDotFor(status catalog.AgentStatus) string { + switch status { + case catalog.StatusActive: + return activeDot + case catalog.StatusIdle: + return idleDot + case catalog.StatusInstalled: + return installedDot + case catalog.StatusAvailable: + return availableDot + case catalog.StatusComingSoon: + return comingSoonDot + default: + return " " + } +} diff --git a/tui/internal/ui/hub/model.go b/tui/internal/ui/hub/model.go new file mode 100644 index 000000000..387edebbc --- /dev/null +++ b/tui/internal/ui/hub/model.go @@ -0,0 +1,371 @@ +package hub + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/amd/gaia/tui/internal/catalog" + "github.com/amd/gaia/tui/internal/ui/components" + "github.com/amd/gaia/tui/internal/vote" +) + +// LaunchAgentMsg signals the root model to switch to chat with this agent. +type LaunchAgentMsg struct { + Agent catalog.Agent +} + +type HubModel struct { + catalog *catalog.Catalog + list list.Model + activeTab int + tabs []catalog.Section + confirm *components.ConfirmModel + debug bool + width int + height int + status string // ephemeral status messages +} + +func NewHubModel(cat *catalog.Catalog, debug bool) HubModel { + tabs := catalog.AllSections() + + delegate := newAgentDelegate() + l := list.New(agentsToItems(cat.BySection(tabs[0])), delegate, 80, 20) + l.Title = "" + l.SetShowHelp(false) + l.SetShowStatusBar(false) + l.SetShowTitle(false) + l.SetFilteringEnabled(true) + l.DisableQuitKeybindings() + + return HubModel{ + catalog: cat, + list: l, + activeTab: 0, + tabs: tabs, + debug: debug, + } +} + +func (m HubModel) Init() tea.Cmd { + return nil +} + +func (m HubModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // If confirmation dialog is active, route everything to it + if m.confirm != nil { + return m.handleConfirm(msg) + } + + switch msg := msg.(type) { + case tea.KeyMsg: + // Don't intercept keys when filtering (typing in search) + if m.list.FilterState() == list.Filtering { + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd + } + return m.handleKey(msg) + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeList() + return m, nil + + case vote.VoteResultMsg: + // Vote was already incremented locally in the key handler + if msg.Err != nil { + m.status = fmt.Sprintf("Voted for %s (offline)", msg.AgentID) + } + m.refreshList() + return m, nil + + case components.ConfirmMsg: + if msg.Result == components.ConfirmYes { + m.catalog.Remove(msg.ID) + m.status = fmt.Sprintf("Removed %s", msg.ID) + m.refreshList() + } + m.confirm = nil + return m, nil + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m HubModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + + case "enter": + selected, ok := m.list.SelectedItem().(catalog.Agent) + if !ok { + return m, nil + } + if selected.Status.IsLaunchable() { + return m, func() tea.Msg { + return LaunchAgentMsg{Agent: selected} + } + } + if selected.Status == catalog.StatusAvailable { + m.status = fmt.Sprintf("%s is not installed yet", selected.Name) + } else if selected.Status == catalog.StatusComingSoon { + m.status = fmt.Sprintf("%s is coming soon — press 'v' to vote", selected.Name) + } + return m, nil + + case "tab": + m.activeTab = (m.activeTab + 1) % len(m.tabs) + m.refreshList() + m.status = "" + return m, nil + + case "shift+tab": + m.activeTab = (m.activeTab - 1 + len(m.tabs)) % len(m.tabs) + m.refreshList() + m.status = "" + return m, nil + + case "d", "delete", "backspace": + selected, ok := m.list.SelectedItem().(catalog.Agent) + if !ok { + return m, nil + } + if selected.Status == catalog.StatusInstalled || selected.Status == catalog.StatusIdle { + confirm := components.NewConfirmModel( + selected.ID, + fmt.Sprintf("Uninstall \"%s\"?", selected.Name), + "This will remove the agent and clear its cache.\nYou can reinstall it later.", + ) + m.confirm = &confirm + } + return m, nil + + case "v": + selected, ok := m.list.SelectedItem().(catalog.Agent) + if !ok { + return m, nil + } + if selected.Status == catalog.StatusComingSoon { + // Increment locally immediately for responsive UX + m.catalog.IncrementVotes(selected.ID) + m.refreshList() + m.status = fmt.Sprintf("Voted for %s! (vote sent to amd-gaia.ai)", selected.Name) + // Fire HTTP POST — sends only agent_id, no personal data + return m, vote.CastVote(selected.ID) + } + return m, nil + + case "?": + return m, func() tea.Msg { return components.HelpContext(components.HelpContextHub) } + + case "r": + // Request a new agent — opens a text input for the user's idea + m.status = "Agent requests coming soon — share ideas at github.com/amd/gaia/issues" + return m, nil + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m HubModel) handleConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + updated, cmd := m.confirm.Update(msg) + m.confirm = &updated + return m, cmd + case components.ConfirmMsg: + if msg.Result == components.ConfirmYes { + m.catalog.Remove(msg.ID) + m.status = fmt.Sprintf("Removed %s", msg.ID) + m.refreshList() + } + m.confirm = nil + return m, nil + } + return m, nil +} + +func (m HubModel) View() string { + if m.width == 0 { + return "Loading..." + } + + header := m.renderHeader() + dashboard := m.renderDashboard() + tabBar := m.renderTabs() + divider := dividerStyle.Render(strings.Repeat("─", m.width)) + + listView := m.list.View() + + statusLine := "" + if m.status != "" { + statusLine = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")). + Padding(0, 1). + Render(m.status) + } + + footer := m.renderFooter() + + view := lipgloss.JoinVertical(lipgloss.Left, + header, + dashboard, + tabBar, + divider, + listView, + statusLine, + footer, + ) + + if m.confirm != nil { + return m.confirm.Overlay(view, m.width, m.height) + } + + return view +} + +func (m HubModel) renderHeader() string { + logo := colorizeRobotLogo() + + gaiaText := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("150")). + Render(" G A I A") + + subtitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")). + Italic(true). + Render(" Local AI Agent Hub — by AMD") + + return logo + "\n" + gaiaText + " " + subtitle + "\n" +} + +// colorizeRobotLogo renders the GAIA robot ASCII art with colors matching the mascot PNG. +func colorizeRobotLogo() string { + // Colors from the GAIA mascot: green body, cyan eyes, dark background + bright := lipgloss.NewStyle().Foreground(lipgloss.Color("150")) // brightest green (body highlights) + body := lipgloss.NewStyle().Foreground(lipgloss.Color("114")) // solid green (body) + mid := lipgloss.NewStyle().Foreground(lipgloss.Color("107")) // muted green (mid-tone) + detail := lipgloss.NewStyle().Foreground(lipgloss.Color("65")) // dark green (detail) + shadow := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) // dark shadow + eye := lipgloss.NewStyle().Foreground(lipgloss.Color("51")) // cyan (eyes) + + lines := []string{ + " +=------- ", + " =====++======----- ", + " ++======+*=========----- ", + " +++++++++++===========------ ", + " ++++*#*++++++++============-=-- ", + " +***+++==#+++++++++==============-- ", + " +#####*++=*%+++++##+++============== ", + " *##%#%##++*%*++*%%%%%%%%%#########+=- ", + " +#%#%%*#+*#%++#%%%#######%########%%%+ ", + " *+**#####%++*%%%##*--+##%%%%%##++##%++ ", + " +#####%%*+*#%%%##+--+*##%%%##*--*##** ", + " +##%##++*#%%%%###++###%%%%%#*==##*# ", + " +**##+*++#%%%%%%####%%%%%%%%####* ", + " +*%%%**#+*%##%%%%%%%%%%%%%%%%%#+ ", + " *##*###**+*####%%%%%%%%%%###= ", + " ==+*#######+*##########+= ", + " +#############*= ", + " +=***%%%%#** ", + " %%%##*##**##***== ", + " #*+++++++**+= ", + } + + var result strings.Builder + for _, line := range lines { + for _, ch := range line { + switch ch { + case '%': + result.WriteString(bright.Render(string(ch))) + case '#': + result.WriteString(body.Render(string(ch))) + case '*': + result.WriteString(mid.Render(string(ch))) + case '+': + result.WriteString(detail.Render(string(ch))) + case '=': + result.WriteString(shadow.Render(string(ch))) + case '-': + // Eye sockets — use cyan for the dash markers inside the face area + result.WriteString(eye.Render(string(ch))) + case ' ': + result.WriteByte(' ') + default: + result.WriteString(shadow.Render(string(ch))) + } + } + result.WriteByte('\n') + } + return result.String() +} + +func (m HubModel) renderDashboard() string { + installed, active, idle := m.catalog.DashboardStats() + + parts := []string{ + installedLabel.Render(fmt.Sprintf(" Installed: %d", installed)), + } + if active > 0 { + parts = append(parts, activeLabel.Render(fmt.Sprintf("● Active: %d", active))) + } + if idle > 0 { + parts = append(parts, idleLabel.Render(fmt.Sprintf("● Idle: %d", idle))) + } + + return dashboardStyle.Render(strings.Join(parts, " ")) +} + +func (m HubModel) renderTabs() string { + var tabs []string + for i, section := range m.tabs { + label := string(section) + count := len(m.catalog.BySection(section)) + label = fmt.Sprintf("%s (%d)", label, count) + if i == m.activeTab { + tabs = append(tabs, tabActive.Render(label)) + } else { + tabs = append(tabs, tabInactive.Render(label)) + } + } + return strings.Join(tabs, "") +} + +func (m HubModel) renderFooter() string { + hint := "Enter=launch /=search Tab=category d=delete v=vote r=request ?=help q=quit" + return statusBarStyle.Width(m.width).Render(" " + hint) +} + +func (m *HubModel) refreshList() { + items := agentsToItems(m.catalog.BySection(m.tabs[m.activeTab])) + m.list.SetItems(items) +} + +func (m *HubModel) resizeList() { + overhead := 26 // logo(20) + gaia+subtitle(1) + dashboard(1) + tabs(1) + divider(1) + footer(1) + padding(1) + h := m.height - overhead + if h < 5 { + h = 5 + } + m.list.SetSize(m.width, h) +} + +func agentsToItems(agents []catalog.Agent) []list.Item { + items := make([]list.Item, len(agents)) + for i, a := range agents { + items[i] = a + } + return items +} diff --git a/tui/internal/ui/hub/styles.go b/tui/internal/ui/hub/styles.go new file mode 100644 index 000000000..3d754945a --- /dev/null +++ b/tui/internal/ui/hub/styles.go @@ -0,0 +1,66 @@ +package hub + +import "github.com/charmbracelet/lipgloss" + +// AMD-inspired color palette: greens and teals from the GAIA robot mascot +var ( + // Primary accent — muted green (matches GAIA robot) + accentColor = lipgloss.Color("114") + // Bright accent — for selected/highlighted items + brightAccent = lipgloss.Color("150") + + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(brightAccent). + Padding(0, 1) + + dashboardStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")). + Padding(0, 1) + + installedLabel = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + activeLabel = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) + idleLabel = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + + tabActive = lipgloss.NewStyle(). + Bold(true). + Foreground(brightAccent). + Underline(true). + Padding(0, 2) + + tabInactive = lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")). + Padding(0, 2) + + dividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("238")) + + statusBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Foreground(lipgloss.Color("252")). + Padding(0, 1) + + // Agent list item styles + selectedItemStyle = lipgloss.NewStyle(). + Foreground(brightAccent). + Bold(true) + + normalItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + descriptionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("243")) + + selectedDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + // Status dots — green for active, amber for idle, dim for installed + activeDot = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("●") + idleDot = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("●") + installedDot = lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Render("●") + availableDot = lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Render("○") + comingSoonDot = lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Render("◌") + + voteStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")) +) diff --git a/tui/internal/ui/root/model.go b/tui/internal/ui/root/model.go new file mode 100644 index 000000000..d5e21cfea --- /dev/null +++ b/tui/internal/ui/root/model.go @@ -0,0 +1,175 @@ +package root + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/amd/gaia/tui/internal/catalog" + "github.com/amd/gaia/tui/internal/client" + "github.com/amd/gaia/tui/internal/ui/chat" + "github.com/amd/gaia/tui/internal/ui/components" + "github.com/amd/gaia/tui/internal/ui/hub" +) + +type view int + +const ( + viewHub view = iota + viewChat +) + +type RootModel struct { + activeView view + hub hub.HubModel + chat *chat.ChatModel + chatClient client.AgentClient + catalog *catalog.Catalog + showHelp bool + helpCtx components.HelpContext + width int + height int + debug bool +} + +func NewRootModel(cat *catalog.Catalog, debug bool) RootModel { + return RootModel{ + activeView: viewHub, + hub: hub.NewHubModel(cat, debug), + catalog: cat, + debug: debug, + } +} + +func (m RootModel) Init() tea.Cmd { + return m.hub.Init() +} + +func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + // Forward to active sub-model + switch m.activeView { + case viewHub: + updated, cmd := m.hub.Update(msg) + m.hub = updated.(hub.HubModel) + return m, cmd + case viewChat: + if m.chat != nil { + updated, cmd := m.chat.Update(msg) + chatModel := updated.(chat.ChatModel) + m.chat = &chatModel + return m, cmd + } + } + return m, nil + + case hub.LaunchAgentMsg: + return m.launchAgent(msg.Agent) + + case chat.ReturnToHubMsg: + return m.returnToHub(msg.AgentID) + + case chat.ToggleHelpMsg: + m.showHelp = !m.showHelp + m.helpCtx = components.HelpContextChat + return m, nil + + case components.HelpContext: + m.showHelp = !m.showHelp + m.helpCtx = msg + return m, nil + + case tea.KeyMsg: + if m.showHelp { + // Any key dismisses help overlay + m.showHelp = false + return m, nil + } + } + + // Forward to active sub-model + switch m.activeView { + case viewHub: + updated, cmd := m.hub.Update(msg) + m.hub = updated.(hub.HubModel) + return m, cmd + case viewChat: + if m.chat != nil { + updated, cmd := m.chat.Update(msg) + chatModel := updated.(chat.ChatModel) + m.chat = &chatModel + return m, cmd + } + } + + return m, nil +} + +func (m RootModel) View() string { + var base string + switch m.activeView { + case viewHub: + base = m.hub.View() + case viewChat: + if m.chat != nil { + base = m.chat.View() + } + } + + if m.showHelp { + return components.RenderHelpOverlay(m.helpCtx, base, m.width, m.height) + } + + return base +} + +func (m RootModel) launchAgent(agent catalog.Agent) (tea.Model, tea.Cmd) { + cmdLine := agent.BinaryPath + if len(agent.BinaryArgs) > 0 { + cmdLine += " " + strings.Join(agent.BinaryArgs, " ") + } + + c := client.NewSubprocessClient(cmdLine, m.debug) + m.chatClient = c + + m.catalog.SetStatus(agent.ID, catalog.StatusActive) + + chatModel := chat.NewChatModelFromHub(c, agent.ID, agent.Name, m.debug) + m.chat = &chatModel + m.activeView = viewChat + + // Forward initial window size + init the chat model + var cmds []tea.Cmd + cmds = append(cmds, m.chat.Init()) + if m.width > 0 && m.height > 0 { + cmds = append(cmds, func() tea.Msg { + return tea.WindowSizeMsg{Width: m.width, Height: m.height} + }) + } + + return m, tea.Batch(cmds...) +} + +func (m RootModel) returnToHub(agentID string) (tea.Model, tea.Cmd) { + m.catalog.SetStatus(agentID, catalog.StatusIdle) + + if m.chatClient != nil { + m.chatClient.Close() + m.chatClient = nil + } + m.chat = nil + m.activeView = viewHub + + // Re-send window size to hub + var cmds []tea.Cmd + if m.width > 0 && m.height > 0 { + cmds = append(cmds, func() tea.Msg { + return tea.WindowSizeMsg{Width: m.width, Height: m.height} + }) + } + + return m, tea.Batch(cmds...) +} diff --git a/tui/internal/vote/client.go b/tui/internal/vote/client.go new file mode 100644 index 000000000..93ad7bca0 --- /dev/null +++ b/tui/internal/vote/client.go @@ -0,0 +1,60 @@ +package vote + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +const voteEndpoint = "https://amd-gaia.ai/api/votes" + +type VoteRequest struct { + AgentID string `json:"agent_id"` +} + +type VoteResponse struct { + Success bool `json:"success"` + Votes int `json:"votes"` +} + +// VoteResultMsg is sent back to the Bubble Tea model after the HTTP call. +type VoteResultMsg struct { + AgentID string + Votes int + Err error +} + +// CastVote returns a tea.Cmd that performs the HTTP POST in a goroutine. +func CastVote(agentID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + body, _ := json.Marshal(VoteRequest{AgentID: agentID}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, voteEndpoint, bytes.NewReader(body)) + if err != nil { + return VoteResultMsg{AgentID: agentID, Err: err} + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + // Network error — still increment locally + return VoteResultMsg{AgentID: agentID, Votes: -1, Err: err} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return VoteResultMsg{AgentID: agentID, Err: fmt.Errorf("vote API returned %d", resp.StatusCode)} + } + + var vr VoteResponse + json.NewDecoder(resp.Body).Decode(&vr) + return VoteResultMsg{AgentID: agentID, Votes: vr.Votes} + } +} diff --git a/tui/test/mockagent/main.go b/tui/test/mockagent/main.go new file mode 100644 index 000000000..88207e8c3 --- /dev/null +++ b/tui/test/mockagent/main.go @@ -0,0 +1,147 @@ +// Package main implements a mock GAIA agent for TUI testing. +// It reads queries from stdin and emits realistic JSONL events to stdout, +// simulating an agent session without requiring a real LLM backend. +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "math/rand" + "os" + "strings" + "time" +) + +func emit(v map[string]interface{}) { + b, err := json.Marshal(v) + if err != nil { + return + } + fmt.Println(string(b)) +} + +func delay(minMs, maxMs int) { + ms := minMs + rand.Intn(maxMs-minMs+1) + time.Sleep(time.Duration(ms) * time.Millisecond) +} + +// toolScenario returns a tool name, command, and result based on the query. +func toolScenario(query string) (tool, command, stdout, summary string) { + q := strings.ToLower(query) + switch { + case strings.Contains(q, "file") || strings.Contains(q, "list") || strings.Contains(q, "ls"): + return "bash_execute", "ls -la /tmp", + "total 48\ndrwxrwxrwt 12 root root 4096 May 20 10:00 .\n-rw-r--r-- 1 user user 2300 May 20 09:55 report.txt\n-rw-r--r-- 1 user user 1100 May 20 09:50 data.csv\n-rw-r--r-- 1 user user 45000 May 20 09:45 backup.tar.gz\ndrwxr-xr-x 2 user user 4096 May 20 09:40 logs\n-rwxr-xr-x 1 user user 8192 May 20 09:35 script.sh", + "Listed 5 files and 1 directory" + case strings.Contains(q, "search") || strings.Contains(q, "find") || strings.Contains(q, "grep"): + return "bash_execute", fmt.Sprintf("grep -r '%s' .", query), + "./src/main.go:42: // matching result\n./README.md:15: relevant documentation", + "Found 2 matches" + case strings.Contains(q, "python") || strings.Contains(q, "code") || strings.Contains(q, "write"): + return "file_write", "write hello.py", + "File written: hello.py (12 lines)", + "Created hello.py" + case strings.Contains(q, "git") || strings.Contains(q, "status"): + return "bash_execute", "git status", + "On branch main\nYour branch is up to date with 'origin/main'.\n\nChanges not staged for commit:\n modified: src/main.go\n modified: README.md\n\nno changes added to commit", + "2 files modified" + case strings.Contains(q, "install") || strings.Contains(q, "setup"): + return "bash_execute", "pip install requests", + "Collecting requests\n Downloading requests-2.31.0.tar.gz (110 kB)\nInstalling collected packages: requests\nSuccessfully installed requests-2.31.0", + "Installed requests 2.31.0" + default: + return "bash_execute", "echo 'hello world'", + "hello world", + "Command executed successfully" + } +} + +func handleQuery(query string) { + tool, command, stdout, summary := toolScenario(query) + totalSteps := 3 + + // Step 1: Thinking + emit(map[string]interface{}{ + "type": "step", "step": 1, "total": totalSteps, "status": "running", + }) + delay(100, 200) + + emit(map[string]interface{}{ + "type": "thinking", + "content": fmt.Sprintf("Let me analyze the request: \"%s\"", query), + }) + delay(200, 400) + + emit(map[string]interface{}{ + "type": "status", "status": "working", "message": "Analyzing request", + }) + delay(150, 300) + + // Step 2: Tool execution + emit(map[string]interface{}{ + "type": "step", "step": 2, "total": totalSteps, "status": "running", + }) + delay(50, 100) + + emit(map[string]interface{}{ + "type": "tool_start", "tool": tool, "detail": command, + }) + delay(100, 200) + + emit(map[string]interface{}{ + "type": "tool_args", "tool": tool, + "args": map[string]string{"command": command}, + }) + delay(300, 600) + + emit(map[string]interface{}{ + "type": "tool_end", "success": true, + }) + delay(50, 100) + + emit(map[string]interface{}{ + "type": "tool_result", "title": tool, "success": true, + "command_output": map[string]string{"stdout": stdout}, + "summary": summary, + }) + delay(100, 200) + + // Step 3: Generate answer + emit(map[string]interface{}{ + "type": "step", "step": 3, "total": totalSteps, "status": "running", + }) + delay(200, 400) + + answer := fmt.Sprintf("Based on your request \"%s\", here's what I found:\n\n"+ + "## Results\n\n"+ + "I executed `%s` and got the following output:\n\n"+ + "```\n%s\n```\n\n"+ + "**Summary:** %s\n\n"+ + "Let me know if you need anything else!", + query, command, stdout, summary) + + emit(map[string]interface{}{ + "type": "answer", "content": answer, + "steps": totalSteps, "tools_used": 1, + }) +} + +func main() { + scanner := bufio.NewScanner(os.Stdin) + // 1MB buffer for large queries + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + for scanner.Scan() { + query := strings.TrimSpace(scanner.Text()) + if query == "" { + continue + } + handleQuery(query) + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "mockagent: stdin read error: %v\n", err) + os.Exit(1) + } +} diff --git a/tui/test/smoke_test.go b/tui/test/smoke_test.go new file mode 100644 index 000000000..3696b4623 --- /dev/null +++ b/tui/test/smoke_test.go @@ -0,0 +1,204 @@ +package test + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/amd/gaia/tui/internal/catalog" + "github.com/amd/gaia/tui/internal/ui/chat" + "github.com/amd/gaia/tui/internal/ui/hub" + "github.com/amd/gaia/tui/internal/ui/root" +) + +func TestHubModelRenders(t *testing.T) { + cat := catalog.NewCatalog() + m := hub.NewHubModel(cat, false) + + // Simulate window size + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + hubModel := updated.(hub.HubModel) + + view := hubModel.View() + if view == "" { + t.Fatal("hub view is empty") + } + if view == "Loading..." { + t.Fatal("hub still showing loading after window size") + } + + // Check for key content in rendered view + // Only Bash is installed — it should appear in the default Installed tab + checks := []string{"Agent Hub", "Bash"} + for _, check := range checks { + if !contains(view, check) { + t.Errorf("hub view missing expected content: %q", check) + } + } + t.Logf("Hub view length: %d chars", len(view)) +} + +func TestHubTabSwitching(t *testing.T) { + cat := catalog.NewCatalog() + m := hub.NewHubModel(cat, false) + + // Set window size + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(hub.HubModel) + + // Tab to Available section + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("tab")}) + + // Verify no panic + view := updated.(hub.HubModel).View() + if view == "" { + t.Fatal("view empty after tab") + } +} + +func TestHubSearch(t *testing.T) { + cat := catalog.NewCatalog() + m := hub.NewHubModel(cat, false) + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(hub.HubModel) + + // Press / to enter search mode + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + + // Verify no panic + view := updated.(hub.HubModel).View() + if view == "" { + t.Fatal("view empty after search") + } +} + +func TestRootModelStartsWithHub(t *testing.T) { + cat := catalog.NewCatalog() + m := root.NewRootModel(cat, false) + + // Set window size + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + + view := updated.(root.RootModel).View() + if view == "" { + t.Fatal("root view is empty") + } + if !contains(view, "Agent Hub") { + t.Error("root view missing Agent Hub text") + } +} + +func TestChatModelWelcome(t *testing.T) { + // Use nil client — we won't send queries + m := chat.NewChatModel(nil, "test-agent", "", false) + + // View before window size — should show welcome + view := m.View() + if !contains(view, "Welcome to GAIA") { + t.Error("chat view missing welcome message before window size") + } + + // After window size + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + view = updated.(chat.ChatModel).View() + + if !contains(view, "Welcome to GAIA") { + t.Error("chat view missing welcome message after window size") + } + if !contains(view, "test-agent") { + t.Error("chat view missing agent name") + } + if !contains(view, "Ctrl+C to quit") { + t.Error("chat view missing quit hint") + } +} + +func TestChatModelFromHub(t *testing.T) { + m := chat.NewChatModelFromHub(nil, "bash", "Bash", false) + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + view := updated.(chat.ChatModel).View() + + if !contains(view, "Esc back") { + t.Error("hub-launched chat missing 'Esc back' hint") + } +} + +func TestBinaryDiscovery(t *testing.T) { + cat := catalog.NewCatalog() + cat.DiscoverBinaries() + + bash := cat.Get("bash") + if bash == nil { + t.Fatal("bash agent not found") + } + // If gaia-bash.exe exists in the repo, discovery should find it + if bash.BinaryPath != "gaia-bash" { + // Discovery found something — verify it's a real path + t.Logf("Discovered bash binary: %s", bash.BinaryPath) + } else { + t.Logf("Binary discovery did not find gaia-bash (expected if not built)") + } +} + +func TestDashboardStats(t *testing.T) { + cat := catalog.NewCatalog() + + installed, active, idle := cat.DashboardStats() + if installed != 1 { + t.Errorf("expected 1 installed (bash only), got %d", installed) + } + if active != 0 { + t.Errorf("expected 0 active, got %d", active) + } + if idle != 0 { + t.Errorf("expected 0 idle, got %d", idle) + } + + // Set one to active + cat.SetStatus("bash", catalog.StatusActive) + installed, active, idle = cat.DashboardStats() + if active != 1 { + t.Errorf("expected 1 active after SetStatus, got %d", active) + } + if installed != 0 { + t.Errorf("expected 0 installed after SetStatus (bash now active), got %d", installed) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// stripAnsi removes ANSI escape sequences from a string. +func stripAnsi(s string) string { + var result []byte + i := 0 + for i < len(s) { + if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' { + // Skip until we find the terminating character + j := i + 2 + for j < len(s) && !((s[j] >= 'A' && s[j] <= 'Z') || (s[j] >= 'a' && s[j] <= 'z') || s[j] == '~') { + j++ + } + if j < len(s) { + j++ // skip the terminating character + } + i = j + } else { + result = append(result, s[i]) + i++ + } + } + return string(result) +}