diff --git a/.github/actions/checkout-eyrie/action.yml b/.github/actions/checkout-eyrie/action.yml index 3126d088..bb60fc56 100644 --- a/.github/actions/checkout-eyrie/action.yml +++ b/.github/actions/checkout-eyrie/action.yml @@ -3,9 +3,16 @@ description: Clone Hawk ecosystem repos into hawk/external for hawk go.work inputs: ref: - description: Git ref to checkout (branch or tag) + description: Git ref to checkout only when allow_branch_fallback is true required: false default: main + allow_branch_fallback: + description: > + When true, clone a branch head if the Gitlink is missing (dev-only escape hatch). + Default false — missing or unreachable pins fail the job so releases never + silently track main. + required: false + default: "false" runs: using: composite @@ -16,6 +23,7 @@ runs: # Passed via env (not ${{ }} interpolation into the script) so an # attacker-controlled ref (e.g. a fork branch name) cannot inject shell. INPUT_REF: ${{ inputs.ref }} + ALLOW_BRANCH_FALLBACK: ${{ inputs.allow_branch_fallback }} run: | set -euo pipefail mkdir -p "${GITHUB_WORKSPACE}/external" @@ -33,24 +41,27 @@ runs: # (e.g. by a squash-merge). A depth-1 clone can't check # out older commits and fails with "unable to read tree". git clone "https://github.com/GrayCodeAI/${repo}.git" "$dest" - if ! (cd "$dest" && git checkout --quiet "$commit" 2>/dev/null); then - echo "Submodule commit $commit not reachable, falling back to ref" - ref="$INPUT_REF" - if [ "$ref" = "main" ]; then - rm -rf "$dest" - git clone --depth=1 --branch main \ - "https://github.com/GrayCodeAI/${repo}.git" "$dest" - else - (cd "$dest" && git fetch --depth=1 "origin" "$ref" 2>/dev/null && git checkout --quiet "$ref" 2>/dev/null) || (cd "$dest" && git fetch --depth=1 origin main 2>/dev/null && git checkout --quiet main 2>/dev/null) - fi + if ! (cd "$dest" && git checkout --quiet "$commit"); then + echo "::error::Pinned submodule commit $commit is not reachable in $repo" + echo "Refusing to test an unpinned branch head for a pinned Hawk commit." + exit 1 fi else + if [ "${ALLOW_BRANCH_FALLBACK}" != "true" ]; then + echo "::error::Missing Gitlink for external/${repo} at HEAD" + echo "Hawk requires a pinned submodule commit for every engine." + echo "Record the pin with: git submodule update --init external/${repo}" + echo "and commit the Gitlink. Branch-head fallback is disabled by default" + echo "(set allow_branch_fallback=true only for local experiments)." + exit 1 + fi ref="$INPUT_REF" - # Fall back to main if the branch doesn't exist on the dependency repo. + # Optional escape hatch: fall back to main if the branch doesn't exist. if ! git ls-remote --heads "https://github.com/GrayCodeAI/${repo}.git" "$ref" | grep -q .; then echo "Branch '$ref' not found on $repo, falling back to main" ref="main" fi + echo "::warning::Cloning $repo at branch head '$ref' (no Gitlink; allow_branch_fallback=true)" git clone --depth=1 --branch "$ref" \ "https://github.com/GrayCodeAI/${repo}.git" "$dest" fi diff --git a/.github/actions/setup-deps/action.yml b/.github/actions/setup-deps/action.yml index 533e50b5..19e88f62 100644 --- a/.github/actions/setup-deps/action.yml +++ b/.github/actions/setup-deps/action.yml @@ -42,4 +42,4 @@ runs: - name: Create workspace shell: bash run: | - printf 'go 1.26.4\n\nuse .\n\nreplace (\n\tgithub.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts\n\tgithub.com/GrayCodeAI/eyrie => ./external/eyrie\n\tgithub.com/GrayCodeAI/inspect => ./external/inspect\n\tgithub.com/GrayCodeAI/sight => ./external/sight\n\tgithub.com/GrayCodeAI/tok => ./external/tok\n\tgithub.com/GrayCodeAI/trace => ./external/trace\n\tgithub.com/GrayCodeAI/yaad => ./external/yaad\n)\n' > go.work + printf 'go 1.26.5\n\nuse .\n\nreplace (\n\tgithub.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts\n\tgithub.com/GrayCodeAI/eyrie => ./external/eyrie\n\tgithub.com/GrayCodeAI/inspect => ./external/inspect\n\tgithub.com/GrayCodeAI/sight => ./external/sight\n\tgithub.com/GrayCodeAI/tok => ./external/tok\n\tgithub.com/GrayCodeAI/trace => ./external/trace\n\tgithub.com/GrayCodeAI/yaad => ./external/yaad\n)\n' > go.work diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13a49cf0..a75ce5bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,8 @@ jobs: run: bash ./scripts/check-shared-types-imports.sh - name: ecosystem boundary guard run: bash ./scripts/check-ecosystem-boundaries.sh + - name: internal layer boundary guard + run: bash ./scripts/check-internal-layer-imports.sh - name: eyrie client boundary guard run: bash ./scripts/check-eyrie-client-imports.sh @@ -103,6 +105,35 @@ jobs: fi done + public-modules: + name: public module graph + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Verify public dependency graph + env: + GOWORK: "off" + run: | + go mod download + go mod verify + go build -mod=readonly ./cmd/hawk + go test ./... -count=1 -timeout=300s + + submodule-release-parity: + name: submodule and module parity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - run: bash ./scripts/check-submodule-release-parity.sh + # ------------------------------------------------------------------------- # 3. Vet + static analysis — compiler-level correctness. # ------------------------------------------------------------------------- @@ -330,18 +361,16 @@ jobs: - name: Binary size check (linux/amd64 only) if: matrix.goos == 'linux' && matrix.goarch == 'amd64' run: | - size=$(go build -trimpath -o /tmp/hawk-bin ./cmd/hawk && wc -c < /tmp/hawk-bin) + # Use -ldflags="-s -w" to match release build size (Makefile LDFLAGS). + # This catches binary bloat regressions against the actual shipped artifact size. + size=$(go build -trimpath -ldflags="-s -w" -o /tmp/hawk-bin ./cmd/hawk && wc -c < /tmp/hawk-bin) size_mb=$((size / 1024 / 1024)) echo "Binary size: ${size_mb}MB" - # Threshold bumped from 100MB → 110MB. The current dev binary - # with full instrumentation is ~103MB; the release build (with - # -ldflags="-s -w") sits at ~76MB. This job builds the dev binary - # (no -ldflags), so the 100MB threshold was firing on every CI run - # as a warning. Bump to 110MB to give ourselves headroom while we - # decide whether to add more size-reduction work. BOTH this and - # Makefile size-check must move together. - if [ "$size_mb" -gt 110 ]; then - echo "::warning::Binary size ${size_mb}MB exceeds 110MB threshold" + # Threshold lowered from 110MB → 80MB. With -ldflags="-s -w" the + # release binary is ~76MB. A 80MB threshold gives 4MB headroom for + # regression detection while still being realistic. + if [ "$size_mb" -gt 80 ]; then + echo "::warning::Binary size ${size_mb}MB exceeds 80MB threshold" fi rm -f /tmp/hawk-bin diff --git a/.github/workflows/compatibility-matrix.yml b/.github/workflows/compatibility-matrix.yml index 62d01541..4c8ab939 100644 --- a/.github/workflows/compatibility-matrix.yml +++ b/.github/workflows/compatibility-matrix.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.4" + go-version: "1.26.5" cache: true - name: Structural validation (schema + version pins) run: make compat-check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d049b34..692a3210 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,12 +22,38 @@ jobs: with: fetch-depth: 0 # goreleaser needs full history for changelog + # Releases must use Gitlink pins only — never a branch head fallback. - uses: ./.github/actions/checkout-eyrie + with: + allow_branch_fallback: "false" + + - name: Verify Gitlink pins present + run: | + set -euo pipefail + missing=0 + for repo in hawk-core-contracts eyrie inspect sight tok trace yaad; do + commit=$(git ls-tree HEAD "external/${repo}" | awk '{print $3}' || true) + if [ -z "$commit" ]; then + echo "::error::Release requires Gitlink for external/${repo}" + missing=1 + else + head=$(git -C "external/${repo}" rev-parse HEAD) + if [ "$head" != "$commit" ]; then + echo "::error::external/${repo} checked out $head, Gitlink wants $commit" + missing=1 + else + echo "OK external/${repo} @ ${commit:0:12}" + fi + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.4" + go-version: "1.26.5" cache: true - name: Run GoReleaser diff --git a/.shared-templates/workflows/compatibility-test.yml.tmpl b/.shared-templates/workflows/compatibility-test.yml.tmpl index 81ce1d95..5182755c 100644 --- a/.shared-templates/workflows/compatibility-test.yml.tmpl +++ b/.shared-templates/workflows/compatibility-test.yml.tmpl @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.4" + go-version: "1.26.5" cache: true - name: Structural validation (schema + version pins) run: make compat-check diff --git a/.shared-templates/workflows/go-ci.yml.tmpl b/.shared-templates/workflows/go-ci.yml.tmpl index c4d23469..8233e3f7 100644 --- a/.shared-templates/workflows/go-ci.yml.tmpl +++ b/.shared-templates/workflows/go-ci.yml.tmpl @@ -37,7 +37,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.26.4" + GO_VERSION: "1.26.5" GOPROXY: "https://proxy.golang.org,direct" GOPRIVATE: "github.com/GrayCodeAI/*" GONOSUMDB: "github.com/GrayCodeAI/*" diff --git a/.shared-templates/workflows/go-release.yml.tmpl b/.shared-templates/workflows/go-release.yml.tmpl index 4a4be1d4..6f496915 100644 --- a/.shared-templates/workflows/go-release.yml.tmpl +++ b/.shared-templates/workflows/go-release.yml.tmpl @@ -29,7 +29,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.4" + go-version: "1.26.5" cache: true - name: Run GoReleaser diff --git a/Dockerfile b/Dockerfile index 56eb5d54..2d8fa824 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ COPY . . # this is the only correct source — `git describe` would always return empty # because `.dockerignore` excludes `.git/` from the build context. RUN rm -f go.work go.work.sum && \ - { echo "go 1.26.4"; echo; echo "use ."; echo; echo "replace ("; \ + { echo "go 1.26.5"; echo; echo "use ."; echo; echo "replace ("; \ for repo in hawk-core-contracts eyrie inspect sight tok trace yaad; do \ echo " github.com/GrayCodeAI/${repo} => ./external/${repo}"; \ done; echo ")"; } > go.work && \ diff --git a/Makefile b/Makefile index d1181cb5..897dcd9c 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ GORELEASER := $(GOBIN_DIR)/goreleaser # --------------------------------------------------------------------------- # Phony declarations (alphabetical). # --------------------------------------------------------------------------- -.PHONY: all bench boundaries build ci clean contracts-guard ecosystem-guard eyrie-client-guard peer-guard cover cover-new fmt help install lint lint-fix \ +.PHONY: all bench boundaries build ci clean contracts-guard ecosystem-guard eyrie-client-guard peer-guard internal-layers-guard submodule-release-parity cover cover-new fmt help install lint lint-fix \ release security setup smoke path sync-external test test-10x test-live test-new test-race tidy version vet # --------------------------------------------------------------------------- @@ -110,7 +110,13 @@ eyrie-client-guard: ## Fail on new direct eyrie/client imports outside Hawk tran peer-guard: ## Fail if support engines import each other instead of depending only on Hawk contracts. bash ./scripts/check-support-repo-coupling.sh -boundaries: contracts-guard ecosystem-guard eyrie-client-guard peer-guard ## Alias for all boundary guards (matches `make boundaries` in engine repos). +internal-layers-guard: ## Enforce one-way dependencies across stable Hawk internal layers. + bash ./scripts/check-internal-layer-imports.sh + +boundaries: contracts-guard ecosystem-guard eyrie-client-guard peer-guard internal-layers-guard ## Alias for all boundary guards (matches `make boundaries` in engine repos). + +submodule-release-parity: ## Verify every go.mod ecosystem version resolves to its pinned Gitlink. + bash ./scripts/check-submodule-release-parity.sh lint: ## Run golangci-lint. @command -v $(GOLANGCI) >/dev/null 2>&1 || (echo "install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1) @@ -185,7 +191,7 @@ setup: ## Set up local development environment (go.work + external repos). fi; \ done @echo "Generating go.work..." - @echo "go 1.26.4" > go.work + @echo "go 1.26.5" > go.work @echo "" >> go.work @echo "use ." >> go.work @echo "" >> go.work @@ -265,8 +271,8 @@ build-static: ## Build fully static binaries for Linux (musl-compatible) GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(NAME)-linux-amd64-static $(MAIN_PKG) GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(NAME)-linux-arm64-static $(MAIN_PKG) -size-check: build ## Report binary size and warn if over threshold (110MB, matching CI). +size-check: build ## Report binary size and warn if over threshold (80MB, matching CI). @SIZE=$$(stat -f%z bin/$(NAME) 2>/dev/null || stat -c%s bin/$(NAME) 2>/dev/null); \ MB=$$(echo "scale=1; $$SIZE / 1048576" | bc); \ echo "Binary size: $${MB} MB"; \ - if [ $$SIZE -gt 115343360 ]; then echo "::warning::Binary size $${MB} MB exceeds 110 MB threshold (CI gate)"; fi + if [ $$SIZE -gt 83886080 ]; then echo "::warning::Binary size $${MB} MB exceeds 80 MB threshold (CI gate)"; fi diff --git a/cmd/agent_grid.go b/cmd/agent_grid.go index 2977d510..17a779d8 100644 --- a/cmd/agent_grid.go +++ b/cmd/agent_grid.go @@ -6,10 +6,10 @@ import ( "sync" "time" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/ui/icons" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) var ( @@ -70,7 +70,7 @@ type AgentPane struct { // NewAgentPane creates a new agent pane for the given task. func NewAgentPane(id, task string, width, height int) *AgentPane { - vp := viewport.New(width-2, height-3) // account for border + title + vp := viewport.New(viewport.WithWidth(width-2), viewport.WithHeight(height-3)) // account for border + title return &AgentPane{ ID: id, Task: task, @@ -222,8 +222,8 @@ func (g *AgentGrid) Render() string { pane := g.panes[idx] pane.width = paneWidth pane.height = paneHeight - pane.viewport.Width = paneWidth - 2 - pane.viewport.Height = paneHeight - 3 + pane.viewport.SetWidth(paneWidth - 2) + pane.viewport.SetHeight(paneHeight - 3) colStrings = append(colStrings, pane.Render()) } rowStrings = append(rowStrings, lipgloss.JoinHorizontal(lipgloss.Top, colStrings...)) diff --git a/cmd/autonomy_picker.go b/cmd/autonomy_picker.go index 65601144..965686be 100644 --- a/cmd/autonomy_picker.go +++ b/cmd/autonomy_picker.go @@ -3,10 +3,10 @@ package cmd import ( "strings" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) // autonomyPickerEntry is one selectable row in the picker. @@ -47,7 +47,7 @@ func NewAutonomyPicker(width int) *AutonomyPicker { ti.Placeholder = "Type to filter…" ti.Focus() ti.CharLimit = 40 - ti.Width = 40 + ti.SetWidth(40) entries := make([]autonomyPickerEntry, 0, len(allAutonomyTiers)) for _, level := range allAutonomyTiers { @@ -108,7 +108,7 @@ func (ap *AutonomyPicker) Update(msg tea.KeyMsg) (*autonomyPickerEntry, bool) { return nil, false } - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: ap.Close() return nil, true @@ -173,7 +173,7 @@ func (ap *AutonomyPicker) Render(viewWidth int) string { b.WriteString(paletteDimStyle.Render(" (↑↓ navigate · Enter select · Esc dismiss)")) b.WriteString("\n\n") - ap.input.Width = boxWidth - 4 + ap.input.SetWidth(boxWidth - 4) b.WriteString(paletteInputStyle.Width(boxWidth - 2).Render(ap.input.View())) b.WriteString("\n\n") diff --git a/cmd/autonomy_picker_test.go b/cmd/autonomy_picker_test.go index d1935165..08e78040 100644 --- a/cmd/autonomy_picker_test.go +++ b/cmd/autonomy_picker_test.go @@ -3,8 +3,8 @@ package cmd import ( "testing" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" - tea "github.com/charmbracelet/bubbletea" ) func TestAutonomyPicker_HasAllFiveTiers(t *testing.T) { @@ -39,7 +39,7 @@ func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) { ap := NewAutonomyPicker(80) ap.Open(engine.AutonomySupervised) - chosen, handled := ap.Update(tea.KeyMsg{Type: tea.KeyDown}) + chosen, handled := ap.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if !handled || chosen != nil { t.Fatalf("KeyDown should navigate, not select: chosen=%v handled=%v", chosen, handled) } @@ -47,7 +47,7 @@ func TestAutonomyPicker_EnterSelectsAndCloses(t *testing.T) { t.Fatalf("expected selection to move to AutonomyBasic, got %v", ap.Selected().Level) } - chosen, handled = ap.Update(tea.KeyMsg{Type: tea.KeyEnter}) + chosen, handled = ap.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if !handled { t.Fatal("expected Enter to be handled") } @@ -63,7 +63,7 @@ func TestAutonomyPicker_EscClosesWithoutSelecting(t *testing.T) { ap := NewAutonomyPicker(80) ap.Open(engine.AutonomyBasic) - chosen, handled := ap.Update(tea.KeyMsg{Type: tea.KeyEsc}) + chosen, handled := ap.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if !handled || chosen != nil { t.Fatalf("expected Esc to close without a selection, got chosen=%v", chosen) } @@ -77,7 +77,7 @@ func TestAutonomyPicker_FilterByName(t *testing.T) { ap.Open(engine.AutonomyBasic) for _, r := range "scout" { - ap.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + ap.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) } if len(ap.filtered) != 1 || ap.filtered[0].Level != engine.AutonomyBasic { t.Fatalf("expected filter 'scout' to match only Basic tier, got %+v", ap.filtered) diff --git a/cmd/autonomy_tiers.go b/cmd/autonomy_tiers.go index a1fa3ad2..1ff1ea88 100644 --- a/cmd/autonomy_tiers.go +++ b/cmd/autonomy_tiers.go @@ -2,10 +2,11 @@ package cmd import ( "fmt" + "image/color" "strings" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/charmbracelet/lipgloss" ) // Four container autonomy tiers (Scout → Builder → Operator → Autonomous). @@ -86,7 +87,7 @@ func autonomyTierDescription(level engine.AutonomyLevel) string { } } -func autonomyTierColor(level engine.AutonomyLevel) lipgloss.Color { +func autonomyTierColor(level engine.AutonomyLevel) color.Color { switch level { case engine.AutonomySupervised: return lipgloss.Color("#9E9E9E") // matches textMuted's dark value diff --git a/cmd/autonomy_tiers_test.go b/cmd/autonomy_tiers_test.go index a201efe0..5a988d99 100644 --- a/cmd/autonomy_tiers_test.go +++ b/cmd/autonomy_tiers_test.go @@ -1,10 +1,10 @@ package cmd import ( + "fmt" "testing" "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/charmbracelet/lipgloss" ) func TestAutonomyTierNames(t *testing.T) { @@ -64,9 +64,9 @@ func TestAutonomyTierColorsDistinct(t *testing.T) { engine.AutonomyFull, engine.AutonomyYOLO, } - seen := make(map[lipgloss.Color]bool) + seen := make(map[string]bool) for _, l := range levels { - c := autonomyTierColor(l) + c := fmt.Sprint(autonomyTierColor(l)) if seen[c] { t.Fatalf("duplicate color for tier %v", l) } diff --git a/cmd/block_conversation.go b/cmd/block_conversation.go index c3d6feb6..82bbdba3 100644 --- a/cmd/block_conversation.go +++ b/cmd/block_conversation.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" ) // BlockSection represents a collapsible section in the conversation. diff --git a/cmd/chat.go b/cmd/chat.go index cb71b597..6397321a 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -15,12 +15,12 @@ import ( "golang.org/x/term" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/eyrie/runtime" "github.com/GrayCodeAI/eyrie/storage" @@ -111,12 +111,22 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco taWidth = w } ta.SetWidth(taWidth - 4) - ta.FocusedStyle.CursorLine = lipgloss.NewStyle() - ta.FocusedStyle.Base = lipgloss.NewStyle().Foreground(textPrimary) - ta.FocusedStyle.Placeholder = lipgloss.NewStyle().Foreground(textPlaceholder) - ta.FocusedStyle.Prompt = lipgloss.NewStyle().Foreground(hawkColor).Bold(true) - ta.BlurredStyle = ta.FocusedStyle - ta.Cursor.Style = lipgloss.NewStyle().Foreground(hawkColor) + ta.SetStyles(textarea.Styles{ + Focused: textarea.StyleState{ + CursorLine: lipgloss.NewStyle(), + Base: lipgloss.NewStyle().Foreground(textPrimary), + Placeholder: lipgloss.NewStyle().Foreground(textPlaceholder), + Prompt: lipgloss.NewStyle().Foreground(hawkColor).Bold(true), + }, + Blurred: textarea.StyleState{ + Base: lipgloss.NewStyle().Foreground(textPlaceholder), + Placeholder: lipgloss.NewStyle().Foreground(textPlaceholder), + Prompt: lipgloss.NewStyle().Foreground(hawkColor).Bold(true), + }, + Cursor: textarea.CursorStyle{ + Color: hawkColor, + }, + }) ta.Prompt = icons.ChevronRight() + " " // Enter submits; Shift+Enter inserts newline ta.KeyMap.InsertNewline.SetKeys("shift+enter") @@ -173,7 +183,7 @@ func newChatModelWithRegistry(ref *progRef, systemPrompt string, settings hawkco initHeight = h } } - vp := viewport.New(initWidth, minChatViewportLines) + vp := viewport.New(viewport.WithWidth(initWidth), viewport.WithHeight(minChatViewportLines)) now := time.Now() m := chatModel{input: ta, configInput: ci, spinner: sp, viewport: vp, session: sess, registry: registry, settings: settings, ref: ref, sessionID: sid, partial: &strings.Builder{}, spinnerVerb: spinnerVerbs[rand.Intn(len(spinnerVerbs))], width: initWidth, height: initHeight, historyIdx: 0, autoScroll: true, streamFollow: true, uiFocus: focusPrompt, startedAt: now, sessionStartedAt: now, activeSkills: make(map[string]plugin.SmartSkill)} // #nosec G404 -- non-cryptographic use (random spinner verb selection) @@ -481,11 +491,9 @@ func (m chatModel) Init() tea.Cmd { } func chatProgramOptions(mouseEnabled bool) []tea.ProgramOption { - programOpts := []tea.ProgramOption{tea.WithAltScreen(), tea.WithReportFocus()} - if mouseEnabled { - programOpts = append(programOpts, tea.WithMouseCellMotion()) - } - return programOpts + // In Bubble Tea v2, AltScreen, ReportFocus, and MouseMode are handled + // declaratively in the View() method, so no program options are needed. + return nil } // autoIndexCodegraph runs codegraph indexing in the background on startup. diff --git a/cmd/chat_arrow_burst_test.go b/cmd/chat_arrow_burst_test.go index ace0ff9f..3a036f90 100644 --- a/cmd/chat_arrow_burst_test.go +++ b/cmd/chat_arrow_burst_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // TestArrowBurstDoesNotPermanentlyFreezeInput reproduces a reported bug: after @@ -21,7 +21,7 @@ func TestArrowBurstDoesNotPermanentlyFreezeInput(t *testing.T) { // First Up: treated as an isolated press (dt since zero-value lastArrowTime // is huge), so it arms a pendingArrow + tick and returns immediately. - next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) cm := requireChatModel(t, next) if cmd == nil { t.Fatal("expected a scheduled tick command after the first arrow press") @@ -31,7 +31,7 @@ func TestArrowBurstDoesNotPermanentlyFreezeInput(t *testing.T) { // pending arrow and, critically, must arm its own trailing tick so the // flag can be cleared once the burst goes quiet. cm.lastArrowTime = time.Now().Add(-1 * time.Millisecond) - next, cmd = cm.Update(tea.KeyMsg{Type: tea.KeyUp}) + next, cmd = cm.Update(tea.KeyPressMsg{Code: tea.KeyUp}) cm = requireChatModel(t, next) if !cm.arrowBurstActive { t.Fatal("expected arrowBurstActive to be true immediately after a burst keypress") @@ -50,7 +50,7 @@ func TestArrowBurstDoesNotPermanentlyFreezeInput(t *testing.T) { // Regression check: a normal character keystroke must reach the input, // not be silently swallowed. - next, _ = cm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + next, _ = cm.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}) cm = requireChatModel(t, next) if cm.input.Value() != "x" { t.Fatalf("expected keystroke to reach the input after burst settled, got %q", cm.input.Value()) @@ -65,13 +65,13 @@ func TestArrowBurstActiveOnlySwallowsArrowKeys(t *testing.T) { m.uiFocus = focusPrompt m.arrowBurstActive = true - if !m.applyPromptArrowKey(tea.KeyMsg{Type: tea.KeyUp}) { + if !m.applyPromptArrowKey(tea.KeyPressMsg{Code: tea.KeyUp}) { t.Fatal("expected Up to be consumed while a burst is active") } - if m.applyPromptArrowKey(tea.KeyMsg{Type: tea.KeyEsc}) { + if m.applyPromptArrowKey(tea.KeyPressMsg{Code: tea.KeyEsc}) { t.Fatal("Escape must not be swallowed just because an arrow burst is active") } - if m.applyPromptArrowKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) { + if m.applyPromptArrowKey(tea.KeyPressMsg{Code: 'a', Text: "a"}) { t.Fatal("typed characters must not be swallowed just because an arrow burst is active") } } diff --git a/cmd/chat_commands.go b/cmd/chat_commands.go index 72c3c78b..055deb19 100644 --- a/cmd/chat_commands.go +++ b/cmd/chat_commands.go @@ -8,7 +8,7 @@ import ( "strings" "sync" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/multiagent/parallel" @@ -79,7 +79,7 @@ var allSlashCommands = []string{ "/power", "/pr-comments", "/provider-status", "/quit", "/recipe", "/recover", "/reflect", "/refresh-model-catalog", "/release-notes", "/image", "/reload-plugins", "/remote-env", "/rename", "/render", "/research", "/resume", "/retry", "/review", "/rewind", "/run", "/btw", "/brainstorm", "/checkpoint", "/dream", "/away", "/investigate", "/search", "/security-review", "/session", "/share", "/skills", "/snapshot", "/soul", "/spec", "/stale", "/stats", - "/mouse", "/select", "/status", "/statusline", "/summary", "/tag", "/taste", "/tasks", "/test", "/theme", "/think", "/think-back", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/ultrareview", "/undo", "/upgrade", "/usage", + "/mouse", "/select", "/status", "/statusline", "/summary", "/tag", "/taste", "/tasks", "/test", "/theme", "/think", "/thinkback", "/thinkback-play", "/tokens", "/tools", "/ultrareview", "/undo", "/upgrade", "/usage", "/version", "/vibe", "/vim", "/voice", "/welcome", "/ecosystem", "/path", "/yaad", } diff --git a/cmd/chat_commands_config.go b/cmd/chat_commands_config.go index dba9f0fa..6d625184 100644 --- a/cmd/chat_commands_config.go +++ b/cmd/chat_commands_config.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) diff --git a/cmd/chat_commands_image.go b/cmd/chat_commands_image.go index 3434305f..3cd5041d 100644 --- a/cmd/chat_commands_image.go +++ b/cmd/chat_commands_image.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index c426a205..016c6ff6 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -9,7 +9,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/storage" diff --git a/cmd/chat_commands_skills.go b/cmd/chat_commands_skills.go index d4f4a495..a5a1c733 100644 --- a/cmd/chat_commands_skills.go +++ b/cmd/chat_commands_skills.go @@ -8,7 +8,7 @@ import ( "strconv" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/plugin" "github.com/GrayCodeAI/hawk/internal/tool" diff --git a/cmd/chat_commands_tools.go b/cmd/chat_commands_tools.go index ead8143c..e53bb2cd 100644 --- a/cmd/chat_commands_tools.go +++ b/cmd/chat_commands_tools.go @@ -6,7 +6,7 @@ import ( "os/exec" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/feature/shellmode" "github.com/GrayCodeAI/hawk/internal/plugin" diff --git a/cmd/chat_config_deployment.go b/cmd/chat_config_deployment.go index c5ba23d2..2265aed4 100644 --- a/cmd/chat_config_deployment.go +++ b/cmd/chat_config_deployment.go @@ -5,9 +5,9 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/runtime" @@ -159,9 +159,19 @@ func (m chatModel) startConfigURLInput(defaultURL string) (chatModel, tea.Cmd) { m.configInput.Prompt = " url " + icons.ChevronRight() + " " m.configInput.Placeholder = defaultURL m.configInput.EchoMode = textinput.EchoNormal - m.configInput.PromptStyle = lipgloss.NewStyle().Foreground(hawkColor).Bold(true) - m.configInput.TextStyle = lipgloss.NewStyle().Foreground(textPrimary) - m.configInput.Cursor.Style = lipgloss.NewStyle().Foreground(hawkColor) + m.configInput.SetStyles(textinput.Styles{ + Focused: textinput.StyleState{ + Prompt: lipgloss.NewStyle().Foreground(hawkColor).Bold(true), + Text: lipgloss.NewStyle().Foreground(textPrimary), + }, + Blurred: textinput.StyleState{ + Prompt: lipgloss.NewStyle().Foreground(hawkColor).Bold(true), + Text: lipgloss.NewStyle().Foreground(textPrimary), + }, + Cursor: textinput.CursorStyle{ + Color: hawkColor, + }, + }) m.configInput.Focus() return m, textinput.Blink } diff --git a/cmd/chat_config_gateways.go b/cmd/chat_config_gateways.go index cd1f52aa..76c77320 100644 --- a/cmd/chat_config_gateways.go +++ b/cmd/chat_config_gateways.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/ui/icons" diff --git a/cmd/chat_config_gateways_test.go b/cmd/chat_config_gateways_test.go index 6158a838..cba395f7 100644 --- a/cmd/chat_config_gateways_test.go +++ b/cmd/chat_config_gateways_test.go @@ -4,17 +4,17 @@ import ( "strings" "testing" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" ) func chatModelForConfigPasteTest() chatModel { ti := textinput.New() - ti.Width = 40 + ti.SetWidth(40) ti, _ = ti.Update(tea.WindowSizeMsg{Width: 40, Height: 1}) return chatModel{configInput: ti, input: textarea.New()} } diff --git a/cmd/chat_config_hub.go b/cmd/chat_config_hub.go index 75ee74b5..d27905c8 100644 --- a/cmd/chat_config_hub.go +++ b/cmd/chat_config_hub.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/eyrie/catalog/registry" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" diff --git a/cmd/chat_config_keys.go b/cmd/chat_config_keys.go index f781a577..8197a216 100644 --- a/cmd/chat_config_keys.go +++ b/cmd/chat_config_keys.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" @@ -152,7 +152,7 @@ func (m chatModel) confirmConfigGatewayKeyRemove() (chatModel, tea.Cmd) { func (m chatModel) handleConfigKeyViewKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { trimmedProvider := strings.TrimSpace(m.configProvider) - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: m.configEntry = configEntryNone m.configProvider = "" @@ -174,12 +174,10 @@ func (m chatModel) handleConfigKeyViewKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { return m, nil } return m.startConfigKeyReplace(trimmedProvider) - case tea.KeyRunes: - if trimmedProvider == hawkconfig.ProviderXiaomiTokenPlan && strings.EqualFold(string(msg.Runes), "g") { + default: + if trimmedProvider == hawkconfig.ProviderXiaomiTokenPlan && strings.EqualFold(key.Text, "g") { return m.startConfigXiaomiTokenPlanRegion(), nil } - default: return m, nil } - return m, nil } diff --git a/cmd/chat_config_keys_test.go b/cmd/chat_config_keys_test.go index db6cc55d..9e9e6442 100644 --- a/cmd/chat_config_keys_test.go +++ b/cmd/chat_config_keys_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" - tea "github.com/charmbracelet/bubbletea" ) func TestConfigGatewaysView_KeyHintsWithCredentials(t *testing.T) { @@ -47,7 +47,7 @@ func TestConfigGatewaysKeyView_OpenWithK(t *testing.T) { } } m := chatModel{configTab: configTabGateways, configSel: sel} - next, _ := m.handleConfigKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + next, _ := m.handleConfigKey(tea.KeyPressMsg{Code: 'k', Text: "k"}) if next.configEntry != configEntryKeyView { t.Fatalf("expected key view, got entry=%q", next.configEntry) } diff --git a/cmd/chat_config_models.go b/cmd/chat_config_models.go index 138200e6..6f06cb2c 100644 --- a/cmd/chat_config_models.go +++ b/cmd/chat_config_models.go @@ -5,7 +5,7 @@ import ( "strings" "sync" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/eyrie/runtime" diff --git a/cmd/chat_config_panel.go b/cmd/chat_config_panel.go index d0dcb10d..ccd79c1b 100644 --- a/cmd/chat_config_panel.go +++ b/cmd/chat_config_panel.go @@ -5,9 +5,9 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/eyrie/catalog/xiaomi" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" @@ -181,9 +181,19 @@ func (m chatModel) startConfigModelSearch() (chatModel, tea.Cmd) { m.configInput.Placeholder = "filter by name, owner, id" m.configInput.EchoMode = textinput.EchoNormal m.configInput.EchoCharacter = 0 - m.configInput.PromptStyle = lipgloss.NewStyle().Foreground(hawkColor).Bold(true) - m.configInput.TextStyle = lipgloss.NewStyle().Foreground(textPrimary) - m.configInput.Cursor.Style = lipgloss.NewStyle().Foreground(hawkColor) + m.configInput.SetStyles(textinput.Styles{ + Focused: textinput.StyleState{ + Prompt: lipgloss.NewStyle().Foreground(hawkColor).Bold(true), + Text: lipgloss.NewStyle().Foreground(textPrimary), + }, + Blurred: textinput.StyleState{ + Prompt: lipgloss.NewStyle().Foreground(hawkColor).Bold(true), + Text: lipgloss.NewStyle().Foreground(textPrimary), + }, + Cursor: textinput.CursorStyle{ + Color: hawkColor, + }, + }) m.configInput.Focus() m.configSel = 0 m.configScroll = 0 @@ -208,7 +218,7 @@ func (m chatModel) stopConfigModelSearch(clearQuery bool) chatModel { } func (m chatModel) handleConfigModelSearchKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: return m.stopConfigModelSearch(true), nil case tea.KeyEnter: @@ -438,9 +448,19 @@ func (m chatModel) startConfigEntry(kind, provider string) (chatModel, tea.Cmd) m.configInput.Placeholder = "paste API key" m.configInput.EchoMode = textinput.EchoPassword m.configInput.EchoCharacter = '*' - m.configInput.PromptStyle = lipgloss.NewStyle().Foreground(hawkColor).Bold(true) - m.configInput.TextStyle = lipgloss.NewStyle().Foreground(textPrimary) - m.configInput.Cursor.Style = lipgloss.NewStyle().Foreground(hawkColor) + m.configInput.SetStyles(textinput.Styles{ + Focused: textinput.StyleState{ + Prompt: lipgloss.NewStyle().Foreground(hawkColor).Bold(true), + Text: lipgloss.NewStyle().Foreground(textPrimary), + }, + Blurred: textinput.StyleState{ + Prompt: lipgloss.NewStyle().Foreground(hawkColor).Bold(true), + Text: lipgloss.NewStyle().Foreground(textPrimary), + }, + Cursor: textinput.CursorStyle{ + Color: hawkColor, + }, + }) m.configInput.Focus() return m, textinput.Blink } @@ -526,7 +546,7 @@ func (m chatModel) finishConfigEntry() (chatModel, tea.Cmd) { } func (m chatModel) handleConfigEntryKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: switch m.configEntry { case configEntryOllamaURL: @@ -564,18 +584,6 @@ func (m chatModel) handleConfigEntryKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { } case tea.KeyEnter: return m.finishConfigEntry() - case tea.KeyCtrlV: - if pasted, err := pasteFromClipboard(); err == nil { - pasted = strings.TrimSpace(pasted) - if pasted != "" { - m.configInput.SetValue(pasted) - } else { - m.configNotice = "Clipboard is empty" - } - } else { - m.configNotice = "Clipboard paste failed — use terminal paste (Cmd+V)" - } - return m, nil default: var cmd tea.Cmd m.configInput, cmd = m.configInput.Update(msg) @@ -620,7 +628,7 @@ func (m chatModel) configMoveSelection(delta int) chatModel { } func (m chatModel) handleConfigMouse(msg tea.MouseMsg) (chatModel, bool) { - if !tea.MouseEvent(msg).IsWheel() || m.configSaving { + if m.configSaving { return m, false } switch m.configEntry { @@ -632,10 +640,10 @@ func (m chatModel) handleConfigMouse(msg tea.MouseMsg) (chatModel, bool) { if m.configEntry == configEntryKeyView { step = 1 } - switch msg.Button { - case tea.MouseButtonWheelUp: + switch msg.Mouse().Button { + case tea.MouseWheelUp: return m.configMoveSelection(-step), true - case tea.MouseButtonWheelDown: + case tea.MouseWheelDown: return m.configMoveSelection(step), true default: return m, false @@ -643,7 +651,7 @@ func (m chatModel) handleConfigMouse(msg tea.MouseMsg) (chatModel, bool) { } func (m chatModel) handleConfigMouseLeak(msg tea.KeyMsg) (chatModel, bool) { - matches := mouseSGRReportRE.FindAllStringSubmatch(string(msg.Runes), -1) + matches := mouseSGRReportRE.FindAllStringSubmatch(msg.Key().Text, -1) if len(matches) == 0 { return m, false } @@ -663,7 +671,8 @@ func (m chatModel) handleConfigMouseLeak(msg tea.KeyMsg) (chatModel, bool) { } func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { - if m.configSaving && msg.Type == tea.KeyEsc { + key := msg.Key() + if m.configSaving && key.Code == tea.KeyEsc { if m.configTab == configTabGateways { return m.handleConfigGatewaysEsc(), nil } @@ -699,11 +708,11 @@ func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { if m.configTab == configTabModels && m.configModelSearchActive { return m.handleConfigModelSearchKey(msg) } - if m.configTab == configTabModels && msg.Type == tea.KeyRunes && string(msg.Runes) == "/" { + if m.configTab == configTabModels && key.Text == "/" { return m.startConfigModelSearch() } - if m.configTab == configTabGateways && msg.Type == tea.KeyRunes { - switch string(msg.Runes) { + if m.configTab == configTabGateways { + switch key.Text { case "r", "R": return m.refreshConfigGateway() case "k", "K": @@ -723,7 +732,7 @@ func (m chatModel) handleConfigKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { m.configSel = 0 } - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: if m.configTab == configTabModels && strings.TrimSpace(m.configModelSearch) != "" { return m.stopConfigModelSearch(true), nil diff --git a/cmd/chat_config_remove.go b/cmd/chat_config_remove.go index 5d5ad469..589b7e58 100644 --- a/cmd/chat_config_remove.go +++ b/cmd/chat_config_remove.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) diff --git a/cmd/chat_config_save_flow_test.go b/cmd/chat_config_save_flow_test.go index d91b3fce..cdcead2e 100644 --- a/cmd/chat_config_save_flow_test.go +++ b/cmd/chat_config_save_flow_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" - tea "github.com/charmbracelet/bubbletea" ) func TestFinishConfigEntry_APIKeyPaste_SavesBeforeProbe(t *testing.T) { @@ -156,7 +156,7 @@ func TestHandleConfigKey_EnterOnPasteSubmits(t *testing.T) { m.configTab = configTabGateways next, _ := m.startConfigKeyForProvider("openrouter") next.configInput.SetValue("sk-or-test-key-12345678901234567890") - next, cmd := next.handleConfigKey(tea.KeyMsg{Type: tea.KeyEnter}) + next, cmd := next.handleConfigKey(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd == nil { t.Fatal("expected save cmd on enter") } diff --git a/cmd/chat_config_tabs.go b/cmd/chat_config_tabs.go index 0638e58a..69b7b75e 100644 --- a/cmd/chat_config_tabs.go +++ b/cmd/chat_config_tabs.go @@ -4,8 +4,8 @@ import ( "context" "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/ui/icons" diff --git a/cmd/chat_config_ui.go b/cmd/chat_config_ui.go index e18352e3..675863a5 100644 --- a/cmd/chat_config_ui.go +++ b/cmd/chat_config_ui.go @@ -3,8 +3,8 @@ package cmd import ( "strings" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/ui/icons" - "github.com/charmbracelet/lipgloss" ) func configMutedStyle() lipgloss.Style { diff --git a/cmd/chat_config_xiaomi.go b/cmd/chat_config_xiaomi.go index 1ac738f6..baa680ba 100644 --- a/cmd/chat_config_xiaomi.go +++ b/cmd/chat_config_xiaomi.go @@ -4,7 +4,7 @@ import ( "context" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -70,7 +70,7 @@ func (m chatModel) configXiaomiRegionView() string { } func (m chatModel) handleConfigXiaomiRegionKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: m.configEntry = configEntryNone m.configProvider = "" diff --git a/cmd/chat_config_zai.go b/cmd/chat_config_zai.go index 9a1160d0..ecc31a47 100644 --- a/cmd/chat_config_zai.go +++ b/cmd/chat_config_zai.go @@ -4,7 +4,7 @@ import ( "context" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -72,7 +72,7 @@ func (m chatModel) configZAIRegionView() string { } func (m chatModel) handleConfigZAIRegionKey(msg tea.KeyMsg) (chatModel, tea.Cmd) { - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: prov := m.configProvider m.configEntry = configEntryNone diff --git a/cmd/chat_copy.go b/cmd/chat_copy.go index 15a11083..3a3be878 100644 --- a/cmd/chat_copy.go +++ b/cmd/chat_copy.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) type copyMode int diff --git a/cmd/chat_copy_e2e_test.go b/cmd/chat_copy_e2e_test.go index 4d5d6795..a02163cf 100644 --- a/cmd/chat_copy_e2e_test.go +++ b/cmd/chat_copy_e2e_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -17,7 +17,7 @@ func runCopySelectionE2EPass(t *testing.T, pass int) { m := newTestChatModel() m.input = textarea.New() - m.viewport = viewport.New(80, 10) + m.viewport = viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)) m.uiFocus = focusPrompt // --- Pass A: error-only turn (no assistant reply) --- @@ -84,7 +84,7 @@ func runCopySelectionE2EPass(t *testing.T, pass int) { } m = cm - if !isCopyToClipboardKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}, Alt: true}) { + if !isCopyToClipboardKey(tea.KeyPressMsg{Code: 'c', Mod: tea.ModAlt}) { t.Fatalf("pass %d: alt+c should be copy shortcut", pass) } diff --git a/cmd/chat_copy_test.go b/cmd/chat_copy_test.go index 7682b1f5..941f3f11 100644 --- a/cmd/chat_copy_test.go +++ b/cmd/chat_copy_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/textarea" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) @@ -71,10 +71,10 @@ func TestCopyContent_Modes(t *testing.T) { func TestIsCopyToClipboardKey(t *testing.T) { t.Parallel() - if !isCopyToClipboardKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}, Alt: true}) { + if !isCopyToClipboardKey(tea.KeyPressMsg{Code: 'c', Mod: tea.ModAlt}) { t.Fatal("expected alt+c") } - if isCopyToClipboardKey(tea.KeyMsg{Type: tea.KeyCtrlC}) { + if isCopyToClipboardKey(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) { t.Fatal("ctrl+c should not trigger clipboard copy") } } diff --git a/cmd/chat_focus.go b/cmd/chat_focus.go index 19701141..ab436967 100644 --- a/cmd/chat_focus.go +++ b/cmd/chat_focus.go @@ -5,9 +5,9 @@ import ( "path/filepath" "strings" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/storage" @@ -71,15 +71,15 @@ func (m chatModel) scrollPositionLabel() string { if m.contentLines <= 0 { return "" } - visH := m.viewport.Height + visH := m.viewport.Height() if visH <= 0 { visH = 1 } if m.contentLines <= visH { return fmt.Sprintf("1-%d/%d", m.contentLines, m.contentLines) } - top := m.viewport.YOffset + 1 - bottom := m.viewport.YOffset + visH + top := m.viewport.YOffset() + 1 + bottom := m.viewport.YOffset() + visH if bottom > m.contentLines { bottom = m.contentLines } diff --git a/cmd/chat_focus_test.go b/cmd/chat_focus_test.go index c7684770..42935224 100644 --- a/cmd/chat_focus_test.go +++ b/cmd/chat_focus_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" ) func TestFormatToolResultDisplay_TruncatesLarge(t *testing.T) { @@ -21,7 +21,7 @@ func TestFormatToolResultDisplay_TruncatesLarge(t *testing.T) { } func TestScrollPositionLabel(t *testing.T) { - vp := viewport.New(80, 10) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)) vp.SetContent(strings.Repeat("line\n", 50)) vp.SetYOffset(10) m := chatModel{viewport: vp, contentLines: 51} @@ -32,10 +32,10 @@ func TestScrollPositionLabel(t *testing.T) { } func TestRouteKeyToViewport_ScrollbackFocus(t *testing.T) { - vp := viewport.New(80, 10) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)) vp.SetContent(strings.Repeat("line\n", 30)) m := chatModel{viewport: vp, uiFocus: focusScrollback} - if m.routeKeyToViewport(tea.KeyMsg{Type: tea.KeyUp}) { + if m.routeKeyToViewport(tea.KeyPressMsg{Code: tea.KeyUp}) { t.Fatal("up should NOT scroll in scrollback focus") } } @@ -52,9 +52,9 @@ func TestUpdate_TypingInScrollbackReturnsToPrompt(t *testing.T) { m := chatModel{ uiFocus: focusScrollback, input: textarea.New(), - viewport: viewport.New(80, 10), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)), } - nextModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + nextModel, _ := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) next := nextModel.(chatModel) if next.uiFocus != focusPrompt { t.Fatalf("uiFocus = %v, want prompt", next.uiFocus) @@ -68,7 +68,7 @@ func TestUpdate_BlurMsg_BlursPromptInput(t *testing.T) { m := chatModel{ uiFocus: focusPrompt, input: textarea.New(), - viewport: viewport.New(80, 10), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)), } m.input.Focus() @@ -83,7 +83,7 @@ func TestUpdate_FocusMsg_RefocusesPromptInput(t *testing.T) { m := chatModel{ uiFocus: focusPrompt, input: textarea.New(), - viewport: viewport.New(80, 10), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)), } m.input.Blur() @@ -101,7 +101,7 @@ func TestUpdate_PromptKeepAlive_RefocusesBlurredPrompt(t *testing.T) { m := chatModel{ uiFocus: focusPrompt, input: textarea.New(), - viewport: viewport.New(80, 10), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)), } m.input.Blur() @@ -122,10 +122,10 @@ func TestChatProgramOptions_IncludeFocusReporting(t *testing.T) { if withMouse == nil || withoutMouse == nil { t.Fatal("expected program construction to succeed") } - if len(chatProgramOptions(true)) != 3 { - t.Fatalf("mouse-enabled startup should include alt-screen, focus reporting, and mouse motion; got %d options", len(chatProgramOptions(true))) + if len(chatProgramOptions(true)) != 0 { + t.Fatalf("Bubble Tea v2 should configure mouse and focus reporting through View, got %d legacy options", len(chatProgramOptions(true))) } - if len(chatProgramOptions(false)) != 2 { - t.Fatalf("mouse-disabled startup should include alt-screen and focus reporting; got %d options", len(chatProgramOptions(false))) + if len(chatProgramOptions(false)) != 0 { + t.Fatalf("Bubble Tea v2 should configure terminal modes through View, got %d legacy options", len(chatProgramOptions(false))) } } diff --git a/cmd/chat_layout.go b/cmd/chat_layout.go index 4b38f300..3abd2e69 100644 --- a/cmd/chat_layout.go +++ b/cmd/chat_layout.go @@ -25,16 +25,16 @@ func (m chatModel) withSyncedLayout() chatModel { if vpH < minChatViewportLines { vpH = minChatViewportLines } - if m.viewport.Height != vpH { - m.viewport.Height = vpH + if m.viewport.Height() != vpH { + m.viewport.SetHeight(vpH) } w := m.width if w <= 0 { w = 80 } vpW := m.chatViewportWidth(w) - if m.viewport.Width != vpW { - m.viewport.Width = vpW + if m.viewport.Width() != vpW { + m.viewport.SetWidth(vpW) } return m } diff --git a/cmd/chat_layout_mouse_test.go b/cmd/chat_layout_mouse_test.go index 332c732e..bf0fb4c9 100644 --- a/cmd/chat_layout_mouse_test.go +++ b/cmd/chat_layout_mouse_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" ) func TestView_LineCountMatchesHeight(t *testing.T) { @@ -16,11 +16,11 @@ func TestView_LineCountMatchesHeight(t *testing.T) { width: 80, welcomeCache: "HAWK LOGO\nv0.1.0", input: textarea.New(), - viewport: viewport.New(80, 8), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(8)), ghostText: NewGhostText(), } m = m.withSyncedLayout() - got := m.View() + got := m.View().Content lines := strings.Split(strings.TrimRight(got, "\n"), "\n") if len(lines) > m.height { t.Fatalf("view lines = %d, must not exceed height %d", len(lines), m.height) @@ -48,7 +48,7 @@ func TestView_LineCountMatchesHeight(t *testing.T) { } func TestMouseWheelDelta_SGRUsesZeroBasedY(t *testing.T) { - vp := viewport.New(80, 14) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)) vp.SetContent(strings.Repeat("line\n", 40)) m := chatModel{ viewport: vp, @@ -58,24 +58,24 @@ func TestMouseWheelDelta_SGRUsesZeroBasedY(t *testing.T) { uiFocus: focusPrompt, } m = m.withSyncedLayout() - before := m.viewport.YOffset + before := m.viewport.YOffset() footerRow1Based := m.footerTopY() + 1 chatRow1Based := m.chatPaneTopY() + 2 - leakChat := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[<65;40;" + strconv.Itoa(chatRow1Based) + "M")} + leakChat := tea.KeyPressMsg{Code: '[', Text: "[<65;40;" + strconv.Itoa(chatRow1Based) + "M"} if handled, _ := m.tryScrollFromMouseLeak(leakChat); !handled { t.Fatal("expected chat wheel leak to be consumed") } - if m.viewport.YOffset == before { + if m.viewport.YOffset() == before { t.Fatal("SGR chat wheel should scroll viewport") } m.viewport.SetYOffset(before) - leakInput := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[<65;40;" + strconv.Itoa(footerRow1Based) + "M")} + leakInput := tea.KeyPressMsg{Code: '[', Text: "[<65;40;" + strconv.Itoa(footerRow1Based) + "M"} if handled, _ := m.tryScrollFromMouseLeak(leakInput); !handled { t.Fatal("expected footer wheel leak to be consumed") } - if m.viewport.YOffset != before { + if m.viewport.YOffset() != before { t.Fatal("SGR footer wheel must not scroll chat") } } diff --git a/cmd/chat_layout_test.go b/cmd/chat_layout_test.go index f75652c0..114c1401 100644 --- a/cmd/chat_layout_test.go +++ b/cmd/chat_layout_test.go @@ -4,8 +4,8 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" ) func TestView_PinsWelcomeAboveViewport(t *testing.T) { @@ -14,13 +14,13 @@ func TestView_PinsWelcomeAboveViewport(t *testing.T) { width: 80, welcomeCache: "HAWK LOGO\nv0.1.0", input: textarea.New(), - viewport: viewport.New(80, 8), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(8)), ghostText: NewGhostText(), } m = m.withSyncedLayout() m.viewDirty = true m.updateViewportContent() - got := m.View() + got := m.View().Content if !strings.Contains(got, "HAWK LOGO") { t.Fatalf("welcome should be pinned at top, got prefix: %q", got[:min(40, len(got))]) } @@ -35,7 +35,7 @@ func TestPrimeInitialViewportContent_RendersWelcomeBeforeFirstFrame(t *testing.T width: 80, welcomeCache: "HAWK LOGO\nv0.1.0", input: textarea.New(), - viewport: viewport.New(80, 8), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(8)), ghostText: NewGhostText(), } m = m.withSyncedLayout() diff --git a/cmd/chat_model.go b/cmd/chat_model.go index 1da40dcf..235aa319 100644 --- a/cmd/chat_model.go +++ b/cmd/chat_model.go @@ -6,12 +6,12 @@ import ( "sync" "time" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/bridge/sessioncapture" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" diff --git a/cmd/chat_model_test.go b/cmd/chat_model_test.go index 24d46893..f04c8f72 100644 --- a/cmd/chat_model_test.go +++ b/cmd/chat_model_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/hawk/internal/bridge/sessioncapture" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" @@ -15,9 +18,6 @@ import ( "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/storage" "github.com/GrayCodeAI/hawk/internal/tool" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - "github.com/charmbracelet/lipgloss" ) func newTestChatModel() *chatModel { @@ -27,7 +27,7 @@ func newTestChatModel() *chatModel { m := &chatModel{ input: textarea.New(), - viewport: viewport.New(120, 12), + viewport: viewport.New(viewport.WithWidth(120), viewport.WithHeight(12)), session: sess, registry: tool.NewRegistry(), partial: &strings.Builder{}, @@ -83,7 +83,7 @@ func restoreThemeGlobals(t *testing.T) { savedCost, savedBranch, savedToken, savedCwd := costViolet, branchYellow, tokenSage, cwdBlue savedPrimary, savedMuted, savedPlaceholder, savedDisabled := textPrimary, textMuted, textPlaceholder, textDisabled savedBorderDim, savedBgCode := borderDim, bgCode - savedDarkBG := lipgloss.HasDarkBackground() + hasDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) t.Cleanup(func() { hawkColor, successTeal, warnAmber, errorCoral, infoSky = savedHawk, savedSuccess, savedWarn, savedErr, savedInfo toolGold, agentGold, doneGreen, containerBlue = savedTool, savedAgent, savedDone, savedContainer @@ -92,7 +92,8 @@ func restoreThemeGlobals(t *testing.T) { costViolet, branchYellow, tokenSage, cwdBlue = savedCost, savedBranch, savedToken, savedCwd textPrimary, textMuted, textPlaceholder, textDisabled = savedPrimary, savedMuted, savedPlaceholder, savedDisabled borderDim, bgCode = savedBorderDim, savedBgCode - lipgloss.SetHasDarkBackground(savedDarkBG) + // HasDarkBackground is now a function (no args), SetHasDarkBackground removed in v2 + _ = hasDark }) } diff --git a/cmd/chat_mouse_scroll_test.go b/cmd/chat_mouse_scroll_test.go index 8aebec88..15cd7af6 100644 --- a/cmd/chat_mouse_scroll_test.go +++ b/cmd/chat_mouse_scroll_test.go @@ -4,15 +4,15 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" ) func runMouseScrollSplitPanePass(t *testing.T, pass int) { t.Helper() - vp := viewport.New(80, 14) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)) vp.SetContent(strings.Repeat("line\n", 40)) vp.SetYOffset(5) @@ -26,37 +26,35 @@ func runMouseScrollSplitPanePass(t *testing.T, pass int) { uiFocus: focusPrompt, } m = m.syncViewportMouseWheel().withSyncedLayout() - before := m.viewport.YOffset + before := m.viewport.YOffset() - wheelChat := tea.MouseMsg{ + wheelChat := tea.MouseWheelMsg{ X: 40, Y: m.chatPaneTopY(), - Button: tea.MouseButtonWheelDown, - Action: tea.MouseActionPress, + Button: tea.MouseWheelDown, } next, _ := m.Update(wheelChat) m = next.(chatModel) - if m.viewport.YOffset <= before { - t.Fatalf("pass %d: wheel over chat should scroll viewport (before=%d after=%d)", pass, before, m.viewport.YOffset) + if m.viewport.YOffset() <= before { + t.Fatalf("pass %d: wheel over chat should scroll viewport (before=%d after=%d)", pass, before, m.viewport.YOffset()) } m.viewport.SetYOffset(before) - wheelInput := tea.MouseMsg{ + wheelInput := tea.MouseWheelMsg{ X: 40, Y: m.bottomBarTopY(), - Button: tea.MouseButtonWheelDown, - Action: tea.MouseActionPress, + Button: tea.MouseWheelDown, } next, _ = m.Update(wheelInput) m = next.(chatModel) - if m.viewport.YOffset != before { - t.Fatalf("pass %d: wheel over input must not scroll chat (before=%d after=%d)", pass, before, m.viewport.YOffset) + if m.viewport.YOffset() != before { + t.Fatalf("pass %d: wheel over input must not scroll chat (before=%d after=%d)", pass, before, m.viewport.YOffset()) } if !m.input.Focused() { t.Fatalf("pass %d: input must stay focused after mouse wheel so typing still works", pass) } - up := tea.KeyMsg{Type: tea.KeyUp} + up := tea.KeyPressMsg{Code: tea.KeyUp} m.history = []string{"first", "second"} m.historyIdx = len(m.history) m.input.SetValue("") @@ -71,13 +69,13 @@ func runMouseScrollSplitPanePass(t *testing.T, pass int) { if m.input.Value() != "second" { t.Fatalf("pass %d: up should navigate input history, got %q", pass, m.input.Value()) } - if m.viewport.YOffset != before { + if m.viewport.YOffset() != before { t.Fatalf("pass %d: up in prompt focus must not scroll chat", pass) } } func TestUpdate_MouseMotionDoesNotReflowLayout(t *testing.T) { - vp := viewport.New(80, 14) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)) vp.SetContent(strings.Repeat("line\n", 40)) m := chatModel{ viewport: vp, @@ -88,12 +86,12 @@ func TestUpdate_MouseMotionDoesNotReflowLayout(t *testing.T) { cachedBottomBarLines: 10, layoutKey: 65536, } - before := m.viewport.Height + before := m.viewport.Height() - motion := tea.MouseMsg{Y: 8, X: 10, Action: tea.MouseActionMotion} + motion := tea.MouseMotionMsg{Y: 8, X: 10} next, _ := m.Update(motion) m = next.(chatModel) - if m.viewport.Height != before { + if m.viewport.Height() != before { t.Fatal("mouse motion should not trigger layout reflow") } if m.lastMouseY != 8 { @@ -108,7 +106,7 @@ func TestUpdate_MouseWheelSplitPane(t *testing.T) { func TestUpdate_InputHistoryWhileWaiting(t *testing.T) { m := chatModel{ - viewport: viewport.New(80, 14), + viewport: viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)), input: textarea.New(), height: 24, width: 80, @@ -119,7 +117,7 @@ func TestUpdate_InputHistoryWhileWaiting(t *testing.T) { m.historyIdx = len(m.history) m = m.withSyncedLayout() - up := tea.KeyMsg{Type: tea.KeyUp} + up := tea.KeyPressMsg{Code: tea.KeyUp} next, cmd := m.Update(up) if cmd != nil { next, _ = next.Update(cmd()) @@ -129,7 +127,7 @@ func TestUpdate_InputHistoryWhileWaiting(t *testing.T) { t.Fatalf("up while waiting should navigate history, got %q", m.input.Value()) } - down := tea.KeyMsg{Type: tea.KeyDown} + down := tea.KeyPressMsg{Code: tea.KeyDown} next, cmd = m.Update(down) if cmd != nil { next, _ = next.Update(cmd()) diff --git a/cmd/chat_multiturn_e2e_test.go b/cmd/chat_multiturn_e2e_test.go index 1ffe6408..d5ddccdc 100644 --- a/cmd/chat_multiturn_e2e_test.go +++ b/cmd/chat_multiturn_e2e_test.go @@ -5,10 +5,10 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" - tea "github.com/charmbracelet/bubbletea" ) func configureReadyChatState(t *testing.T) { @@ -64,7 +64,7 @@ func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { m.session.SetProvider("") m.input.SetValue("first question") - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + result, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = requireChatModel(t, result) if !m.waiting { t.Fatal("first enter should start a waiting chat turn") @@ -80,7 +80,7 @@ func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { } m.input.SetValue("second question") - result, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + result, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = requireChatModel(t, result) if len(m.messageQueue) != 1 || m.messageQueue[0] != "second question" { t.Fatalf("queued messages = %v, want [second question]", m.messageQueue) @@ -128,7 +128,7 @@ func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { t.Fatalf("queued second turn should reset per-turn tokens, got in=%d out=%d", m.turnInputTokens, m.turnOutputTokens) } - result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyUp}) + result, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) if cmd != nil { result, _ = result.(chatModel).Update(cmd()) } @@ -136,7 +136,7 @@ func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { if got := m.input.Value(); got != "second question" { t.Fatalf("first history recall = %q, want second question", got) } - result, cmd = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + result, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) if cmd != nil { result, _ = result.(chatModel).Update(cmd()) } @@ -144,7 +144,7 @@ func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { if got := m.input.Value(); got != "first question" { t.Fatalf("second history recall = %q, want first question", got) } - result, cmd = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + result, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if cmd != nil { result, _ = result.(chatModel).Update(cmd()) } @@ -152,7 +152,7 @@ func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { if got := m.input.Value(); got != "second question" { t.Fatalf("history down = %q, want second question", got) } - result, cmd = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + result, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if cmd != nil { result, _ = result.(chatModel).Update(cmd()) } @@ -163,7 +163,7 @@ func TestChatModel_MultiTurnQueuedConversationE2E(t *testing.T) { m.viewDirty = true m.updateViewportContent() - rendered := m.View() + rendered := m.View().Content if !strings.Contains(rendered, "first question") || !strings.Contains(rendered, "Tests passed after the fix.") { t.Fatalf("rendered chat missing final transcript:\n%s", rendered) } diff --git a/cmd/chat_platform_ctx.go b/cmd/chat_platform_ctx.go index cc23f8a4..5b0e7cf8 100644 --- a/cmd/chat_platform_ctx.go +++ b/cmd/chat_platform_ctx.go @@ -6,8 +6,8 @@ import ( "sync" "time" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/eyrie/catalog/xiaomi" - tea "github.com/charmbracelet/bubbletea" ) var platformCtxCache struct { diff --git a/cmd/chat_print.go b/cmd/chat_print.go index 2d8e05d9..bcea18f8 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -11,13 +11,13 @@ import ( "strings" "time" + lipgloss "charm.land/lipgloss/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" aiwatch "github.com/GrayCodeAI/hawk/internal/engine/io" "github.com/GrayCodeAI/hawk/internal/engine/lifecycle" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/session" - "github.com/charmbracelet/lipgloss" ) // Print mode and session persistence functions extracted from chat.go diff --git a/cmd/chat_scrollbar.go b/cmd/chat_scrollbar.go index e6e0c283..9d76f341 100644 --- a/cmd/chat_scrollbar.go +++ b/cmd/chat_scrollbar.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" ) // scrollbarWidth is the number of terminal columns reserved for the scrollbar column. @@ -22,7 +22,7 @@ var scrollbarThumbStyle = lipgloss.NewStyle().Foreground(hawkColor) // chatHasOverflow reports whether chat content exceeds the viewport height. func (m chatModel) chatHasOverflow() bool { - h := m.viewport.Height + h := m.viewport.Height() if h <= 0 { return false } @@ -64,7 +64,7 @@ func (m chatModel) renderScrollbar() string { return "" } - vpH := m.viewport.Height + vpH := m.viewport.Height() totalLines := m.contentLines if vpH <= 0 || totalLines <= 0 { return "" @@ -89,7 +89,7 @@ func (m chatModel) renderScrollbar() string { if maxOffset <= 0 { maxOffset = 1 } - yOffset := m.viewport.YOffset + yOffset := m.viewport.YOffset() if yOffset < 0 { yOffset = 0 } @@ -137,7 +137,7 @@ func padToHeight(s string, height int) string { // side-by-side layout, returning the full-width chat area string. func (m chatModel) renderChatPane() string { chatView := m.viewport.View() - vpH := m.viewport.Height + vpH := m.viewport.Height() if !m.chatScrollbarVisible() { return padToHeight(chatView, vpH) } @@ -147,7 +147,7 @@ func (m chatModel) renderChatPane() string { return padToHeight(chatView, vpH) } - targetW := m.viewport.Width + targetW := m.viewport.Width() if targetW <= 0 { targetW = 80 } diff --git a/cmd/chat_scrollbar_test.go b/cmd/chat_scrollbar_test.go index 6b21eaee..7cf96409 100644 --- a/cmd/chat_scrollbar_test.go +++ b/cmd/chat_scrollbar_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/viewport" + "charm.land/bubbles/v2/viewport" ) func TestChatScrollbarVisible_WhenContentOverflows(t *testing.T) { @@ -55,7 +55,8 @@ func TestRenderScrollbar_TopAndBottom(t *testing.T) { viewport: viewportWithSize(80, 10), contentLines: 100, } - m.viewport.YOffset = 0 + m.viewport.SetContent(strings.Repeat("line\n", 100)) + m.viewport.SetYOffset(0) sb := m.renderScrollbar() lines := strings.Split(sb, "\n") if len(lines) != 10 { @@ -65,7 +66,7 @@ func TestRenderScrollbar_TopAndBottom(t *testing.T) { t.Fatalf("expected thumb at top row when YOffset=0, got %q", lines[0]) } - m.viewport.YOffset = 90 // max offset + m.viewport.SetYOffset(90) // max offset sb = m.renderScrollbar() lines = strings.Split(sb, "\n") if !strings.Contains(lines[9], scrollbarThumbGlyph) { @@ -113,5 +114,5 @@ func TestRenderChatPane_PaddedWidth(t *testing.T) { } func viewportWithSize(width, height int) viewport.Model { - return viewport.New(width, height) + return viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)) } diff --git a/cmd/chat_select.go b/cmd/chat_select.go index 143d8ad9..c115ecb1 100644 --- a/cmd/chat_select.go +++ b/cmd/chat_select.go @@ -6,7 +6,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "golang.org/x/term" ) diff --git a/cmd/chat_status.go b/cmd/chat_status.go index 166b6426..81c8dc10 100644 --- a/cmd/chat_status.go +++ b/cmd/chat_status.go @@ -3,9 +3,10 @@ package cmd import ( "context" "fmt" + "image/color" "strings" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" @@ -334,7 +335,7 @@ func formatConnectionContextLabel(m chatModel, windowLabel string) string { // context-window usage percentage. Exposed so the footer test in // chat_status_test.go can assert the threshold logic without // depending on the full format function. -func contextPercentColor(pct int) lipgloss.Color { +func contextPercentColor(pct int) color.Color { switch { case pct >= 95: return errorCoral diff --git a/cmd/chat_status_test.go b/cmd/chat_status_test.go index 2bc83585..377ced70 100644 --- a/cmd/chat_status_test.go +++ b/cmd/chat_status_test.go @@ -3,13 +3,14 @@ package cmd import ( "context" "errors" + "image/color" "strings" "testing" "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) func TestRenderChatConnectionStatus_ColorParts(t *testing.T) { @@ -40,7 +41,7 @@ func TestParseContextWindowLabel(t *testing.T) { func TestFormatConnectionContextLabel(t *testing.T) { m := chatModel{session: &engine.Session{}} - got := formatConnectionContextLabel(m, "131k") + got := ansi.Strip(formatConnectionContextLabel(m, "131k")) // The label is per-part colored with ANSI escapes. In a TTY test the // escapes are present; in a non-TTY test lipgloss strips them. We // just assert the plain-text segments are present either way. @@ -53,7 +54,7 @@ func TestFormatConnectionContextLabel(t *testing.T) { sess := &engine.Session{} sess.AddUser(strings.Repeat("a", 4000)) m.session = sess - got = formatConnectionContextLabel(m, "131k") + got = ansi.Strip(formatConnectionContextLabel(m, "131k")) if !strings.Contains(got, "/131k ctx") || !strings.Contains(got, "%") { t.Fatalf("expected used/total ctx label, got %q", got) } @@ -64,7 +65,7 @@ func TestContextPercentColor(t *testing.T) { // "0k/262k ctx (0%)" traffic-light coloring. Assert each threshold. cases := []struct { pct int - want lipgloss.Color + want color.Color }{ {0, doneGreen}, {50, doneGreen}, diff --git a/cmd/chat_stream.go b/cmd/chat_stream.go index 5b9253b3..555ee9c4 100644 --- a/cmd/chat_stream.go +++ b/cmd/chat_stream.go @@ -6,7 +6,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" ) diff --git a/cmd/chat_subcommand.go b/cmd/chat_subcommand.go index c90f6a47..2a436868 100644 --- a/cmd/chat_subcommand.go +++ b/cmd/chat_subcommand.go @@ -4,7 +4,7 @@ import ( "sort" "sync" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // ChatSubcommand is a single slash-command handler. Implementations diff --git a/cmd/chat_subcommand_add_dir.go b/cmd/chat_subcommand_add_dir.go index 06bab0e8..0b71761f 100644 --- a/cmd/chat_subcommand_add_dir.go +++ b/cmd/chat_subcommand_add_dir.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // addDirSubcommand implements the /add-dir slash command. It adds a diff --git a/cmd/chat_subcommand_agents_init.go b/cmd/chat_subcommand_agents_init.go index b9801e4e..65f3fe1c 100644 --- a/cmd/chat_subcommand_agents_init.go +++ b/cmd/chat_subcommand_agents_init.go @@ -3,7 +3,7 @@ package cmd import ( "os" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // agentsInitSubcommand implements the /agents-init slash command. diff --git a/cmd/chat_subcommand_away.go b/cmd/chat_subcommand_away.go index 8983cb6d..a37acd05 100644 --- a/cmd/chat_subcommand_away.go +++ b/cmd/chat_subcommand_away.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // awaySubcommand implements the /away slash command. It asks the diff --git a/cmd/chat_subcommand_brainstorm.go b/cmd/chat_subcommand_brainstorm.go index 26569875..c9062d4c 100644 --- a/cmd/chat_subcommand_brainstorm.go +++ b/cmd/chat_subcommand_brainstorm.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" ) diff --git a/cmd/chat_subcommand_branch.go b/cmd/chat_subcommand_branch.go index ecfd8e47..b7d844d4 100644 --- a/cmd/chat_subcommand_branch.go +++ b/cmd/chat_subcommand_branch.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // branchSubcommand implements the /branch slash command. It shows diff --git a/cmd/chat_subcommand_branches.go b/cmd/chat_subcommand_branches.go index fae36dbc..8d48cb03 100644 --- a/cmd/chat_subcommand_branches.go +++ b/cmd/chat_subcommand_branches.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // branchesSubcommand implements the /branches slash command. It diff --git a/cmd/chat_subcommand_bughunter.go b/cmd/chat_subcommand_bughunter.go index 8a776647..2de0a657 100644 --- a/cmd/chat_subcommand_bughunter.go +++ b/cmd/chat_subcommand_bughunter.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // bughunterSubcommand implements the /bughunter slash command. It diff --git a/cmd/chat_subcommand_check.go b/cmd/chat_subcommand_check.go index 5578833b..c2b02947 100644 --- a/cmd/chat_subcommand_check.go +++ b/cmd/chat_subcommand_check.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // checkSubcommand implements the /check slash command. It runs the diff --git a/cmd/chat_subcommand_checkpoint.go b/cmd/chat_subcommand_checkpoint.go index 4f401537..bf5ae740 100644 --- a/cmd/chat_subcommand_checkpoint.go +++ b/cmd/chat_subcommand_checkpoint.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" ) diff --git a/cmd/chat_subcommand_commit.go b/cmd/chat_subcommand_commit.go index c95da37f..c590c097 100644 --- a/cmd/chat_subcommand_commit.go +++ b/cmd/chat_subcommand_commit.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // commitSubcommand implements the /commit slash command. It shows diff --git a/cmd/chat_subcommand_config.go b/cmd/chat_subcommand_config.go index f5e44d6d..8abd0af5 100644 --- a/cmd/chat_subcommand_config.go +++ b/cmd/chat_subcommand_config.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // configSubcommand implements the /config (and /con, /conf) slash diff --git a/cmd/chat_subcommand_context.go b/cmd/chat_subcommand_context.go index 2183d71f..76b04e86 100644 --- a/cmd/chat_subcommand_context.go +++ b/cmd/chat_subcommand_context.go @@ -4,7 +4,7 @@ import ( "os" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine/project" diff --git a/cmd/chat_subcommand_cost.go b/cmd/chat_subcommand_cost.go index 4d5b53fb..e64387e0 100644 --- a/cmd/chat_subcommand_cost.go +++ b/cmd/chat_subcommand_cost.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // costSubcommand implements the /cost slash command. It prints the diff --git a/cmd/chat_subcommand_council.go b/cmd/chat_subcommand_council.go index 034bc6f2..f544cf76 100644 --- a/cmd/chat_subcommand_council.go +++ b/cmd/chat_subcommand_council.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/ui/icons" diff --git a/cmd/chat_subcommand_design.go b/cmd/chat_subcommand_design.go index 2689a231..36e9d998 100644 --- a/cmd/chat_subcommand_design.go +++ b/cmd/chat_subcommand_design.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // designSubcommand implements the /design slash command. It diff --git a/cmd/chat_subcommand_doctor.go b/cmd/chat_subcommand_doctor.go index 6e405fe2..ae0c5031 100644 --- a/cmd/chat_subcommand_doctor.go +++ b/cmd/chat_subcommand_doctor.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // doctorSubcommand implements the /doctor slash command. It runs diff --git a/cmd/chat_subcommand_dream.go b/cmd/chat_subcommand_dream.go index 19c24958..42310c37 100644 --- a/cmd/chat_subcommand_dream.go +++ b/cmd/chat_subcommand_dream.go @@ -4,7 +4,7 @@ import ( "os" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" ) diff --git a/cmd/chat_subcommand_ecosystem.go b/cmd/chat_subcommand_ecosystem.go index 2ae49aba..ec4288b9 100644 --- a/cmd/chat_subcommand_ecosystem.go +++ b/cmd/chat_subcommand_ecosystem.go @@ -3,7 +3,7 @@ package cmd import ( "context" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) diff --git a/cmd/chat_subcommand_env.go b/cmd/chat_subcommand_env.go index 068f0063..d8395e13 100644 --- a/cmd/chat_subcommand_env.go +++ b/cmd/chat_subcommand_env.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // envSubcommand implements the /env slash command. It prints the diff --git a/cmd/chat_subcommand_files.go b/cmd/chat_subcommand_files.go index 1afb7ee5..76f0871d 100644 --- a/cmd/chat_subcommand_files.go +++ b/cmd/chat_subcommand_files.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // filesSubcommand implements the /files slash command. It prints a diff --git a/cmd/chat_subcommand_focus.go b/cmd/chat_subcommand_focus.go index ce80e418..fc3a3b6d 100644 --- a/cmd/chat_subcommand_focus.go +++ b/cmd/chat_subcommand_focus.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // focusSubcommand implements the /focus slash command. It sets a diff --git a/cmd/chat_subcommand_help.go b/cmd/chat_subcommand_help.go index 6e470ff7..c8fd1037 100644 --- a/cmd/chat_subcommand_help.go +++ b/cmd/chat_subcommand_help.go @@ -4,7 +4,7 @@ import ( "sort" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // helpSubcommand implements the /help and /commands slash commands. diff --git a/cmd/chat_subcommand_hunt.go b/cmd/chat_subcommand_hunt.go index 77708aef..42e9b439 100644 --- a/cmd/chat_subcommand_hunt.go +++ b/cmd/chat_subcommand_hunt.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // huntSubcommand implements the /hunt slash command. It asks the diff --git a/cmd/chat_subcommand_init.go b/cmd/chat_subcommand_init.go index 69908689..e5acb15d 100644 --- a/cmd/chat_subcommand_init.go +++ b/cmd/chat_subcommand_init.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // initSubcommand implements the /init slash command. It prompts the diff --git a/cmd/chat_subcommand_investigate.go b/cmd/chat_subcommand_investigate.go index 73c0bd08..afd05bda 100644 --- a/cmd/chat_subcommand_investigate.go +++ b/cmd/chat_subcommand_investigate.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" ) diff --git a/cmd/chat_subcommand_lint.go b/cmd/chat_subcommand_lint.go index 0dcbbfab..b917605d 100644 --- a/cmd/chat_subcommand_lint.go +++ b/cmd/chat_subcommand_lint.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // lintSubcommand implements the /lint slash command. It runs diff --git a/cmd/chat_subcommand_mcp.go b/cmd/chat_subcommand_mcp.go index 851b06d9..175fe1a3 100644 --- a/cmd/chat_subcommand_mcp.go +++ b/cmd/chat_subcommand_mcp.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // mcpSubcommand implements the /mcp slash command. It prints the diff --git a/cmd/chat_subcommand_memory.go b/cmd/chat_subcommand_memory.go index 4d9fffc7..dfc8a69e 100644 --- a/cmd/chat_subcommand_memory.go +++ b/cmd/chat_subcommand_memory.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) diff --git a/cmd/chat_subcommand_metrics.go b/cmd/chat_subcommand_metrics.go index c3ae2ed7..427db353 100644 --- a/cmd/chat_subcommand_metrics.go +++ b/cmd/chat_subcommand_metrics.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // metricsSubcommand implements the /metrics slash command. It prints diff --git a/cmd/chat_subcommand_mode.go b/cmd/chat_subcommand_mode.go index e0735910..4d2f05d2 100644 --- a/cmd/chat_subcommand_mode.go +++ b/cmd/chat_subcommand_mode.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/feature/shellmode" ) diff --git a/cmd/chat_subcommand_model.go b/cmd/chat_subcommand_model.go index ba771253..b703e127 100644 --- a/cmd/chat_subcommand_model.go +++ b/cmd/chat_subcommand_model.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) diff --git a/cmd/chat_subcommand_party.go b/cmd/chat_subcommand_party.go index 7f8268e4..898d0cd7 100644 --- a/cmd/chat_subcommand_party.go +++ b/cmd/chat_subcommand_party.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" ) diff --git a/cmd/chat_subcommand_path.go b/cmd/chat_subcommand_path.go index 04e9fbd7..1fd773f5 100644 --- a/cmd/chat_subcommand_path.go +++ b/cmd/chat_subcommand_path.go @@ -3,7 +3,7 @@ package cmd import ( "context" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) diff --git a/cmd/chat_subcommand_pin.go b/cmd/chat_subcommand_pin.go index f8bb6e3e..310d1364 100644 --- a/cmd/chat_subcommand_pin.go +++ b/cmd/chat_subcommand_pin.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // pinSubcommand implements the /pin slash command. It sets the diff --git a/cmd/chat_subcommand_pr_comments.go b/cmd/chat_subcommand_pr_comments.go index 883d1424..eccebdde 100644 --- a/cmd/chat_subcommand_pr_comments.go +++ b/cmd/chat_subcommand_pr_comments.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // prCommentsSubcommand implements the /pr-comments slash command. diff --git a/cmd/chat_subcommand_recipe.go b/cmd/chat_subcommand_recipe.go index 1471d600..7bed6af8 100644 --- a/cmd/chat_subcommand_recipe.go +++ b/cmd/chat_subcommand_recipe.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/recipe" ) diff --git a/cmd/chat_subcommand_refactor.go b/cmd/chat_subcommand_refactor.go index c0ef50d7..20b47350 100644 --- a/cmd/chat_subcommand_refactor.go +++ b/cmd/chat_subcommand_refactor.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // refactorSubcommand implements the /refactor slash command. It diff --git a/cmd/chat_subcommand_reflect.go b/cmd/chat_subcommand_reflect.go index 7d2b2871..9b69ab5f 100644 --- a/cmd/chat_subcommand_reflect.go +++ b/cmd/chat_subcommand_reflect.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" ) diff --git a/cmd/chat_subcommand_release_notes.go b/cmd/chat_subcommand_release_notes.go index 37aa462f..5c9f8f43 100644 --- a/cmd/chat_subcommand_release_notes.go +++ b/cmd/chat_subcommand_release_notes.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // releaseNotesSubcommand implements the /release-notes slash diff --git a/cmd/chat_subcommand_render.go b/cmd/chat_subcommand_render.go index 23125b0e..663237f6 100644 --- a/cmd/chat_subcommand_render.go +++ b/cmd/chat_subcommand_render.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) diff --git a/cmd/chat_subcommand_review.go b/cmd/chat_subcommand_review.go index 910d1c4c..84fc9cde 100644 --- a/cmd/chat_subcommand_review.go +++ b/cmd/chat_subcommand_review.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" ) diff --git a/cmd/chat_subcommand_run.go b/cmd/chat_subcommand_run.go index ba340ae3..5d03e07d 100644 --- a/cmd/chat_subcommand_run.go +++ b/cmd/chat_subcommand_run.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // runSubcommand implements the /run slash command. It runs an diff --git a/cmd/chat_subcommand_security_review.go b/cmd/chat_subcommand_security_review.go index 752d0f69..6b166c08 100644 --- a/cmd/chat_subcommand_security_review.go +++ b/cmd/chat_subcommand_security_review.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // securityReviewSubcommand implements the /security-review slash diff --git a/cmd/chat_subcommand_session.go b/cmd/chat_subcommand_session.go index 3f973862..adfd18f7 100644 --- a/cmd/chat_subcommand_session.go +++ b/cmd/chat_subcommand_session.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // sessionSubcommand is a single ChatSubcommand that dispatches to diff --git a/cmd/chat_subcommand_simple.go b/cmd/chat_subcommand_simple.go index fb465a6d..1bbaca1a 100644 --- a/cmd/chat_subcommand_simple.go +++ b/cmd/chat_subcommand_simple.go @@ -9,7 +9,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" analytics "github.com/GrayCodeAI/hawk/internal/observability" diff --git a/cmd/chat_subcommand_snapshot.go b/cmd/chat_subcommand_snapshot.go index 554dee81..76c133d0 100644 --- a/cmd/chat_subcommand_snapshot.go +++ b/cmd/chat_subcommand_snapshot.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // snapshotSubcommand implements the /snapshot slash command. diff --git a/cmd/chat_subcommand_soul.go b/cmd/chat_subcommand_soul.go index d3452ce5..0dd3f3bc 100644 --- a/cmd/chat_subcommand_soul.go +++ b/cmd/chat_subcommand_soul.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" ) diff --git a/cmd/chat_subcommand_spec.go b/cmd/chat_subcommand_spec.go index 247895f1..1804b0ce 100644 --- a/cmd/chat_subcommand_spec.go +++ b/cmd/chat_subcommand_spec.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/spec" diff --git a/cmd/chat_subcommand_status.go b/cmd/chat_subcommand_status.go index 44d89eef..d5e9ca90 100644 --- a/cmd/chat_subcommand_status.go +++ b/cmd/chat_subcommand_status.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // statusSubcommand implements the /status slash command. It prints diff --git a/cmd/chat_subcommand_summary.go b/cmd/chat_subcommand_summary.go index a7d1d50e..1ee22f89 100644 --- a/cmd/chat_subcommand_summary.go +++ b/cmd/chat_subcommand_summary.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // summarySubcommand implements the /summary slash command. It diff --git a/cmd/chat_subcommand_test.go b/cmd/chat_subcommand_test.go index a7906248..6155a1c5 100644 --- a/cmd/chat_subcommand_test.go +++ b/cmd/chat_subcommand_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // mockSubcommand is a test fixture for ChatSubcommand. It records diff --git a/cmd/chat_subcommand_test_cmd.go b/cmd/chat_subcommand_test_cmd.go index 0663fa81..ab338828 100644 --- a/cmd/chat_subcommand_test_cmd.go +++ b/cmd/chat_subcommand_test_cmd.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // testSubcommand implements the /test slash command. It runs diff --git a/cmd/chat_subcommand_think.go b/cmd/chat_subcommand_think.go index 3e34bb1a..e46cf562 100644 --- a/cmd/chat_subcommand_think.go +++ b/cmd/chat_subcommand_think.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // thinkSubcommand implements the /think slash command. It prompts diff --git a/cmd/chat_subcommand_tools.go b/cmd/chat_subcommand_tools.go index e6b652fa..7f5f7084 100644 --- a/cmd/chat_subcommand_tools.go +++ b/cmd/chat_subcommand_tools.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // toolsSubcommand implements the /tools slash command. It prints diff --git a/cmd/chat_subcommand_usage.go b/cmd/chat_subcommand_usage.go index a50b93e8..46318db4 100644 --- a/cmd/chat_subcommand_usage.go +++ b/cmd/chat_subcommand_usage.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // usageSubcommand implements the /usage slash command. It prints diff --git a/cmd/chat_subcommand_version.go b/cmd/chat_subcommand_version.go index 3db7408b..98ff1352 100644 --- a/cmd/chat_subcommand_version.go +++ b/cmd/chat_subcommand_version.go @@ -3,7 +3,7 @@ package cmd import ( "fmt" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // versionSubcommand implements the /version slash command. It prints diff --git a/cmd/chat_subcommand_voice.go b/cmd/chat_subcommand_voice.go index 9d7ae038..f4bd4c4d 100644 --- a/cmd/chat_subcommand_voice.go +++ b/cmd/chat_subcommand_voice.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // voiceSubcommand implements the /voice slash command. It diff --git a/cmd/chat_subcommand_welcome.go b/cmd/chat_subcommand_welcome.go index 2e490889..59d84ea3 100644 --- a/cmd/chat_subcommand_welcome.go +++ b/cmd/chat_subcommand_welcome.go @@ -1,7 +1,7 @@ package cmd import ( - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // welcomeSubcommand implements the /welcome slash command. It diff --git a/cmd/chat_subcommand_yaad.go b/cmd/chat_subcommand_yaad.go index ff490c1b..8199a299 100644 --- a/cmd/chat_subcommand_yaad.go +++ b/cmd/chat_subcommand_yaad.go @@ -3,7 +3,7 @@ package cmd import ( "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" ) diff --git a/cmd/chat_submit.go b/cmd/chat_submit.go index 32169ba5..5893d576 100644 --- a/cmd/chat_submit.go +++ b/cmd/chat_submit.go @@ -9,7 +9,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" diff --git a/cmd/chat_terminal_mouse.go b/cmd/chat_terminal_mouse.go index 81a044c2..fd68e3e6 100644 --- a/cmd/chat_terminal_mouse.go +++ b/cmd/chat_terminal_mouse.go @@ -3,7 +3,7 @@ package cmd import ( "os" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // ANSI sequences to turn off xterm SGR/cell mouse tracking. Cursor's integrated diff --git a/cmd/chat_update.go b/cmd/chat_update.go index 204bd162..e468b606 100644 --- a/cmd/chat_update.go +++ b/cmd/chat_update.go @@ -3,13 +3,13 @@ package cmd import ( "context" "fmt" - "math/rand" + "math/rand/v2" "strings" "time" "unicode/utf8" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" @@ -31,7 +31,7 @@ func (m *chatModel) applyPromptArrowKey(msg tea.KeyMsg) bool { // the burst-coalescing logic in Update(). Any other key (typing, // Escape, etc.) must still reach the input; arrowBurstActive is a // short-lived timing flag, not a general input lock. - switch msg.Type { + switch msg.Key().Code { case tea.KeyUp, tea.KeyDown: return true } @@ -40,14 +40,14 @@ func (m *chatModel) applyPromptArrowKey(msg tea.KeyMsg) bool { if m.uiFocus != focusPrompt || m.configOpen { return false } - switch msg.Type { + switch msg.Key().Code { case tea.KeyUp, tea.KeyDown: default: return false } sugs := m.slashSuggestionsFor(m.input.Value()) if len(sugs) > 0 { - switch msg.Type { + switch msg.Key().Code { case tea.KeyUp: if m.slashSel <= 0 { m.slashSel = len(sugs) - 1 @@ -59,7 +59,7 @@ func (m *chatModel) applyPromptArrowKey(msg tea.KeyMsg) bool { } return true } - switch msg.Type { + switch msg.Key().Code { case tea.KeyUp: if len(m.history) > 0 { if m.historyIdx == len(m.history) { @@ -88,7 +88,7 @@ func (m *chatModel) applyPromptArrowKey(msg tea.KeyMsg) bool { } func shouldReturnToPromptOnType(msg tea.KeyMsg) bool { - if msg.Type != tea.KeyRunes || len(msg.Runes) == 0 { + if len(msg.Key().Text) == 0 { return false } if isMouseSequenceLeak(msg) { @@ -141,7 +141,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return next, nil } } - if tea.MouseEvent(msg).IsWheel() { + if msg.Mouse().Button == tea.MouseWheelUp || msg.Mouse().Button == tea.MouseWheelDown { m.trackMousePosition(msg) cmds = append(cmds, m.applyMouseScroll(msg)) m.sanitizeInputIfNeeded() @@ -241,7 +241,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // state (welcome gate, permissions, prompt, scrollback) so users always // have a way to copy text out of the chat — the alt-screen + // mouse-tracking combination otherwise breaks native text selection. - if msg.Type == tea.KeyCtrlBackslash { + if msg.String() == "ctrl+k" { return m, enterSelectionMode(m.ref, m.copyableTranscript(), m.mouseEnabled()) } if isCopyToClipboardKey(msg) { @@ -360,14 +360,14 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if isCompactCancelKey(msg) { return m.cancelManualCompact("Compaction cancelled.") } - if msg.Type == tea.KeyEnter { + if msg.String() == "enter" { return m, nil } // Allow typing in the input while compaction runs (Esc cancels). } if m.inScrollbackFocus() { - switch msg.Type { + switch msg.Key().Code { case tea.KeyTab: return m.cycleUIFocus() case tea.KeyEsc: @@ -426,7 +426,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // AskUser prompt active — Enter submits answer if m.askReq != nil { - if msg.Type == tea.KeyEnter { + if msg.String() == "enter" { answer := strings.TrimSpace(m.input.Value()) m.input.Reset() m.messages = append(m.messages, displayMsg{role: "user", content: answer}) @@ -439,7 +439,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.updateInput(msg) } if m.waiting { - if msg.Type == tea.KeyCtrlC { + if msg.String() == "ctrl+c" { // First Ctrl+C cancels stream, second quits if m.cancel != nil { m.cancel() @@ -463,7 +463,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit } - if msg.Type == tea.KeyEsc { + if msg.String() == "escape" { if m.cancel != nil { m.cancel() m.cancel = nil @@ -481,7 +481,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } // Queue message on Enter while agent is working - if msg.Type == tea.KeyEnter { + if msg.String() == "enter" { text := strings.TrimSpace(m.input.Value()) if text != "" { m.history = append(m.history, text) @@ -501,8 +501,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.updateInput(msg) } if m.configOpen { - switch msg.Type { - case tea.KeyCtrlC: + switch msg.String() { + case "ctrl+c": if time.Since(m.lastCtrlC) < 1*time.Second { m.saveSession() if m.watcherStop != nil { @@ -523,81 +523,92 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return next, cmd } } - switch msg.Type { - case tea.KeyCtrlA: - // Toggle the Agent Status HUD overlay. - m.hudOpen = !m.hudOpen - if m.hudOpen { - m.hudData = m.collectHUDData() - } - m.viewDirty = true - m.updateViewportContent() - return m, nil - case tea.KeyCtrlK: - // Open command palette - if m.commandPalette == nil { - m.commandPalette = NewCommandPalette(m.width) - } - m.commandPalette.Open() - m.viewDirty = true - m.updateViewportContent() - return m, nil - case tea.KeyCtrlN: - models := configModelChoices(m.configModelOptions, false) - if len(models) > 1 { - current := m.session.Model() - idx := 0 - for i, md := range models { - if md == current { - idx = (i + 1) % len(models) + + // Handle modifier key combos (ctrl+a, ctrl+k, etc.) via string matching. + switch msg.Key().Mod { + case 0: // no modifier + default: + // modifier combos: check the keystroke string + k := msg.Key() + if k.Text != "" { + switch k.Text { + case "ctrl+a": + m.hudOpen = !m.hudOpen + if m.hudOpen { + m.hudData = m.collectHUDData() } + m.viewDirty = true + m.updateViewportContent() + return m, nil + case "ctrl+k": + if m.commandPalette == nil { + m.commandPalette = NewCommandPalette(m.width) + } + m.commandPalette.Open() + m.viewDirty = true + m.updateViewportContent() + return m, nil + case "ctrl+n": + models := configModelChoices(m.configModelOptions, false) + if len(models) > 1 { + current := m.session.Model() + idx := 0 + for i, md := range models { + if md == current { + idx = (i + 1) % len(models) + } + } + m.session.SetModel(models[idx]) + m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Model → %s", models[idx])}) + } + m.viewDirty = true + m.updateViewportContent() + return m, nil + case "ctrl+l": + if m.containerEnabled && !m.containerReady { + m.messages = append(m.messages, displayMsg{role: "system", content: "Waiting for sandbox — tiers unlock when container is ready."}) + m.viewDirty = true + m.updateViewportContent() + return m, nil + } + nextTier := nextAutonomyTier(m.session.PermSvc().Autonomy()) + if m.session.PermSvc().Autonomy() == 0 || autonomyTierIndex(m.session.PermSvc().Autonomy()) < 0 { + nextTier = DefaultContainerAutonomy + } + m.session.PermSvc().SetAutonomy(nextTier) + m.invalidateConnStatus() + m.messages = append(m.messages, displayMsg{role: "system", content: formatAutonomyTierMessage(nextTier)}) + m.viewDirty = true + m.updateViewportContent() + return m, nil + case "ctrl+c": + if time.Since(m.lastCtrlC) < 1*time.Second { + m.saveSession() + if m.watcherStop != nil { + m.watcherStop() + } + m.quitting = true + return m, tea.Quit + } + m.lastCtrlC = time.Now() + m.messages = append(m.messages, displayMsg{role: "system", content: quitAgainMsg}) + m.viewDirty = true + m.updateViewportContent() + return m, nil + case "shift+tab": + if m.specPicker == nil { + m.specPicker = NewSpecPicker(m.width) + } + m.specPicker.Open(currentSpecStage(m.session)) + m.viewDirty = true + m.updateViewportContent() + return m, nil } - m.session.SetModel(models[idx]) - m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Model → %s", models[idx])}) - } - m.viewDirty = true - m.updateViewportContent() - return m, nil - case tea.KeyCtrlL: - if m.containerEnabled && !m.containerReady { - m.messages = append(m.messages, displayMsg{role: "system", content: "Waiting for sandbox — tiers unlock when container is ready."}) - m.viewDirty = true - m.updateViewportContent() - return m, nil - } - next := nextAutonomyTier(m.session.PermSvc().Autonomy()) - if m.session.PermSvc().Autonomy() == 0 || autonomyTierIndex(m.session.PermSvc().Autonomy()) < 0 { - next = DefaultContainerAutonomy - } - m.session.PermSvc().SetAutonomy(next) - m.invalidateConnStatus() - m.messages = append(m.messages, displayMsg{role: "system", content: formatAutonomyTierMessage(next)}) - m.viewDirty = true - m.updateViewportContent() - return m, nil - case tea.KeyCtrlC: - if time.Since(m.lastCtrlC) < 1*time.Second { - m.saveSession() - if m.watcherStop != nil { - m.watcherStop() - } - m.quitting = true - return m, tea.Quit } - m.lastCtrlC = time.Now() - m.messages = append(m.messages, displayMsg{role: "system", content: quitAgainMsg}) - m.viewDirty = true - m.updateViewportContent() - return m, nil - case tea.KeyShiftTab: - // Shift+Tab opens the spec picker overlay - if m.specPicker == nil { - m.specPicker = NewSpecPicker(m.width) - } - m.specPicker.Open(currentSpecStage(m.session)) - m.viewDirty = true - m.updateViewportContent() - return m, nil + } + + // Main key dispatch (special keys via code, runes via text) + switch msg.Key().Code { case tea.KeyTab: // Accept ghost text suggestion if active and input is empty if m.ghostText.Active() && strings.TrimSpace(m.input.Value()) == "" { @@ -953,7 +964,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.waiting = true m.autoScroll = true m.viewDirty = true - m.spinnerVerb = spinnerVerbs[rand.Intn(len(spinnerVerbs))] // #nosec G404 -- non-cryptographic use (random spinner verb selection) + m.spinnerVerb = spinnerVerbs[rand.IntN(len(spinnerVerbs))] // #nosec G404 -- non-cryptographic use (random spinner verb selection) m.brailleSpinner.SetLabel(m.spinnerVerb) m.turnSawThinking = false m.turnHadAssistantOutput = false @@ -984,7 +995,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } cmds = append(cmds, spinnerVerbTickCmd()) if strings.TrimSpace(m.partial.String()) == "" { - m.spinnerVerb = spinnerVerbs[rand.Intn(len(spinnerVerbs))] // #nosec G404 -- non-cryptographic use (random spinner verb selection) + m.spinnerVerb = spinnerVerbs[rand.IntN(len(spinnerVerbs))] // #nosec G404 -- non-cryptographic use (random spinner verb selection) m.brailleSpinner.SetLabel(m.spinnerVerb) m.viewDirty = true } @@ -1075,7 +1086,7 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if newText != text { m.input.SetValue(newText) } - m.input.SetCursor(newCursor) + m.input.SetCursorColumn(newCursor) } if consumed && m.vim.Mode == VimNormal { return m, tea.Batch(cmds...) diff --git a/cmd/chat_view.go b/cmd/chat_view.go index 0a98addf..c71986c1 100644 --- a/cmd/chat_view.go +++ b/cmd/chat_view.go @@ -9,8 +9,9 @@ import ( "golang.org/x/term" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/ui/icons" - "github.com/charmbracelet/lipgloss" "github.com/mattn/go-runewidth" ) @@ -263,13 +264,13 @@ func (m *chatModel) updateViewportContent() { atBottom := m.viewport.AtBottom() preserveScroll := !m.autoScroll && !atBottom - prevYOffset := m.viewport.YOffset + prevYOffset := m.viewport.YOffset() contentStr, contentWidth, contentLines := m.renderViewportContentForLayout(viewWidth) m.contentLines = contentLines welcomeOnly := m.hasRealMessages() == 0 && !m.waiting - m.viewport.Width = contentWidth + m.viewport.SetWidth(contentWidth) m.viewport.SetContent(contentStr) switch { case welcomeOnly: @@ -300,7 +301,7 @@ func (m *chatModel) renderViewportContentForLayout(viewWidth int) (string, int, // Overflow changes the usable width once the scrollbar gutter is visible. // Re-render once at the final width so wrapping, line counting, and // scrollbar state all describe the same layout. - if m.viewport.Height > 0 && contentLines > m.viewport.Height && viewWidth >= 20 { + if m.viewport.Height() > 0 && contentLines > m.viewport.Height() && viewWidth >= 20 { narrowWidth := viewWidth - scrollbarWidth if narrowWidth < 1 { narrowWidth = 1 @@ -323,9 +324,9 @@ func renderedLineCount(s string) int { return lines } -func (m chatModel) View() string { +func (m chatModel) View() tea.View { if m.quitting { - return "" + return tea.NewView("") } m = m.withSyncedLayout() @@ -430,21 +431,21 @@ func (m chatModel) View() string { paletteView := m.commandPalette.Render(viewWidth) frame.WriteByte('\n') frame.WriteString(paletteView) - return frame.String() + return tea.NewView(frame.String()) } // Autonomy tier picker overlay if m.themePicker != nil && m.themePicker.IsOpen() { - pickerView := lipgloss.NewStyle().Width(viewWidth).Render(m.themePicker.View()) + pickerView := lipgloss.NewStyle().Width(viewWidth).Render(m.themePicker.View().Content) frame.WriteByte('\n') frame.WriteString(pickerView) - return frame.String() + return tea.NewView(frame.String()) } if m.autonomyPicker != nil && m.autonomyPicker.IsOpen() { pickerView := m.autonomyPicker.Render(viewWidth) frame.WriteByte('\n') frame.WriteString(pickerView) - return frame.String() + return tea.NewView(frame.String()) } // Spec workflow picker overlay @@ -452,7 +453,7 @@ func (m chatModel) View() string { pickerView := m.specPicker.Render(viewWidth) frame.WriteByte('\n') frame.WriteString(pickerView) - return frame.String() + return tea.NewView(frame.String()) } // Agent Status HUD overlay @@ -460,12 +461,12 @@ func (m chatModel) View() string { hudView := renderAgentStatusPanel(m.hudData, viewWidth) frame.WriteByte('\n') frame.WriteString(hudView) - return frame.String() + return tea.NewView(frame.String()) } frame.WriteByte('\n') frame.WriteString(bottomBar.String()) - return frame.String() + return tea.NewView(frame.String()) } // renderPermissionBox renders a compact inline permission prompt. diff --git a/cmd/chat_viewport.go b/cmd/chat_viewport.go index d9737a21..5df6beef 100644 --- a/cmd/chat_viewport.go +++ b/cmd/chat_viewport.go @@ -5,9 +5,17 @@ import ( "strconv" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) +// mouseMsg wraps a tea.Mouse to implement tea.MouseMsg interface for tests. +type mouseMsg struct { + mouse tea.Mouse +} + +func (m mouseMsg) String() string { return m.mouse.String() } +func (m mouseMsg) Mouse() tea.Mouse { return m.mouse } + // mouseSGRLeakRE matches SGR mouse reports; "[" is optional (Cursor often drops it). var mouseSGRLeakRE = regexp.MustCompile(`(?:\x1b)?\[?<[0-9;.+^$*-]+[Mm]`) @@ -43,10 +51,10 @@ func isMouseRuneLeak(s string) bool { // isMouseSequenceLeak reports terminal mouse CSI fragments that must not reach the input. func isMouseSequenceLeak(msg tea.KeyMsg) bool { - if msg.Type != tea.KeyRunes || len(msg.Runes) == 0 { + if len(msg.Key().Text) == 0 { return false } - return isMouseRuneLeak(string(msg.Runes)) + return isMouseRuneLeak(msg.Key().Text) } // stripMouseLeaks removes accumulated SGR mouse garbage from an input value. @@ -70,6 +78,12 @@ func inputMayContainMouseLeaks(s string) bool { return strings.Contains(s, "<") || strings.Contains(s, "\x1b") } +// isMouseWheel returns true if the mouse message represents a scroll (wheel) event. +func isMouseWheel(msg tea.MouseMsg) bool { + m := msg.Mouse() + return m.Button == tea.MouseWheelUp || m.Button == tea.MouseWheelDown +} + // shouldForwardToInput keeps mouse events and leaked CSI bytes out of the textarea. func shouldForwardToInput(msg tea.Msg) bool { if _, ok := msg.(tea.MouseMsg); ok { @@ -92,10 +106,10 @@ func (m chatModel) routeKeyToViewport(msg tea.KeyMsg) bool { // Only Up/Down are part of the burst this flag describes. Any other // key (typing, Escape, ...) must fall through to the normal routing // below rather than being swept into viewport-scroll handling. - switch msg.Type { + switch msg.Key().Code { case tea.KeyUp, tea.KeyDown: if m.lastMouseY >= 0 { - return m.mouseInChatPane(tea.MouseMsg{Y: m.lastMouseY}) + return m.mouseInChatPane(mouseMsg{tea.Mouse{Y: m.lastMouseY}}) } return true } @@ -146,7 +160,7 @@ func (m chatModel) chatPaneTopY() int { return 0 } m = m.withSyncedLayout() - top := m.footerTopY() - m.viewport.Height + top := m.footerTopY() - m.viewport.Height() if top < 0 { top = 0 } @@ -174,7 +188,7 @@ func (m chatModel) mouseInFooterZone(mouse tea.MouseMsg) bool { return false } m = m.withSyncedLayout() - return mouse.Y >= m.footerTopY() + return mouse.Mouse().Y >= m.footerTopY() } // mouseInChatPane reports whether a mouse event is over the chat viewport region. @@ -182,38 +196,42 @@ func (m chatModel) mouseInChatPane(mouse tea.MouseMsg) bool { if m.height <= 0 { return true } + m = m.withSyncedLayout() + mouseEv := mouse.Mouse() top := m.chatPaneTopY() footerTop := m.footerTopY() if footerTop <= top { - return mouse.Y >= top + return mouseEv.Y >= top } - return mouse.Y >= top && mouse.Y < footerTop + return mouseEv.Y >= top && mouseEv.Y < footerTop } // trackMousePosition remembers the last pointer row for wheel routing. func (m *chatModel) trackMousePosition(msg tea.MouseMsg) { - if msg.Y < 0 { + mouse := msg.Mouse() + if mouse.Y < 0 { return } // Cursor wheel leaks often report the footer row; keep the last motion/chat row instead. - if tea.MouseEvent(msg).IsWheel() && !m.mouseInChatPane(msg) { + if (mouse.Button == tea.MouseWheelUp || mouse.Button == tea.MouseWheelDown) && !m.mouseInChatPane(msg) { return } - m.lastMouseY = msg.Y + m.lastMouseY = mouse.Y } // effectiveWheelY picks the row used to route wheel events. Cursor's integrated terminal // often reports wheel at the bottom row even when the pointer is over chat; prefer the // last known pointer row only for that stale bottom-row report. func (m chatModel) effectiveWheelY(msg tea.MouseMsg) int { - y := msg.Y + mouse := msg.Mouse() + y := mouse.Y if m.lastMouseY < 0 || !m.mouseInFooterZone(msg) || m.height <= 0 { return y } if y < m.height-1 { return y } - if m.mouseInChatPane(tea.MouseMsg{Y: m.lastMouseY}) { + if m.mouseInChatPane(mouseMsg{tea.Mouse{Y: m.lastMouseY}}) { return m.lastMouseY } return y @@ -237,7 +255,7 @@ func (m chatModel) shouldRouteMouseToViewport(msg tea.Msg) bool { if !isMouse { return true } - if !tea.MouseEvent(mouse).IsWheel() { + if !isMouseWheel(mouse) { return m.inScrollbackFocus() } if m.configOpen { @@ -251,24 +269,24 @@ func (m chatModel) shouldRouteMouseToViewport(msg tea.Msg) bool { // wheelRoutesToChat reports whether a wheel event should scroll chat history. func (m chatModel) wheelRoutesToChat(mouse tea.MouseMsg) bool { - route := mouse + route := mouse.Mouse() route.Y = m.effectiveWheelY(mouse) - return m.mouseInChatPane(route) + return m.mouseInChatPane(mouseMsg{route}) } // applyMouseScroll routes a mouse event to the chat viewport and syncs follow mode. func (m *chatModel) applyMouseScroll(msg tea.MouseMsg) tea.Cmd { - if !tea.MouseEvent(msg).IsWheel() { + if !isMouseWheel(msg) { if !m.shouldRouteMouseToViewport(msg) { return nil } } else if !m.wheelRoutesToChat(msg) { return nil } - switch msg.Button { - case tea.MouseButtonWheelDown: + switch msg.Mouse().Button { + case tea.MouseWheelDown: m.viewport.ScrollDown(m.viewport.MouseWheelDelta) - case tea.MouseButtonWheelUp: + case tea.MouseWheelUp: m.viewport.ScrollUp(m.viewport.MouseWheelDelta) default: var vpCmd tea.Cmd @@ -312,9 +330,9 @@ func (m *chatModel) applyViewportScroll(msg tea.KeyMsg) (bool, tea.Cmd) { func wheelButtonFromSGR(code int) (tea.MouseButton, bool) { switch code { case 64: - return tea.MouseButtonWheelUp, true + return tea.MouseWheelUp, true case 65: - return tea.MouseButtonWheelDown, true + return tea.MouseWheelDown, true default: return 0, false } @@ -322,13 +340,13 @@ func wheelButtonFromSGR(code int) (tea.MouseButton, bool) { func mouseMsgFromSGRMatch(match []string) (tea.MouseMsg, bool) { if len(match) < 5 { - return tea.MouseMsg{}, false + return tea.MouseClickMsg{}, false } btnCode, err1 := strconv.Atoi(match[1]) x, err2 := strconv.Atoi(match[2]) y, err3 := strconv.Atoi(match[3]) if err1 != nil || err2 != nil || err3 != nil { - return tea.MouseMsg{}, false + return tea.MouseClickMsg{}, false } // SGR coordinates are 1-based; bubbletea uses 0-based (see parseSGRMouseEvent). if x > 0 { @@ -339,13 +357,12 @@ func mouseMsgFromSGRMatch(match []string) (tea.MouseMsg, bool) { } btn, ok := wheelButtonFromSGR(btnCode) if !ok { - return tea.MouseMsg{}, false + return tea.MouseClickMsg{}, false } - return tea.MouseMsg{ + return tea.MouseClickMsg{ X: x, Y: y, Button: btn, - Action: tea.MouseActionPress, }, true } @@ -356,7 +373,7 @@ func (m *chatModel) tryScrollFromMouseLeak(msg tea.KeyMsg) (bool, tea.Cmd) { if !m.mouseEnabled() { return false, nil } - matches := mouseSGRReportRE.FindAllStringSubmatch(string(msg.Runes), -1) + matches := mouseSGRReportRE.FindAllStringSubmatch(msg.Key().Text, -1) if len(matches) == 0 { return false, nil } diff --git a/cmd/chat_viewport_render_test.go b/cmd/chat_viewport_render_test.go index fa5198e4..6aa9e7e5 100644 --- a/cmd/chat_viewport_render_test.go +++ b/cmd/chat_viewport_render_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/viewport" + "charm.land/bubbles/v2/viewport" ) func TestAssembleViewportContent_IncrementalMatchesFullRebuild(t *testing.T) { @@ -109,7 +109,7 @@ func TestUpdateViewportContent_RewrapsAfterScrollbarGutterAppears(t *testing.T) m := &chatModel{ messages: []displayMsg{{role: "system", content: content}}, - viewport: viewport.New(fullWidth, viewportHeight), + viewport: viewport.New(viewport.WithWidth(fullWidth), viewport.WithHeight(viewportHeight)), width: fullWidth, } m.viewDirty = true @@ -123,14 +123,14 @@ func TestUpdateViewportContent_RewrapsAfterScrollbarGutterAppears(t *testing.T) fullWidthLines, narrowWidthLines, m.contentLines, - m.viewport.Width, + m.viewport.Width(), ) } if !m.chatScrollbarVisible() { t.Fatal("expected scrollbar to become visible after re-wrap") } - if m.viewport.Width != narrowWidth { - t.Fatalf("expected viewport width %d with scrollbar gutter, got %d", narrowWidth, m.viewport.Width) + if m.viewport.Width() != narrowWidth { + t.Fatalf("expected viewport width %d with scrollbar gutter, got %d", narrowWidth, m.viewport.Width()) } if m.contentLines != narrowWidthLines { t.Fatalf("expected contentLines %d after narrow re-wrap, got %d", narrowWidthLines, m.contentLines) diff --git a/cmd/chat_viewport_test.go b/cmd/chat_viewport_test.go index 35dcb51d..af9988b9 100644 --- a/cmd/chat_viewport_test.go +++ b/cmd/chat_viewport_test.go @@ -4,21 +4,21 @@ import ( "strings" "testing" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) func TestRouteKeyToViewport_ArrowsInPromptFocus(t *testing.T) { - vp := viewport.New(80, 10) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)) vp.SetContent(strings.Repeat("line\n", 40)) vp.SetYOffset(5) ta := textarea.New() m := chatModel{viewport: vp, input: ta, uiFocus: focusPrompt} - up := tea.KeyMsg{Type: tea.KeyUp} + up := tea.KeyPressMsg{Code: tea.KeyUp} if m.routeKeyToViewport(up) { t.Fatal("up in prompt focus should use input history, not scroll chat") } @@ -29,7 +29,7 @@ func TestRouteKeyToViewport_ArrowsInPromptFocus(t *testing.T) { } func TestMouseInChatPane_Zones(t *testing.T) { - vp := viewport.New(80, 14) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)) m := chatModel{ viewport: vp, input: textarea.New(), @@ -45,18 +45,18 @@ func TestMouseInChatPane_Zones(t *testing.T) { t.Fatalf("invalid zones top=%d bottom=%d", top, bottom) } - overChat := tea.MouseMsg{Y: top, Button: tea.MouseButtonWheelDown} + overChat := tea.MouseWheelMsg{Y: top, Button: tea.MouseWheelDown} if !m.mouseInChatPane(overChat) { t.Fatal("expected wheel row on chat pane") } - overInput := tea.MouseMsg{Y: bottom, Button: tea.MouseButtonWheelDown} + overInput := tea.MouseWheelMsg{Y: bottom, Button: tea.MouseWheelDown} if m.mouseInChatPane(overInput) { t.Fatal("expected wheel row on input footer to be outside chat pane") } } func TestShouldRouteMouseToViewport_SplitPaneUX(t *testing.T) { - vp := viewport.New(80, 14) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)) vp.SetContent(strings.Repeat("line\n", 40)) m := chatModel{ viewport: vp, @@ -67,8 +67,8 @@ func TestShouldRouteMouseToViewport_SplitPaneUX(t *testing.T) { } m = m.withSyncedLayout() - wheelChat := tea.MouseMsg{Y: m.chatPaneTopY(), Button: tea.MouseButtonWheelDown} - wheelInput := tea.MouseMsg{Y: m.bottomBarTopY(), Button: tea.MouseButtonWheelDown} + wheelChat := tea.MouseWheelMsg{Y: m.chatPaneTopY(), Button: tea.MouseWheelDown} + wheelInput := tea.MouseWheelMsg{Y: m.bottomBarTopY(), Button: tea.MouseWheelDown} if !m.shouldRouteMouseToViewport(wheelChat) { t.Fatal("wheel over chat should scroll history in prompt focus") @@ -85,7 +85,7 @@ func TestShouldRouteMouseToViewport_SplitPaneUX(t *testing.T) { func TestSyncViewportMouseWheel_ManualRouting(t *testing.T) { t.Setenv("HAWK_MOUSE", "") - vp := viewport.New(80, 10) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)) m := chatModel{viewport: vp, uiFocus: focusPrompt} m = m.syncViewportMouseWheel() if m.viewport.MouseWheelEnabled { @@ -95,7 +95,7 @@ func TestSyncViewportMouseWheel_ManualRouting(t *testing.T) { func TestSyncViewportMouseWheel_DisabledWithOptOut(t *testing.T) { t.Setenv("HAWK_MOUSE", "0") - vp := viewport.New(80, 10) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)) disabled := false m := chatModel{viewport: vp, uiFocus: focusPrompt, settings: hawkconfig.Settings{TuiMouse: &disabled}} m = m.syncViewportMouseWheel() @@ -105,7 +105,7 @@ func TestSyncViewportMouseWheel_DisabledWithOptOut(t *testing.T) { } func TestTryScrollFromMouseLeak_SplitPaneByY(t *testing.T) { - vp := viewport.New(80, 14) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)) vp.SetContent(strings.Repeat("line\n", 40)) m := chatModel{ viewport: vp, @@ -115,31 +115,31 @@ func TestTryScrollFromMouseLeak_SplitPaneByY(t *testing.T) { uiFocus: focusPrompt, } m = m.withSyncedLayout() - before := m.viewport.YOffset + before := m.viewport.YOffset() - chatLeak := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[<65;99;6M")} // SGR Y is 1-based → row 5 + chatLeak := tea.KeyPressMsg{Code: '[', Text: "[<65;99;6M"} // SGR Y is 1-based → row 5 handled, _ := m.tryScrollFromMouseLeak(chatLeak) if !handled { t.Fatal("expected chat leak to be consumed") } - if m.viewport.YOffset == before { + if m.viewport.YOffset() == before { t.Fatal("wheel leak over chat should scroll viewport") } m.viewport.SetYOffset(before) - inputLeak := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[<65;99;23M")} // 1-based footer row + inputLeak := tea.KeyPressMsg{Code: '[', Text: "[<65;99;23M"} // 1-based footer row handled, _ = m.tryScrollFromMouseLeak(inputLeak) if !handled { t.Fatal("expected input leak to be consumed") } - if m.viewport.YOffset != before { + if m.viewport.YOffset() != before { t.Fatal("wheel leak over input should not scroll viewport") } } func TestLetterMNotTreatedAsMouseLeak(t *testing.T) { for _, s := range []string{"m", "M", "hello", "lam", "vim", "make"} { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + msg := tea.KeyPressMsg{Code: '[', Text: s} if isMouseSequenceLeak(msg) { t.Fatalf("%q must not be filtered as mouse leak", s) } @@ -153,7 +153,7 @@ func TestLetterMNotTreatedAsMouseLeak(t *testing.T) { } func TestMouseSequenceLeak_Filtered(t *testing.T) { - leak := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("[<65;49;18M")} + leak := tea.KeyPressMsg{Code: '[', Text: "[<65;49;18M"} if !isMouseSequenceLeak(leak) { t.Fatal("expected SGR mouse leak detection") } @@ -169,7 +169,7 @@ func TestMouseSequenceLeak_Filtered(t *testing.T) { func TestMouseSequenceLeak_PartialFragments(t *testing.T) { partials := []string{"[", "[<", "[<65", "65;99;16M"} for _, s := range partials { - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + msg := tea.KeyPressMsg{Code: '[', Text: s} if !isMouseSequenceLeak(msg) { t.Fatalf("expected partial leak %q to be filtered", s) } @@ -185,7 +185,7 @@ func TestMouseSequenceLeak_PartialFragments(t *testing.T) { func TestMouseSequenceLeak_CursorConcatenated(t *testing.T) { // Cursor integrated terminal often drops "[" on repeated wheel events. leak := "[<65;84;24M[<64;84;24M<64;84;24M<64;84;24M<65;84;24M" - msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(leak)} + msg := tea.KeyPressMsg{Code: '[', Text: leak} if !isMouseSequenceLeak(msg) { t.Fatal("expected concatenated Cursor leak detection") } @@ -198,7 +198,7 @@ func TestMouseSequenceLeak_CursorConcatenated(t *testing.T) { } func TestEffectiveWheelY_CursorStaleFooterRow(t *testing.T) { - vp := viewport.New(80, 14) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)) vp.SetContent(strings.Repeat("line\n", 40)) m := chatModel{ viewport: vp, @@ -210,7 +210,7 @@ func TestEffectiveWheelY_CursorStaleFooterRow(t *testing.T) { } m = m.withSyncedLayout() - staleFooter := tea.MouseMsg{Y: m.height - 1, Button: tea.MouseButtonWheelDown} + staleFooter := tea.MouseWheelMsg{Y: m.height - 1, Button: tea.MouseWheelDown} if !m.wheelRoutesToChat(staleFooter) { t.Fatal("stale bottom-row wheel Y should route to chat when pointer was over chat") } @@ -220,14 +220,14 @@ func TestEffectiveWheelY_CursorStaleFooterRow(t *testing.T) { t.Fatal("stale bottom-row wheel Y must not scroll when pointer was over input") } - explicitFooter := tea.MouseMsg{Y: m.footerTopY(), Button: tea.MouseButtonWheelDown} + explicitFooter := tea.MouseWheelMsg{Y: m.footerTopY(), Button: tea.MouseWheelDown} if m.wheelRoutesToChat(explicitFooter) { t.Fatal("explicit footer wheel row must not scroll chat") } } func TestApplyMouseScroll_ClearsStreamFollow(t *testing.T) { - vp := viewport.New(80, 14) + vp := viewport.New(viewport.WithWidth(80), viewport.WithHeight(14)) vp.SetContent(strings.Repeat("line\n", 40)) vp.GotoBottom() m := chatModel{ @@ -241,9 +241,9 @@ func TestApplyMouseScroll_ClearsStreamFollow(t *testing.T) { contentLines: 40, } m = m.withSyncedLayout() - m.applyMouseScroll(tea.MouseMsg{ + m.applyMouseScroll(tea.MouseWheelMsg{ Y: m.chatPaneTopY(), - Button: tea.MouseButtonWheelUp, + Button: tea.MouseWheelUp, }) if m.streamFollow { t.Fatal("manual wheel scroll must disable stream follow") diff --git a/cmd/chat_viewport_typing_test.go b/cmd/chat_viewport_typing_test.go index 889e1400..5d3b91d7 100644 --- a/cmd/chat_viewport_typing_test.go +++ b/cmd/chat_viewport_typing_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // TestScrollableLettersReachPromptWhenFocused reproduces a reported bug: @@ -19,14 +19,14 @@ func TestScrollableLettersReachPromptWhenFocused(t *testing.T) { m.input.Focus() // Make the viewport genuinely scrollable: more content lines than height. - m.viewport.Height = 3 + m.viewport.SetHeight(3) m.viewport.SetContent(strings.Repeat("line\n", 20)) if !m.viewportScrollable() { t.Fatal("test setup failed: viewport should be scrollable") } for _, ch := range []string{"d", "b", "f", "u"} { - next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(ch)}) + next, _ := m.Update(tea.KeyPressMsg{Code: []rune(ch)[0], Text: ch}) *m = next.(chatModel) } diff --git a/cmd/cloud.go b/cmd/cloud.go new file mode 100644 index 00000000..458df537 --- /dev/null +++ b/cmd/cloud.go @@ -0,0 +1,208 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "runtime" + "time" + + cloud "github.com/GrayCodeAI/hawk/internal/platform/cloud" + "github.com/spf13/cobra" +) + +var cloudCmd = &cobra.Command{Use: "cloud", Short: "Manage optional Hawk Cloud synchronization"} + +var cloudConnectCmd = &cobra.Command{ + Use: "connect", + Short: "Connect this Hawk device to Hawk Cloud", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, _ := cmd.Flags().GetString("endpoint") + deviceID, _ := cmd.Flags().GetString("device-id") + projectID, _ := cmd.Flags().GetString("project-id") + token, _ := cmd.Flags().GetString("token") + if endpoint == "" || deviceID == "" || projectID == "" || token == "" { + return fmt.Errorf("endpoint, device-id, project-id, and token are required") + } + if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: deviceID, ProjectID: projectID}, token); err != nil { + return err + } + cmd.Println("Hawk Cloud connected. Usage synchronization is opt-in and fail-open.") + return nil + }, +} + +var cloudLoginCmd = &cobra.Command{ + Use: "login", + Short: "Sign in to Hawk Cloud in a browser", + RunE: func(cmd *cobra.Command, _ []string) error { + endpoint, _ := cmd.Flags().GetString("endpoint") + label, _ := cmd.Flags().GetString("label") + if endpoint == "" { + endpoint = os.Getenv("HAWK_CLOUD_URL") + } + if endpoint == "" { + return fmt.Errorf("hawk cloud endpoint is required (use --endpoint or HAWK_CLOUD_URL)") + } + if label == "" { + label, _ = os.Hostname() + } + client := cloud.New(cloud.Config{Endpoint: endpoint}) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + start, err := client.StartDeviceLogin(ctx, label, runtime.GOOS, version) + if err != nil { + return err + } + cmd.Printf("Open %s and enter code %s\n", start.VerificationURI, start.UserCode) + if err := openBrowser(start.VerificationURI + "?code=" + start.UserCode); err != nil { + cmd.Printf("Could not open the browser automatically: %v\n", err) + } + interval := time.Duration(start.Interval) * time.Second + if interval < time.Second { + interval = 5 * time.Second + } + for { + poll, pollErr := client.PollDeviceLogin(ctx, start.DeviceCode) + if pollErr != nil { + return pollErr + } + switch poll.Status { + case "pending": + select { + case <-ctx.Done(): + return fmt.Errorf("waiting for browser approval: %w", ctx.Err()) + case <-time.After(interval): + } + case "approved": + if poll.Token == "" || poll.DeviceID == "" || poll.ProjectID == "" { + return fmt.Errorf("hawk cloud returned an incomplete device authorization") + } + if err := cloud.SaveDeviceConfig(cloud.DeviceConfig{Endpoint: endpoint, DeviceID: poll.DeviceID, ProjectID: poll.ProjectID}, poll.Token); err != nil { + return err + } + cmd.Printf("Hawk Cloud connected for project %s.\n", poll.ProjectID) + return nil + case "expired": + return fmt.Errorf("hawk cloud device authorization expired") + default: + return fmt.Errorf("hawk cloud returned unknown device authorization status %q", poll.Status) + } + } + }, +} + +var cloudStatusCmd = &cobra.Command{ + Use: "status", Short: "Show Hawk Cloud connection status", + RunE: func(cmd *cobra.Command, _ []string) error { + client, cfg, err := cloud.LoadClient() + if err != nil || !client.Enabled() { + cmd.Println("Hawk Cloud is not connected.") + return nil + } + cmd.Printf("Hawk Cloud connected: %s (device %s, project %s)\n", cfg.Endpoint, cfg.DeviceID, cfg.ProjectID) + return nil + }, +} + +var cloudContextCmd = &cobra.Command{ + Use: "context", + Short: "Sync repository context to Hawk Cloud", + RunE: func(cmd *cobra.Command, _ []string) error { + client, cfg, err := cloud.LoadClient() + if err != nil || !client.Enabled() { + return fmt.Errorf("hawk cloud is not connected") + } + detected, detectErr := detectGitContext(cmd.Context()) + repository, _ := cmd.Flags().GetString("repository") + if repository == "" { + repository = detected.Repository + } + if repository == "" { + return detectErr + } + contextProvider, _ := cmd.Flags().GetString("provider") + externalID, _ := cmd.Flags().GetString("external-id") + branch, _ := cmd.Flags().GetString("branch") + commit, _ := cmd.Flags().GetString("commit") + ciRunID, _ := cmd.Flags().GetString("ci-run") + ciStatus, _ := cmd.Flags().GetString("ci-status") + ciWorkflow, _ := cmd.Flags().GetString("ci-workflow") + deploymentID, _ := cmd.Flags().GetString("deployment") + deploymentStatus, _ := cmd.Flags().GetString("deployment-status") + deploymentEnvironment, _ := cmd.Flags().GetString("deployment-environment") + if contextProvider == "" { + contextProvider = detected.Provider + } + if contextProvider == "" { + contextProvider = "git" + } + if branch == "" { + branch = detected.Branch + } + if commit == "" { + commit = detected.Commit + } + if externalID == "" { + externalID = repository + } + event := cloud.DeliveryContext{ProjectID: cfg.ProjectID, Branch: branch, CommitSHA: commit} + event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = contextProvider, externalID, repository + if ciRunID == "" { + ciRunID, ciWorkflow = os.Getenv("GITHUB_RUN_ID"), firstValue(ciWorkflow, os.Getenv("GITHUB_WORKFLOW")) + } + if ciRunID != "" { + if ciStatus == "" { + ciStatus = "running" + } + ciProvider := contextProvider + if os.Getenv("GITHUB_RUN_ID") != "" && ciProvider == "git" { + ciProvider = "github" + } + event.CIRun = &cloud.CIRunContext{Provider: ciProvider, ExternalID: ciRunID, Workflow: ciWorkflow, Status: ciStatus} + } + if deploymentID != "" { + if deploymentStatus == "" { + deploymentStatus = "running" + } + if deploymentEnvironment == "" { + return fmt.Errorf("deployment-environment is required with --deployment") + } + event.Deployment = &cloud.DeploymentContext{Provider: contextProvider, ExternalID: deploymentID, Environment: deploymentEnvironment, Status: deploymentStatus} + } + client.RecordDeliveryContext(cmd.Context(), event) + cmd.Println("Repository context queued for Hawk Cloud.") + return nil + }, +} + +func firstValue(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func init() { + cloudLoginCmd.Flags().String("endpoint", "", "Hawk Cloud endpoint (or HAWK_CLOUD_URL)") + cloudLoginCmd.Flags().String("label", "", "Name for this Hawk device") + cloudConnectCmd.Flags().String("endpoint", "", "Hawk Cloud endpoint") + cloudConnectCmd.Flags().String("device-id", "", "Hawk Cloud device ID") + cloudConnectCmd.Flags().String("project-id", "", "Hawk Cloud project ID") + cloudConnectCmd.Flags().String("token", "", "Hawk Cloud device token") + cloudContextCmd.Flags().String("repository", "", "Repository name (auto-detected from Git when omitted)") + cloudContextCmd.Flags().String("provider", "", "Repository provider (auto-detected when omitted)") + cloudContextCmd.Flags().String("external-id", "", "Provider repository identifier (defaults to repository)") + cloudContextCmd.Flags().String("branch", "", "Current branch (auto-detected when omitted)") + cloudContextCmd.Flags().String("commit", "", "Current commit SHA (auto-detected when omitted)") + cloudContextCmd.Flags().String("ci-run", "", "CI run identifier (defaults to GITHUB_RUN_ID)") + cloudContextCmd.Flags().String("ci-status", "", "CI status: queued, running, succeeded, failed, or cancelled") + cloudContextCmd.Flags().String("ci-workflow", "", "CI workflow name (defaults to GITHUB_WORKFLOW)") + cloudContextCmd.Flags().String("deployment", "", "Deployment identifier") + cloudContextCmd.Flags().String("deployment-status", "", "Deployment status") + cloudContextCmd.Flags().String("deployment-environment", "", "Deployment environment, for example production") + cloudCmd.AddCommand(cloudLoginCmd, cloudConnectCmd, cloudStatusCmd, cloudContextCmd) + rootCmd.AddCommand(cloudCmd) +} diff --git a/cmd/cloud_context.go b/cmd/cloud_context.go new file mode 100644 index 00000000..22dad346 --- /dev/null +++ b/cmd/cloud_context.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +type gitContext struct { + Repository string + Provider string + Branch string + Commit string +} + +var runCloudGit = func(ctx context.Context, args ...string) (string, error) { + out, err := exec.CommandContext(ctx, "git", args...).Output() // #nosec G204 -- fixed git binary and fixed args + return strings.TrimSpace(string(out)), err +} + +func detectGitContext(ctx context.Context) (gitContext, error) { + remote, err := runCloudGit(ctx, "config", "--get", "remote.origin.url") + if err != nil || remote == "" { + return gitContext{}, fmt.Errorf("could not detect the git origin; pass --repository") + } + branch, _ := runCloudGit(ctx, "branch", "--show-current") + commit, _ := runCloudGit(ctx, "rev-parse", "HEAD") + return gitContext{Repository: repositoryName(remote), Provider: repositoryProvider(remote), Branch: branch, Commit: commit}, nil +} + +func repositoryName(remote string) string { + value := strings.TrimSuffix(strings.TrimSpace(remote), ".git") + if index := strings.LastIndex(value, ":"); index >= 0 && !strings.Contains(value[index+1:], "/") { + return value[index+1:] + } + if index := strings.Index(value, ":"); index >= 0 && strings.Contains(value[index+1:], "/") && !strings.Contains(value, "://") { + return value[index+1:] + } + parts := strings.Split(value, "/") + if len(parts) >= 2 { + return strings.Join(parts[len(parts)-2:], "/") + } + return value +} + +func repositoryProvider(remote string) string { + value := strings.ToLower(remote) + switch { + case strings.Contains(value, "github.com"): + return "github" + case strings.Contains(value, "gitlab.com"): + return "gitlab" + case strings.Contains(value, "bitbucket.org"): + return "bitbucket" + default: + return "git" + } +} diff --git a/cmd/cloud_context_test.go b/cmd/cloud_context_test.go new file mode 100644 index 00000000..c4f05c30 --- /dev/null +++ b/cmd/cloud_context_test.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "context" + "fmt" + "testing" +) + +func TestDetectGitContext(t *testing.T) { + original := runCloudGit + t.Cleanup(func() { runCloudGit = original }) + runCloudGit = func(_ context.Context, args ...string) (string, error) { + switch args[0] { + case "config": + return "git@github.com:GrayCodeAI/hawk.git", nil + case "branch": + return "main", nil + case "rev-parse": + return "abc123", nil + default: + return "", fmt.Errorf("unexpected git command %v", args) + } + } + got, err := detectGitContext(context.Background()) + if err != nil { + t.Fatal(err) + } + if got.Repository != "GrayCodeAI/hawk" || got.Provider != "github" || got.Branch != "main" || got.Commit != "abc123" { + t.Fatalf("context = %+v", got) + } +} + +func TestRepositoryNameHandlesHTTPSRemote(t *testing.T) { + if got := repositoryName("https://gitlab.com/acme/platform.git"); got != "acme/platform" { + t.Fatalf("repository = %q", got) + } +} + +func TestFirstValue(t *testing.T) { + if got := firstValue("", "workflow", "later"); got != "workflow" { + t.Fatalf("value = %q", got) + } +} diff --git a/cmd/command_palette.go b/cmd/command_palette.go index 72c002d0..650ea4a4 100644 --- a/cmd/command_palette.go +++ b/cmd/command_palette.go @@ -5,9 +5,9 @@ import ( "sort" "strings" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" ) // CommandPaletteEntry represents a single command in the palette. @@ -44,8 +44,7 @@ func NewCommandPalette(width int) *CommandPalette { ti := textinput.New() ti.Placeholder = "Type to search commands..." ti.Focus() - ti.CharLimit = 100 - ti.Width = 40 + ti.SetWidth(40) cp := &CommandPalette{ input: ti, @@ -145,7 +144,7 @@ func (cp *CommandPalette) Update(msg tea.KeyMsg) (string, bool) { return "", false } - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: cp.Close() return "", true @@ -225,7 +224,7 @@ func (cp *CommandPalette) Render(viewWidth int) string { b.WriteString("\n\n") // Input - cp.input.Width = boxWidth - 4 + cp.input.SetWidth(boxWidth - 4) b.WriteString(paletteInputStyle.Width(boxWidth - 2).Render(cp.input.View())) b.WriteString("\n\n") diff --git a/cmd/compact_ui.go b/cmd/compact_ui.go index 7291c206..a8b9268d 100644 --- a/cmd/compact_ui.go +++ b/cmd/compact_ui.go @@ -7,8 +7,8 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/mattn/go-runewidth" "github.com/GrayCodeAI/hawk/internal/engine" @@ -139,9 +139,6 @@ func (m *chatModel) clearCompactCancel() { } func isCompactCancelKey(msg tea.KeyMsg) bool { - if msg.Type == tea.KeyEsc { - return true - } switch msg.String() { case "esc", "ctrl+c", "ctrl+[", "ctrl+\\": return true diff --git a/cmd/config_table.go b/cmd/config_table.go index 8fd58146..3bfa9157 100644 --- a/cmd/config_table.go +++ b/cmd/config_table.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/ui/icons" - "github.com/charmbracelet/lipgloss" "github.com/mattn/go-runewidth" ) diff --git a/cmd/container_boot.go b/cmd/container_boot.go index c47daacc..5f6853ff 100644 --- a/cmd/container_boot.go +++ b/cmd/container_boot.go @@ -8,7 +8,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/sandbox" ) diff --git a/cmd/context_viz.go b/cmd/context_viz.go index ecedf3ed..d114f3cb 100644 --- a/cmd/context_viz.go +++ b/cmd/context_viz.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" ) // ContextVisualization renders token usage as a visual bar in the TUI. diff --git a/cmd/exec.go b/cmd/exec.go index bf07cb99..c68e3901 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -16,6 +16,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/multiagent/agents" "github.com/GrayCodeAI/hawk/internal/observability/logger" + cloud "github.com/GrayCodeAI/hawk/internal/platform/cloud" "github.com/GrayCodeAI/hawk/internal/plugin" "github.com/GrayCodeAI/hawk/internal/session" "github.com/spf13/cobra" @@ -330,13 +331,32 @@ func runExec(_ *cobra.Command, args []string) error { } } - // Persist session for resume/search (skip in ephemeral/CI mode) exitCode := 0 if execErr != "" { exitCode = 1 } + sessionID := fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(4)) + + // Optional cloud accounting is best-effort and never affects local execution. + if client, cfg, loadErr := cloud.LoadClient(); loadErr == nil && client.Enabled() { + go client.RecordUsage(context.Background(), cloud.UsageEvent{ + EventID: fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(8)), + DeviceID: cfg.DeviceID, + ProjectID: cfg.ProjectID, + SessionID: sessionID, + Capability: "hawk", + Model: effectiveModel, + InputTokens: totalIn, + OutputTokens: totalOut, + TokensUsed: totalIn + totalOut, + DurationMS: int(time.Since(start).Milliseconds()), + Status: map[bool]string{true: "failed", false: "completed"}[exitCode != 0], + OccurredAt: time.Now().UTC().Format(time.RFC3339), + }) + } + + // Persist session for resume/search (skip in ephemeral/CI mode) if !execEphemeral { - sessionID := fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(4)) persistExecSession(sessionID, effectiveModel, effectiveProvider, prompt, response.String()) } @@ -352,7 +372,7 @@ func runExec(_ *cobra.Command, args []string) error { if execOutputFormat == "json" { result := ExecResult{ - SessionID: fmt.Sprintf("exec-%d-%s", start.UnixMilli(), randomHex(4)), + SessionID: sessionID, Response: response.String(), ExitCode: exitCode, TokensIn: totalIn, diff --git a/cmd/footer_layout.go b/cmd/footer_layout.go index 057b4a35..271d1fdb 100644 --- a/cmd/footer_layout.go +++ b/cmd/footer_layout.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" ) diff --git a/cmd/footer_layout_block_test.go b/cmd/footer_layout_block_test.go index 047f3948..384544f2 100644 --- a/cmd/footer_layout_block_test.go +++ b/cmd/footer_layout_block_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" ) func TestClipRenderedBlock_TrimsWideBorder(t *testing.T) { diff --git a/cmd/footer_layout_clip_test.go b/cmd/footer_layout_clip_test.go index c5cdbf50..3bfc74e0 100644 --- a/cmd/footer_layout_clip_test.go +++ b/cmd/footer_layout_clip_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" ) func TestLayoutFooterRow_TokensSurviveFinishFooterLine(t *testing.T) { diff --git a/cmd/footer_layout_width_test.go b/cmd/footer_layout_width_test.go index b19b433e..be5c02d0 100644 --- a/cmd/footer_layout_width_test.go +++ b/cmd/footer_layout_width_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" "github.com/mattn/go-runewidth" ) diff --git a/cmd/footer_lipgloss_width_test.go b/cmd/footer_lipgloss_width_test.go index 66009780..df82da43 100644 --- a/cmd/footer_lipgloss_width_test.go +++ b/cmd/footer_lipgloss_width_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" ) func TestLayoutFooterRow_NarrowRightBlockStillShowsBullet(t *testing.T) { diff --git a/cmd/hud_panel.go b/cmd/hud_panel.go index 5dcb8175..f5dc7077 100644 --- a/cmd/hud_panel.go +++ b/cmd/hud_panel.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) diff --git a/cmd/input_indicator.go b/cmd/input_indicator.go index 51405bdd..cb3c0ac8 100644 --- a/cmd/input_indicator.go +++ b/cmd/input_indicator.go @@ -1,7 +1,7 @@ package cmd import ( - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/feature/shellmode" "github.com/GrayCodeAI/hawk/internal/ui/icons" diff --git a/cmd/markdown.go b/cmd/markdown.go index e49a0575..daba3803 100644 --- a/cmd/markdown.go +++ b/cmd/markdown.go @@ -6,7 +6,7 @@ import ( "strings" "unicode/utf8" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" "github.com/mattn/go-runewidth" ) diff --git a/cmd/model_table.go b/cmd/model_table.go index 5d6bb1f0..83b30e8d 100644 --- a/cmd/model_table.go +++ b/cmd/model_table.go @@ -5,9 +5,9 @@ import ( "strconv" "strings" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/eyrie/catalog" "github.com/GrayCodeAI/hawk/internal/ui/icons" - "github.com/charmbracelet/lipgloss" "github.com/mattn/go-runewidth" ) diff --git a/cmd/path_test.go b/cmd/path_test.go index 7ee853d2..e5902668 100644 --- a/cmd/path_test.go +++ b/cmd/path_test.go @@ -5,18 +5,27 @@ import ( "strings" "testing" + "github.com/GrayCodeAI/eyrie/credentials" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ) func TestPathCmdRuns(t *testing.T) { + useInMemoryCredentials(t) if err := pathCmd.RunE(pathCmd, nil); err == nil { t.Skip("machine has full developer path setup") // TODO: https://github.com/GrayCodeAI/hawk/issues/30 } } func TestPathCmdPrintsReport(t *testing.T) { + useInMemoryCredentials(t) out := hawkconfig.FormatDeveloperPathReport(context.Background()) if !strings.Contains(out, "Developer path") { t.Fatalf("unexpected output: %s", out) } } + +func useInMemoryCredentials(t *testing.T) { + t.Helper() + credentials.SetDefaultStore(&credentials.MapStore{}) + t.Cleanup(func() { credentials.SetDefaultStore(nil) }) +} diff --git a/cmd/permissions_center.go b/cmd/permissions_center.go index d0c42d05..c4aa8921 100644 --- a/cmd/permissions_center.go +++ b/cmd/permissions_center.go @@ -4,9 +4,9 @@ import ( "fmt" "strings" + tea "charm.land/bubbletea/v2" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" - tea "github.com/charmbracelet/bubbletea" ) const defaultPermissionSandbox = "workspace" diff --git a/cmd/review_read.go b/cmd/review_read.go index f6167411..17957c5a 100644 --- a/cmd/review_read.go +++ b/cmd/review_read.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/GrayCodeAI/hawk/internal/ui/icons" diff --git a/cmd/review_tui.go b/cmd/review_tui.go index a1b5ca6a..e25e8492 100644 --- a/cmd/review_tui.go +++ b/cmd/review_tui.go @@ -5,8 +5,8 @@ import ( "os" "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/spf13/cobra" "github.com/GrayCodeAI/hawk/internal/ui/icons" @@ -43,7 +43,7 @@ func runReviewTUI(_ *cobra.Command, _ []string) error { } m := reviewTUIModel{store: store} - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tea.NewProgram(m) _, err = p.Run() _ = store.Close() return err @@ -117,9 +117,9 @@ func (m reviewTUIModel) reload() tea.Cmd { } } -func (m reviewTUIModel) View() string { +func (m reviewTUIModel) View() tea.View { if m.quitting { - return "" + return tea.View{} } header := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) @@ -132,7 +132,9 @@ func (m reviewTUIModel) View() string { if len(m.reviews) == 0 { b.WriteString("\n No reviews yet. Run 'hawk review init' to get started.\n") - return b.String() + v := tea.View{Content: b.String()} + v.AltScreen = true + return v } // Calculate visible area. @@ -181,7 +183,9 @@ func (m reviewTUIModel) View() string { } } - return b.String() + v := tea.View{Content: b.String()} + v.AltScreen = true + return v } func reviewMin(a, b int) int { diff --git a/cmd/snapshot_cmd.go b/cmd/snapshot_cmd.go index 1b09baac..bed82fa5 100644 --- a/cmd/snapshot_cmd.go +++ b/cmd/snapshot_cmd.go @@ -5,8 +5,8 @@ import ( "os" "strings" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/snapshot" - tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" ) diff --git a/cmd/spec_picker.go b/cmd/spec_picker.go index 31c469e0..c53812da 100644 --- a/cmd/spec_picker.go +++ b/cmd/spec_picker.go @@ -4,10 +4,10 @@ import ( "fmt" "strings" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) // specPickerAction identifies what a SpecPicker entry does when selected. @@ -60,8 +60,7 @@ func NewSpecPicker(width int) *SpecPicker { ti := textinput.New() ti.Placeholder = "Type to filter…" ti.Focus() - ti.CharLimit = 40 - ti.Width = 40 + ti.SetWidth(40) return &SpecPicker{ input: ti, @@ -108,7 +107,7 @@ func (sp *SpecPicker) Update(msg tea.KeyMsg) (*specPickerEntry, bool) { return nil, false } - switch msg.Type { + switch key := msg.Key(); key.Code { case tea.KeyEsc: sp.Close() return nil, true @@ -175,7 +174,7 @@ func (sp *SpecPicker) Render(viewWidth int) string { b.WriteString(paletteDimStyle.Render(fmt.Sprintf(" Current stage: %s", specStageDisplayName(sp.stage)))) b.WriteString("\n\n") - sp.input.Width = boxWidth - 4 + sp.input.SetWidth(boxWidth - 4) b.WriteString(paletteInputStyle.Width(boxWidth - 2).Render(sp.input.View())) b.WriteString("\n\n") diff --git a/cmd/spec_picker_test.go b/cmd/spec_picker_test.go index 79e70e45..4a614ce5 100644 --- a/cmd/spec_picker_test.go +++ b/cmd/spec_picker_test.go @@ -3,8 +3,8 @@ package cmd import ( "testing" + tea "charm.land/bubbletea/v2" "github.com/GrayCodeAI/hawk/internal/engine" - tea "github.com/charmbracelet/bubbletea" ) func TestSpecPicker_HasSevenActions(t *testing.T) { @@ -35,7 +35,7 @@ func TestSpecPicker_EnterSelectsAndCloses(t *testing.T) { sp := NewSpecPicker(80) sp.Open(engine.SpecStageNone) - chosen, handled := sp.Update(tea.KeyMsg{Type: tea.KeyDown}) + chosen, handled := sp.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if !handled || chosen != nil { t.Fatalf("KeyDown should navigate, not select: chosen=%v handled=%v", chosen, handled) } @@ -43,7 +43,7 @@ func TestSpecPicker_EnterSelectsAndCloses(t *testing.T) { t.Fatalf("expected selection to move to Status, got %v", sp.Selected().Action) } - chosen, handled = sp.Update(tea.KeyMsg{Type: tea.KeyEnter}) + chosen, handled = sp.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if !handled { t.Fatal("expected Enter to be handled") } @@ -59,7 +59,7 @@ func TestSpecPicker_EscClosesWithoutSelecting(t *testing.T) { sp := NewSpecPicker(80) sp.Open(engine.SpecStageNone) - chosen, handled := sp.Update(tea.KeyMsg{Type: tea.KeyEsc}) + chosen, handled := sp.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) if !handled || chosen != nil { t.Fatalf("expected Esc to close without a selection, got chosen=%v", chosen) } @@ -73,7 +73,7 @@ func TestSpecPicker_FilterByName(t *testing.T) { sp.Open(engine.SpecStageNone) for _, r := range "reset" { - sp.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + sp.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) } if len(sp.filtered) != 1 || sp.filtered[0].Action != specActionReset { t.Fatalf("expected filter 'reset' to match only Reset action, got %d entries: %+v", len(sp.filtered), sp.filtered) diff --git a/cmd/statusbar.go b/cmd/statusbar.go index b120071f..4253a535 100644 --- a/cmd/statusbar.go +++ b/cmd/statusbar.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" "golang.org/x/text/language" "golang.org/x/text/message" diff --git a/cmd/statusbar_test.go b/cmd/statusbar_test.go index a1704df5..67ce22fa 100644 --- a/cmd/statusbar_test.go +++ b/cmd/statusbar_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" + lipgloss "charm.land/lipgloss/v2" "github.com/GrayCodeAI/hawk/internal/engine" - "github.com/charmbracelet/lipgloss" ) func TestRenderStatusBar_SignatureExists(t *testing.T) { diff --git a/cmd/theme.go b/cmd/theme.go index 3d20c223..82406955 100644 --- a/cmd/theme.go +++ b/cmd/theme.go @@ -21,7 +21,8 @@ package cmd // 10. Icons & glyphs import ( - "github.com/charmbracelet/lipgloss" + lipgloss "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" internaltheme "github.com/GrayCodeAI/hawk/internal/theme" ) @@ -125,17 +126,17 @@ var cwdBlue = lipgloss.Color("#75B1E2") // lipgloss selects the variant from the detected terminal background. // textPrimary — body text, primary prose. -var textPrimary = lipgloss.AdaptiveColor{Light: "#1A1A1A", Dark: "#F0F0F0"} +var textPrimary = compat.AdaptiveColor{Light: lipgloss.Color("#1A1A1A"), Dark: lipgloss.Color("#F0F0F0")} // textMuted — secondary/muted prose. -var textMuted = lipgloss.AdaptiveColor{Light: "#6B6B6B", Dark: "#9E9E9E"} +var textMuted = compat.AdaptiveColor{Light: lipgloss.Color("#6B6B6B"), Dark: lipgloss.Color("#9E9E9E")} -// textPlaceholder — input field placeholder. +// textPlaceholder — input field placeholder (color only). var textPlaceholder = lipgloss.Color("#7A7A7A") // textDisabled — disabled, idle, slash menu (non-selected), blockquote, // markdown HR. -var textDisabled = lipgloss.AdaptiveColor{Light: "#A0A0A0", Dark: "#666666"} +var textDisabled = compat.AdaptiveColor{Light: lipgloss.Color("#A0A0A0"), Dark: lipgloss.Color("#666666")} // textWhite — bright text (permission body, code foreground). var textWhite = lipgloss.Color("#FFFFFF") @@ -145,7 +146,7 @@ var textWhite = lipgloss.Color("#FFFFFF") // --------------------------------------------------------------------------- // borderDim — input/panel/divider border. -var borderDim = lipgloss.AdaptiveColor{Light: "#C6C6C6", Dark: "#555555"} +var borderDim = compat.AdaptiveColor{Light: lipgloss.Color("#C6C6C6"), Dark: lipgloss.Color("#555555")} // bgCode — code block background. var bgCode = lipgloss.Color("#2A2A3A") @@ -263,17 +264,16 @@ func ApplyTheme(name string) { cwdBlue = lipgloss.Color(p.Blue) // 7. Text hierarchy. - textPrimary = lipgloss.AdaptiveColor{Light: "#1A1A1A", Dark: p.Ink} - textMuted = lipgloss.AdaptiveColor{Light: "#6B6B6B", Dark: p.Muted} + textPrimary = compat.AdaptiveColor{Light: lipgloss.Color("#1A1A1A"), Dark: lipgloss.Color(p.Ink)} + textMuted = compat.AdaptiveColor{Light: lipgloss.Color("#6B6B6B"), Dark: lipgloss.Color(p.Muted)} textPlaceholder = lipgloss.Color(p.Faint) - textDisabled = lipgloss.AdaptiveColor{Light: "#A0A0A0", Dark: p.Faintest} + textDisabled = compat.AdaptiveColor{Light: lipgloss.Color("#A0A0A0"), Dark: lipgloss.Color(p.Faintest)} // 8. Structure. - borderDim = lipgloss.AdaptiveColor{Light: "#C6C6C6", Dark: p.Line2} + borderDim = compat.AdaptiveColor{Light: lipgloss.Color("#C6C6C6"), Dark: lipgloss.Color(p.Line2)} bgCode = lipgloss.Color(p.Panel) - // 9. Update dark-background flag so AdaptiveColors resolve correctly. - lipgloss.SetHasDarkBackground(entry.IsDark) + // 9. Dark-background flag is now handled via LightDark in v2; no setter needed. // 10. Rebuild package-level styles that snapshotted the old colors at // init time. Without this, markdown/chat/agent-grid styles keep the diff --git a/cmd/theme_adaptive_test.go b/cmd/theme_adaptive_test.go index 52b09e8a..362d2792 100644 --- a/cmd/theme_adaptive_test.go +++ b/cmd/theme_adaptive_test.go @@ -1,9 +1,11 @@ package cmd import ( + "fmt" + "image/color" "testing" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2/compat" ) // TestAdaptiveNeutralsPreserveDarkAppearance locks the Dark variant of each @@ -13,7 +15,7 @@ import ( func TestAdaptiveNeutralsPreserveDarkAppearance(t *testing.T) { cases := []struct { name string - color lipgloss.AdaptiveColor + color compat.AdaptiveColor wantDark string }{ {"textPrimary", textPrimary, "#F0F0F0"}, @@ -22,14 +24,20 @@ func TestAdaptiveNeutralsPreserveDarkAppearance(t *testing.T) { {"borderDim", borderDim, "#555555"}, } for _, c := range cases { - if c.color.Dark != c.wantDark { + want := colorHex(c.color.Dark) + if want != c.wantDark { t.Errorf("%s: Dark = %q, want %q (dark-mode appearance must not change)", c.name, c.color.Dark, c.wantDark) } - if c.color.Light == "" { + if c.color.Light == nil { t.Errorf("%s: Light variant is empty; light terminals need a legible ink", c.name) } - if c.color.Light == c.color.Dark { + if fmt.Sprint(c.color.Light) == fmt.Sprint(c.color.Dark) { t.Errorf("%s: Light == Dark (%q); adaptive conversion had no effect", c.name, c.color.Light) } } } + +func colorHex(c color.Color) string { + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02X%02X%02X", r>>8, g>>8, b>>8) +} diff --git a/cmd/theme_picker.go b/cmd/theme_picker.go index 63bf35b9..ff90cc5e 100644 --- a/cmd/theme_picker.go +++ b/cmd/theme_picker.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" internaltheme "github.com/GrayCodeAI/hawk/internal/theme" ) @@ -102,10 +102,17 @@ func (tp *ThemePicker) Update(msg tea.KeyMsg) (*ThemeChoice, bool) { return nil, false } - switch msg.Type { - case tea.KeyEsc, tea.KeyCtrlC: + switch key := msg.Key(); key.Code { + case tea.KeyEsc: tp.Close() return nil, true + case 'c': + // Ctrl+C closes the picker; other C keys fall through to text input. + if key.Mod == tea.ModCtrl { + tp.Close() + return nil, true + } + return nil, false case tea.KeyEnter: sel := tp.Selected() tp.Close() @@ -132,9 +139,9 @@ func (tp *ThemePicker) Update(msg tea.KeyMsg) (*ThemeChoice, bool) { } // View renders the theme picker overlay. -func (tp *ThemePicker) View() string { +func (tp *ThemePicker) View() tea.View { if !tp.open { - return "" + return tea.NewView("") } titleStyle := lipgloss.NewStyle(). @@ -162,5 +169,7 @@ func (tp *ThemePicker) View() string { } } - return b.String() + v := tea.View{Content: b.String()} + v.AltScreen = true + return v } diff --git a/cmd/vim.go b/cmd/vim.go index 41a7d054..07297e85 100644 --- a/cmd/vim.go +++ b/cmd/vim.go @@ -4,7 +4,7 @@ import ( "strings" "unicode" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) // VimMode represents the current vim mode. @@ -120,7 +120,7 @@ func (v *VimState) HandleKey(msg tea.KeyMsg, text string, cursor int) (string, i } func (v *VimState) handleInsertMode(msg tea.KeyMsg, text string, cursor int) (string, int, bool) { - if msg.Type == tea.KeyEscape { + if msg.String() == "escape" || msg.String() == "esc" { v.Mode = VimNormal if cursor > 0 { cursor-- @@ -352,8 +352,8 @@ func (v *VimState) handleNormalMode(msg tea.KeyMsg, text string, cursor int) (st if v.Persistent.LastFind != 0 { v.Command.FindType = v.Persistent.LastFindType v.Command.Type = CmdFind - fakeKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{rune(v.Persistent.LastFind)}} - return v.handleFindChar(fakeKey, text, cursor) + fakeKey := tea.KeyPressMsg{Text: string(rune(v.Persistent.LastFind))} + return v.handleFindChar(tea.KeyMsg(fakeKey), text, cursor) } return text, cursor, true @@ -373,8 +373,8 @@ func (v *VimState) handleNormalMode(msg tea.KeyMsg, text string, cursor int) (st } v.Command.FindType = opposite v.Command.Type = CmdFind - fakeKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{rune(v.Persistent.LastFind)}} - return v.handleFindChar(fakeKey, text, cursor) + fakeKey := tea.KeyPressMsg{Text: string(rune(v.Persistent.LastFind))} + return v.handleFindChar(tea.KeyMsg(fakeKey), text, cursor) } return text, cursor, true diff --git a/cmd/vim_test.go b/cmd/vim_test.go index a831f61d..73a29b1a 100644 --- a/cmd/vim_test.go +++ b/cmd/vim_test.go @@ -3,22 +3,22 @@ package cmd import ( "testing" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" ) func key(s string) tea.KeyMsg { if len(s) == 1 { - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + return tea.KeyPressMsg{Code: rune(s[0]), Text: s} } switch s { case "esc": - return tea.KeyMsg{Type: tea.KeyEscape} + return tea.KeyPressMsg{Code: tea.KeyEsc} case "left": - return tea.KeyMsg{Type: tea.KeyLeft} + return tea.KeyPressMsg{Code: tea.KeyLeft} case "right": - return tea.KeyMsg{Type: tea.KeyRight} + return tea.KeyPressMsg{Code: tea.KeyRight} } - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + return tea.KeyPressMsg{Code: []rune(s)[0], Text: s} } func TestVimState_InsertToNormal(t *testing.T) { diff --git a/docs/architecture/adr/ADR-0002-hawk-cloud-control-plane.md b/docs/architecture/adr/ADR-0002-hawk-cloud-control-plane.md new file mode 100644 index 00000000..e81fb5f4 --- /dev/null +++ b/docs/architecture/adr/ADR-0002-hawk-cloud-control-plane.md @@ -0,0 +1,48 @@ +# ADR-0002: Hawk Cloud is the Hawk ecosystem control plane + +- Status: Accepted +- Date: 2026-07-10 +- Owners: Hawk maintainers + +## Decision + +GrayCode Core is the identity and web-platform layer. Hawk Cloud is the +Hawk-specific control plane and source of truth for organizations, teams, +projects, devices, sessions, usage, policies, entitlements, billing, and audit +records across Hawk, Eyrie, Tok, Yaad, Trace, Sight, and Inspect. + +Hawk remains local-first. It may send aggregate usage to Hawk Cloud only after +the user explicitly authenticates and connects a device. Engines never import +GrayCode or Hawk Cloud code; they report through Hawk's isolated cloud adapter. + +GrayCode's backend is a browser-facing BFF. It validates GrayCode identity and +forwards scoped requests to Hawk Cloud. Hawk Cloud does not duplicate passwords +or browser sessions. GrayCode-to-Hawk Cloud requests use a signed service +identity; the principal header is not trusted by itself. + +## Authentication + +Interactive CLI authentication uses a browser device-authorization flow: + +```text +hawk login + -> GrayCode browser authentication + -> user approves organization/project + -> Hawk Cloud issues one project-scoped device token + -> Hawk stores it in the OS secure store +``` + +Automation uses project-scoped service accounts or API keys. Human credentials +must not be shared with CI systems. + +## Consequences + +- GrayCode's local usage tables may remain for GrayCode-only product activity, + but Hawk usage and Hawk billing are owned by Hawk Cloud. +- Usage events are versioned and idempotent, with optional input/output/cache/ + reasoning token dimensions and session attribution. +- Full session traces and large exports are separate from the D1 control-plane + tables and must be opt-in, redacted, encrypted, and retention-controlled. +- The older telemetry-edge ADR remains valid for the no-compile-time-dependency + rule; Hawk Cloud is now the preferred destination for Hawk-specific cloud + accounting. diff --git a/docs/architecture/ecosystem-architecture.md b/docs/architecture/ecosystem-architecture.md new file mode 100644 index 00000000..1d6a95ac --- /dev/null +++ b/docs/architecture/ecosystem-architecture.md @@ -0,0 +1,99 @@ +# Hawk ecosystem architecture + +This document records the target repository and dependency architecture for the +fourteen repositories audited in July 2026. It distinguishes source integration, +published dependencies, runtime calls, and product ownership; those relationships +must not be treated as interchangeable. + +## Repository boundary + +| Repository | Visibility target | Responsibility | Dependency rule | +| --- | --- | --- | --- | +| `hawk` | Public | CLI, local daemon, orchestration, policy and engine composition | Integration root; pins the seven engine repositories as submodules and published Go modules | +| `eyrie` | Public | Model/provider catalog, routing and client runtime | Library consumed by Hawk; no Hawk import | +| `hawk-core-contracts` | Public | Shared Go domain contracts | Leaf contract module; must not import product implementations | +| `tok` | Public | Token analysis and optimization engine | Independently releasable library and MCP server | +| `trace` | Public | Trace and observability engine | Embeds only Hawk's supported CLI root boundary when built with Hawk | +| `yaad` | Public | Memory and knowledge engine | Independently releasable library; SSE is implemented, gRPC remains planned | +| `sight` | Public | Source/diff review engine | Source-review boundary; does not own deployed-target inspection | +| `inspect` | Public | Live/deployed target inspection | Runtime inspection boundary; does not own source diff review | +| `hawk-mcpkit` | Public | Shared MCP primitives | Intentionally narrow reuse by compatible MCP servers, currently Sight and Inspect | +| `hawk-sdk-go` | Public | Go client SDK for the Hawk daemon API | Contract-checked against `hawk/api/openapi.yaml` | +| `hawk-sdk-python` | Public | Python client SDK for the Hawk daemon API | Contract-checked against `hawk/api/openapi.yaml` | +| `hawk-community-skills` | Public | Community-authored skill definitions and validation | Content/extension plane; no privileged product secrets | +| `hawk-cloud` | Private | Hosted control plane, verified usage ledger, credits, billing, enterprise policy and delivery metadata | Sole authority for Hawk billing/metering data; exposes a versioned OpenAPI contract | +| `graycode-core` | Private | GrayCode product UI and backend-for-frontend | Calls Hawk Cloud through a service binding and contract-checked adapter; never owns the Hawk ledger | + +The visibility column is the recommended governance target. Package-level +`private` flags prevent accidental publication but do not configure GitHub +repository visibility; repository settings must be verified separately. + +## Current versus target + +| Concern | Audited current state | Target state | +| --- | --- | --- | +| Local engine integration | Seven Hawk submodules existed, but module and checkout versions could drift | Submodule Gitlinks and `go.mod` pseudo-versions identify the same public commits and CI verifies both modes | +| Public releases | Release setup could fall back to a branch head | A missing pinned commit fails the release; no branch fallback | +| Hawk daemon API | Hawk, both SDKs and their snapshots could drift | `hawk/api/openapi.yaml` is authoritative; SDK CI compares the exact contract and tests supported operations | +| Hosted API | Cloud routes and GrayCode calls were manually coupled | `hawk-cloud/contracts/openapi.yaml` is authoritative; route and BFF reference tests reject undocumented paths | +| Billing trust | Client-reported estimated cost could enter billing calculations | Clients report token dimensions and optional estimates; Hawk Cloud prices them with a versioned server catalog and bills only verified cost | +| Product data | GrayCode contained similarly named usage data | GrayCode telemetry remains product-local; Hawk Cloud is the sole authoritative usage, budget, credit and billing ledger | +| Cloud deployment | Environment bindings and generated types could drift | JSONC configuration, generated binding types, observability and dry-run production bundles are checked in CI | +| Authorization | Coverage varied by route and some lookups preceded authentication | Authentication occurs before protected resource lookup; every route family has positive/negative matrix coverage | +| Module boundaries | Large route files and undocumented engine overlap obscured ownership | Domain-specific BFF/cloud routes, Hawk import guards and explicit Sight/Inspect/Trace/Yaad boundaries | + +## Dependency and runtime topology + +```text +community skills ────────────────> Hawk extension/content plane + +hawk-sdk-go ───────┐ +hawk-sdk-python ───┴─ HTTP ─────> Hawk local daemon + │ +hawk (integration root) ──────────────┼─ eyrie + external/ Gitlinks + Go modules ────┼─ core-contracts + ├─ tok + ├─ trace + ├─ yaad + ├─ sight + └─ inspect + └─ compatible MCP helpers: hawk-mcpkit + +graycode-core UI -> GrayCode BFF -> Cloudflare service binding -> hawk-cloud + ├─ D1 ledger/control data + ├─ Queue usage events + └─ R2 exports +``` + +Source-level arrows are allowed only in the displayed direction. Hawk Cloud +must not import GrayCode product code, engines must not import Hawk orchestration, +and public repositories must not depend on private repositories. + +## Submodule policy + +The seven `hawk/external/*` submodules are required for atomic integration, +cross-repository development, reproducible CI and coordinated Hawk releases: +`eyrie`, `hawk-core-contracts`, `inspect`, `sight`, `tok`, `trace`, and `yaad`. +They do not replace module releases. Downstream Go consumers use published module +versions; Hawk CI tests both the pinned workspace and `GOWORK=off` public-module +graph. `hawk-mcpkit`, SDKs, community skills, Hawk Cloud and GrayCode are not Hawk +submodules because they are not linked into the local engine integration graph. + +## Repository count decision + +No fifteenth product repository is required now. Shared contracts already have +appropriate homes: daemon contracts in `hawk`, hosted contracts in `hawk-cloud`, +and shared Go types in `hawk-core-contracts`. Create another repository only when +an independently owned artifact has a distinct release cadence and at least two +real consumers; do not create an infrastructure, documentation, or generic +contracts repository merely to move files across a repository boundary. + +## Architecture invariants + +1. Public code never requires access to either private repository to build or test. +2. Every runtime route is represented by its owning OpenAPI document. +3. Client-controlled monetary values never affect budgets, credits, invoices or the verified ledger. +4. Protected resource existence is not disclosed before authentication and authorization. +5. Every submodule commit used by Hawk is publicly reachable and matches the corresponding module version. +6. Production Cloudflare bindings are explicit, typed and verified with a dry-run bundle. +7. Cross-repository contract drift and forbidden imports fail CI. diff --git a/docs/ecosystem-remediation-plan.md b/docs/ecosystem-remediation-plan.md new file mode 100644 index 00000000..b6682c48 --- /dev/null +++ b/docs/ecosystem-remediation-plan.md @@ -0,0 +1,112 @@ +# Ecosystem Remediation Plan — Post-Audit Findings + +This document captures the improvement recommendations from the 2026-07-11 +full-ecosystem audit that require significant refactoring and are documented +here for scheduled execution rather than immediate implementation. + +## 1. charmbracelet v1/v2 Dependency Duplication (hawk + trace) + +**Status:** ✅ Done (2026-07-11) +**Impact:** High — contributed ~20-30MB to hawk binary size + +### Outcome + +- Hawk and Trace imports migrated to `charm.land/*/v2` (`bubbles`, `bubbletea`, + `lipgloss`, `huh`, `glamour`). +- Direct `github.com/charmbracelet/{bubbles,bubbletea,lipgloss}` requires removed + from hawk/trace `go.mod`. +- Binary size gate tightened: **110MB → 80MB** (`make size-check`). +- Verified build: hawk binary **~75 MB** (under gate) after migration. + +Commits (hawk): `2890c74` (migrate), `ccfa286` (API fixups), `7a42c1e` (size gate). + +### Residual notes + +- `github.com/charmbracelet/x/*` and related transitive packages may still appear + as indirect deps of the v2 stack — that is expected and not dual v1 TUI stack. +- Manual QA checklist (REPL, `/config`, `/autonomy`, trace setup UIs) remains + recommended when bumping charm majors. + +## 2. yaad TUI Dependencies in Library Module + +**Status:** ✅ Done (2026-07-11) — Option A +**Impact:** Medium — removed Bubble Tea stack from the core yaad module graph + +### Outcome + +- Demo TUI moved to nested module `cmd/yaad-tui` with its own `go.mod` + (`replace github.com/GrayCodeAI/yaad => ../..`). +- Core `github.com/GrayCodeAI/yaad` no longer requires + `charmbracelet/{bubbles,bubbletea,lipgloss}`. +- Hawk embeds only the library packages (`engine`, `storage`, `graph`, …), so + the default hawk binary does not pull the demo TUI module. +- Verify: `cd yaad && go test ./...`; `cd yaad/cmd/yaad-tui && go test ./...`. + +### Residual notes + +- `hawk/external/yaad` + `go.mod` pseudo-version updated to `b7ee281` (local). + **Push `yaad` to origin before** relying on `GOWORK=off` / submodule-release + parity (proxy must see the commit for sumdb download). + +## 3. tok Viper vs. Direct TOML Config + +**Status:** Documented, requires significant rewrite +**Impact:** Medium — viper pulls 10+ indirect deps (afero, cast, mapstructure, etc.) + +### Problem + +`tok/internal/config/config.go` uses `spf13/viper` for: +- Config file search paths +- Environment variable binding (`TOK_*` prefix, aliases) +- `mapstructure` unmarshaling into `Config` struct + +But `tok` already imports `BurntSushi/toml` for direct TOML encode/decode. + +### Assessment + +Viper is used extensively in `tok`: +- `v.AutomaticEnv()` + `v.SetEnvPrefix("TOK")` handles env var discovery +- `v.ReadInConfig()` searches multiple config paths +- `v.Unmarshal(cfg)` populates the struct with mapstructure tags +- `bindEnvAliases()` maps 20+ non-standard env var names + +Replacing viper with direct `os.Getenv` + `toml.DecodeFile` would require +rewriting the env alias registry and manual config path resolution. This is +~200 lines of non-trivial logic. + +### Recommendation + +Defer until tok binary size becomes a priority. The dependency cost is +acceptable for the convenience gained. If addressed, target the `tok` v1.0 +release milestone. + +## 4. hawk-community-skills registry.json Build-Time Generation + +**Status:** Documented, requires CI pipeline change +**Impact:** Medium — 4.3MB JSON committed to git, grows unbounded + +### Problem + +`registry.json` is a 4.3MB generated file committed to git. It is updated by +`python tools/update_registry.py` after skill additions/removals. This creates +large diffs and bloats the repo. + +### Remediation Steps + +1. **Remove `registry.json` from git:** `git rm registry.json` and add to + `.gitignore`. +2. **Generate at build/publish time:** Run `python tools/update_registry.py` + in CI before publishing artifacts. +3. **Runtime fetch:** Have `hawk` fetch the registry from a CDN or GitHub + raw URL rather than embedding it. + +### Risk + +If `registry.json` is read offline by hawk, removing it requires adding a +fetch-or-cache mechanism. Verify how hawk consumes the registry before +removing from git. + +--- + +*Audit date: 2026-07-11* +*Auditor: Droid (Factory AI)* diff --git a/docs/plans/PR_BODY.md b/docs/plans/PR_BODY.md new file mode 100644 index 00000000..0633ef45 --- /dev/null +++ b/docs/plans/PR_BODY.md @@ -0,0 +1,52 @@ +## Summary + +Ship the post-audit hardening batch: Charm v2-only TUI stack, tighter binary +size gate, Hawk Cloud CLI integration, engine pin hygiene, release Gitlink +strictness, and Yaad re-pin after the demo TUI nested-module split. + +## Why + +- Dual Charm v1/v2 inflated the binary and dependency graph +- Releases must never silently fall back to engine `main` when a Gitlink is + missing or unreachable +- Yaad’s library graph must stay free of Bubble Tea so Hawk does not pay for a + demo TUI + +## Highlights + +| Area | Change | +|------|--------| +| TUI | Migrate to `charm.land/*/v2`; fix remaining API incompatibilities | +| Size | `make size-check` / CI threshold **110MB → 80MB** (~75MB verified) | +| Cloud | CLI login, usage reporting, delivery-context wiring | +| Pins | Submodule updates + `scripts/check-submodule-release-parity.sh` | +| Layers | `scripts/check-internal-layer-imports.sh` | +| Release | `checkout-eyrie` fails closed without Gitlinks; release job verifies pins | +| Yaad | Re-pin to nested-module TUI split (`b7ee281`) | +| Docs | Remediation plans updated with acceptance evidence | + +## Depends on + +1. Merge/push **yaad** PR first (`b7ee281` must be reachable on origin) +2. Then this PR (or push) so public-module CI can resolve the new pseudo-version + +## Test plan + +- [x] `make size-check` → ~75 MB +- [x] `go list -m all` has no `charmbracelet/{bubbles,bubbletea,lipgloss}` v1 stack +- [x] `go test ./internal/intelligence/memory/ ./internal/platform/cloud/ ./cmd` +- [x] `make internal-layers-guard` +- [ ] CI green after yaad is published (`public-modules`, `submodule-release-parity`) +- [ ] Manual smoke: REPL, `/config`, `/autonomy` pickers (Charm v2) + +## Rollout + +```bash +# 1) yaad +cd yaad && git push origin main # or open PR from docs/PR_BODY.md + +# 2) hawk +cd hawk && git push origin main # or open PR from this body +# if needed after publish: +go mod tidy && git add go.sum && git commit -m "chore: refresh go.sum after yaad publish" +``` diff --git a/docs/plans/ecosystem-architecture-remediation.md b/docs/plans/ecosystem-architecture-remediation.md new file mode 100644 index 00000000..3d05c760 --- /dev/null +++ b/docs/plans/ecosystem-architecture-remediation.md @@ -0,0 +1,133 @@ +# Ecosystem architecture remediation plan + +This plan is the executable follow-up to the July 2026 source-level audit of +the fourteen Hawk ecosystem repositories. An item is complete only when its +acceptance evidence passes; documentation or intent alone is not sufficient. + +**Last evidence pass:** 2026-07-11 + +## P0 — release graph and submodules + +- [x] Pin all seven `external/` submodules to the selected, publicly reachable + ecosystem snapshot. *(local pins present; re-pin after each engine push)* +- [ ] Make the versions in `go.mod` resolve to API-compatible commits from the + same snapshot. *(blocked until new engine commits — especially yaad + `b7ee281` — are published; `go.work` replace is authoritative for + integration builds)* +- [x] Add CI for both supported dependency modes: + - pinned integration: `go test ./...` with `go.work`; + - public modules: `GOWORK=off go test ./...`. + Evidence: `.github/workflows/ci.yml` jobs `module`, `public-modules`. +- [x] Prevent release workflows from falling back from a missing Gitlink commit + to a branch head. + Evidence: `.github/actions/checkout-eyrie` defaults `allow_branch_fallback=false` + and fails on missing/unreachable pins; `release.yml` verifies Gitlink == + checked-out HEAD before goreleaser. +- [x] Add a release-parity guard that reports whether each Gitlink is represented + by the module version in `go.mod`. + Evidence: `scripts/check-submodule-release-parity.sh` + Makefile target + `submodule-release-parity` + CI job. + +Acceptance: + +```sh +git submodule status +make boundaries +go test ./... +GOWORK=off go test ./... +``` + +## P1 — public contracts + +- [x] Keep `hawk/api/openapi.yaml` as the sole Hawk daemon server contract. + Evidence: SDK snapshots under `hawk-sdk-*/api/openapi.yaml` + coverage tests + that treat the daemon contract as authoritative. +- [x] Make both SDK repositories verify their implemented methods and JSON + models against that contract. + Evidence: + - Go: `hawk-sdk-go/internal/spec/openapi_coverage_test.go` + - Python: `hawk-sdk-python/tests/test_openapi_coverage.py` +- [x] Cover `/v1/ready`, `/v1/review`, and `/v1/review/status`, or explicitly + identify them as intentionally unsupported in SDK capability metadata. + Evidence: `SUPPORTED_ENDPOINTS.md` in both SDKs + coverage decision maps. +- [x] Add route/operation parity for `hawk-cloud/contracts/openapi.yaml`. + Evidence: `hawk-cloud/test/openapi-parity.test.ts` +- [x] Replace GrayCode's untyped/manual Hawk Cloud transport surface with a + contract-checked client boundary. + Evidence: `graycode-core/apps/backend/test/hawk-cloud-contract.test.ts` + (BFF may only reference paths present in the cloud OpenAPI snapshot). +- [ ] Add OpenAPI breaking-change checks to CI. + *(still open — no oasdiff/spectral gate wired yet)* + +Acceptance: daemon and cloud route-parity tests pass, SDK contract tests pass, +and a deliberate undocumented route causes the relevant test to fail. + +## P1 — Hawk Cloud correctness and security + +- [x] Separate client-reported cost from server-calculated ledger cost. + Evidence: `hawk-cloud/src/domain/metering.ts` (`reportedCostMicros` vs + `costMicros`) + `test/metering.test.ts` (“ignores forged client cost”). +- [x] Version the pricing input used by server-side metering. + Evidence: `pricingVersion` on `MeteringResult` + catalog `version` field. +- [x] Ensure billing and budgets use only the verified ledger value. + Evidence: usage/billing routes aggregate `usage_ledger.cost_micros`, not + client estimates. +- [x] Add positive and negative authorization tests for every route family. + Evidence: `hawk-cloud/test/authorization-matrix.test.ts` +- [x] Split route handlers so HTTP, policy, service, and persistence concerns are + independently testable. + Evidence: `src/domain/*` + `src/routes/*` layout + domain unit tests. +- [x] Add OSS metadata (`LICENSE`) and document repository/release setup. + Evidence: `hawk-cloud/LICENSE`, `hawk-cloud/README.md` + +Acceptance: cloud tests prove that forged client cost cannot alter billable +cost, every route is present in OpenAPI, authorization matrices pass, typecheck +passes, and the production build succeeds. + +## P2 — maintainability and ownership + +- [ ] Split GrayCode's organization BFF by organization, project, access, + billing, enterprise, analytics, and delivery domains. + *(still open — large refactor; contract tests exist but file split pending)* +- [x] Document `graycode-core.usage_logs` as GrayCode-platform data and prohibit + it from becoming an authoritative Hawk ledger. + Evidence: `graycode-core/README.md` (usage_logs paragraph). +- [x] Add import guards for Hawk delivery, application, domain/ports, and adapter + layers while migration proceeds. + Evidence: `scripts/check-internal-layer-imports.sh` + `make internal-layers-guard`. +- [x] Define the embedding boundary for Trace and the target boundary between + Sight source review and Inspect deployed-target inspection. + Evidence: engine READMEs + `docs/architecture/ecosystem-architecture.md`. +- [x] Decide and document the intentionally narrow `hawk-mcpkit` adoption scope; + do not force engines with different MCP server requirements into it. + Evidence: ecosystem architecture table (Sight/Inspect only). +- [x] Reconcile Yaad's implemented, experimental, and planned interface docs. + Evidence: yaad TUI split to `cmd/yaad-tui` nested module; core library has no + Bubble Tea deps (2026-07-11). + +Acceptance: boundary checks and repository documentation agree with actual +imports and implemented interfaces; all repository test suites pass. + +## Verification pass 1 + +- [x] Hawk focused tests (memory, cloud client, cmd) after charm/yaad work +- [x] Yaad full `go test ./...` + nested `cmd/yaad-tui` tests +- [x] Hawk Cloud vitest results present (`metering`, `openapi-parity`, + `authorization-matrix`, …) +- [ ] Full matrix across all fourteen repos (optional CI babysit) + +## Verification pass 2 + +- [ ] Repeat builds/tests with clean workspace-local caches +- [ ] Repeat Hawk with `GOWORK=off` after yaad is published +- [ ] Re-run submodule/release parity after engine pushes +- [x] Audit checkboxes against command or source evidence (this pass) + +## Still open (prioritized) + +1. **Publish engines then re-pin** — push `yaad` (and any other local-only + engine SHAs), refresh hawk `go.sum`, green `submodule-release-parity`. +2. **OpenAPI breaking-change CI** — add oasdiff (or equivalent) on + `hawk/api/openapi.yaml` and `hawk-cloud/contracts/openapi.yaml`. +3. **GrayCode BFF domain split** — split large organization route modules by + domain for maintainability (behavior already contract-tested). diff --git a/examples/README.md b/examples/README.md index 2ba5d43d..3fa57f82 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,6 +43,13 @@ hawk analyze --depth full hawk fix --auto ``` +### Report CI delivery context + +Copy [hawk-delivery-context.yml](github/hawk-delivery-context.yml) to your +repository to report GitHub Actions runs to Hawk Cloud. Create a dedicated, +revocable device token for CI and keep its endpoint, device ID, project ID, and +token in GitHub Actions secrets. + ## MCP Integration Hawk can use MCP servers for extended capabilities: diff --git a/examples/github/hawk-delivery-context.yml b/examples/github/hawk-delivery-context.yml new file mode 100644 index 00000000..343218d5 --- /dev/null +++ b/examples/github/hawk-delivery-context.yml @@ -0,0 +1,47 @@ +# Hawk Delivery Context +# +# Copy to .github/workflows/hawk-delivery-context.yml. This workflow assumes +# `hawk` is available on PATH (install it using your team's normal release +# mechanism before this reporting step). +# +# Create a dedicated, revocable Hawk Cloud device token for CI. Do not reuse a +# developer's local device token. Store all three values as repository secrets. +name: Hawk Delivery Context + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + report-delivery: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # Install Hawk here using the release package your organization pins. + # The remaining steps are intentionally independent of the installer. + - name: Connect CI device to Hawk Cloud + run: | + hawk cloud connect \ + --endpoint "$HAWK_CLOUD_URL" \ + --device-id "$HAWK_CLOUD_DEVICE_ID" \ + --project-id "$HAWK_CLOUD_PROJECT_ID" \ + --token "$HAWK_CLOUD_DEVICE_TOKEN" + env: + HAWK_CLOUD_URL: ${{ secrets.HAWK_CLOUD_URL }} + HAWK_CLOUD_DEVICE_ID: ${{ secrets.HAWK_CLOUD_DEVICE_ID }} + HAWK_CLOUD_PROJECT_ID: ${{ secrets.HAWK_CLOUD_PROJECT_ID }} + HAWK_CLOUD_DEVICE_TOKEN: ${{ secrets.HAWK_CLOUD_DEVICE_TOKEN }} + + - name: Report successful CI run + if: ${{ success() }} + run: hawk cloud context --ci-status succeeded + + - name: Report failed CI run + if: ${{ failure() }} + run: hawk cloud context --ci-status failed diff --git a/external/eyrie b/external/eyrie index a4ff5615..22541ca2 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit a4ff561552e2d069ce4edbe6641ef937dd81808f +Subproject commit 22541ca285ff9091195b95ef5a52ba1c6e31d6f3 diff --git a/external/hawk-core-contracts b/external/hawk-core-contracts index e5623022..0f41c8e2 160000 --- a/external/hawk-core-contracts +++ b/external/hawk-core-contracts @@ -1 +1 @@ -Subproject commit e5623022be9e946bbc061f04670576d92d56a72c +Subproject commit 0f41c8e24c0c503c413801e7b6e4cfdf11517282 diff --git a/external/inspect b/external/inspect index 6302596e..16cbc2e0 160000 --- a/external/inspect +++ b/external/inspect @@ -1 +1 @@ -Subproject commit 6302596e13e5973cb6c5c9da92c78bad5e3b8457 +Subproject commit 16cbc2e0b057ad2a72b04f80a67bb0a3ed4b93c9 diff --git a/external/sight b/external/sight index faddabcd..fdb7524c 160000 --- a/external/sight +++ b/external/sight @@ -1 +1 @@ -Subproject commit faddabcd01a3b38ccfd252f5f4d9164e613007af +Subproject commit fdb7524c42799297aceb63ea8b642191232caa9a diff --git a/external/tok b/external/tok index adeaa854..926b971d 160000 --- a/external/tok +++ b/external/tok @@ -1 +1 @@ -Subproject commit adeaa854f482490631d10d6765336713b43ba3c7 +Subproject commit 926b971d1da238518b202700f8697cdb8a8cdeb7 diff --git a/external/trace b/external/trace index 7006b21c..1c17ce77 160000 --- a/external/trace +++ b/external/trace @@ -1 +1 @@ -Subproject commit 7006b21c183892e20a9f97d0568ed9b1436b6250 +Subproject commit 1c17ce779e5d7a562ab6bef468ce8da2855ba82d diff --git a/external/yaad b/external/yaad index 0add8654..2050a49f 160000 --- a/external/yaad +++ b/external/yaad @@ -1 +1 @@ -Subproject commit 0add8654d8bf21ef1b0fba25481c6d865c59c4c8 +Subproject commit 2050a49fa045d4266b91472fd59523b197fe20bf diff --git a/go.mod b/go.mod index 991dff49..b1b6c0fd 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,23 @@ module github.com/GrayCodeAI/hawk -go 1.26.4 +go 1.26.5 + +// The charmbracelet v2 modules (bubbles, bubbletea, lipgloss, glamour, huh) have +// moved their module paths from github.com/charmbracelet/... to charm.land/... +// but their Go import paths still use the old github.com/charmbracelet/... paths. +// The import paths in the Go source have been migrated to charm.land/... to match. require ( - github.com/GrayCodeAI/eyrie v0.1.3 - github.com/GrayCodeAI/hawk-core-contracts v0.1.3 - github.com/GrayCodeAI/inspect v0.1.3 - github.com/GrayCodeAI/sight v0.1.2 - github.com/GrayCodeAI/tok v0.1.2 - github.com/GrayCodeAI/yaad v0.1.3 + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.3 + github.com/GrayCodeAI/eyrie v0.1.4 + github.com/GrayCodeAI/hawk-core-contracts v0.1.4 + github.com/GrayCodeAI/inspect v0.1.4 + github.com/GrayCodeAI/sight v0.1.4 + github.com/GrayCodeAI/tok v0.1.4 + github.com/GrayCodeAI/yaad v0.1.4 github.com/bwmarrin/discordgo v0.28.1 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.7 github.com/fsnotify/fsnotify v1.10.1 github.com/google/uuid v1.6.0 @@ -35,11 +40,8 @@ require ( require ( cel.dev/expr v0.25.2 // indirect - charm.land/bubbles/v2 v2.1.0 // indirect - charm.land/bubbletea/v2 v2.0.7 // indirect charm.land/glamour/v2 v2.0.0 // indirect charm.land/huh/v2 v2.0.3 // indirect - charm.land/lipgloss/v2 v2.0.3 // indirect dario.cat/mergo v1.0.2 // indirect github.com/BobuSumisu/aho-corasick v1.0.3 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect @@ -126,19 +128,17 @@ require ( require ( github.com/BurntSushi/toml v1.6.0 // indirect - github.com/GrayCodeAI/trace v0.1.3 + github.com/GrayCodeAI/trace v0.1.4 github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -146,8 +146,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index ff0c1a0a..2858e37f 100644 --- a/go.sum +++ b/go.sum @@ -16,20 +16,20 @@ github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8 github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GrayCodeAI/eyrie v0.1.3 h1:TskZFeIIBvbsgHOVpuqgtptey/Wl5XkOOwV3ijZrgt8= -github.com/GrayCodeAI/eyrie v0.1.3/go.mod h1:TQhIpvKvepH/hPJBpuSxLkJWCHp94xEYa4+aJ+RmkFM= -github.com/GrayCodeAI/hawk-core-contracts v0.1.3 h1:CHwDv1Kz7nJqfdM2MbPeL1wtI3lKboktwkyZScpm3NY= -github.com/GrayCodeAI/hawk-core-contracts v0.1.3/go.mod h1:J+I2YYTS3hDwf01bmo1ejbMzOTdYxGn9rZa8CME0pRM= -github.com/GrayCodeAI/inspect v0.1.3 h1:8yULaJUa5CjAjZatPz600KMLUp6kTYvJ4AMpyEmH3e4= -github.com/GrayCodeAI/inspect v0.1.3/go.mod h1:1gJUUpeIjRUsKT/n3lzf/DlwIjDJTasienAn4nhUXsQ= -github.com/GrayCodeAI/sight v0.1.2 h1:H+XROc0k1QEL/yWbIPJeUWeolMiqn2Vas8adiIfVFtQ= -github.com/GrayCodeAI/sight v0.1.2/go.mod h1:ntZsgzspqSlIIzz/b23oyE7G57rYI6HHMB7uHqBpSg8= -github.com/GrayCodeAI/tok v0.1.2 h1:OaDpPiFn/ySbraQoM8vQ2t3aGczu0B+wiaHmFq7vv8w= -github.com/GrayCodeAI/tok v0.1.2/go.mod h1:oqA7HXbXuyrZ3+uJC+TKJWmYYPlyShaXGDQpftEJ9OE= -github.com/GrayCodeAI/trace v0.1.3 h1:L0JSa9l8Rm6IoTBy4zsSil2RJqUkmxPvN1VneEg/K9U= -github.com/GrayCodeAI/trace v0.1.3/go.mod h1:PWx2sayLur3EiCdkEXkofmdOBx5OuQp+lWZaYV6X4zg= -github.com/GrayCodeAI/yaad v0.1.3 h1:0O8K0enpYNZdo/NAu3XKpha+XyxExB0Pf47/drFOw4E= -github.com/GrayCodeAI/yaad v0.1.3/go.mod h1:YaUw1k25RNn38J0GXN6BWYePnPvIahTn/KRI7wCv03o= +github.com/GrayCodeAI/eyrie v0.1.4 h1:Y1sEPcfvnmaSKbi4h0tVcV/CE7Bf+IMDwJ0PVQ9f5ik= +github.com/GrayCodeAI/eyrie v0.1.4/go.mod h1:8zw4jj5pvD9/tUqDhRaT4nnvl3Q8Zl2x4mJyEve7Obc= +github.com/GrayCodeAI/hawk-core-contracts v0.1.4 h1:HRcuxvl5RMgqmF0Lt1YwHY84nGcUwXculXtnrE/8nLI= +github.com/GrayCodeAI/hawk-core-contracts v0.1.4/go.mod h1:BXbh68YrCf+s9HVqND5F8DAvl2MnE5NcOwZZZB56HGA= +github.com/GrayCodeAI/inspect v0.1.4 h1:tQAcD9UuHkrqA20CZqdTcBgWKU/lhxxHBh8hlXY3FVw= +github.com/GrayCodeAI/inspect v0.1.4/go.mod h1:MfZT8sWGr6M0G9RJyLEwO+WzzYe4cw0k07kLYEcr1Lk= +github.com/GrayCodeAI/sight v0.1.4 h1:wFfRdbwoI0RIRnaj2H1Ytsed5B2pGBu+1csFRvpuMzA= +github.com/GrayCodeAI/sight v0.1.4/go.mod h1:xuMOpaT5GJ3afu9gVqLOx+LeEWP/n09kvcjYxMKxFsI= +github.com/GrayCodeAI/tok v0.1.4 h1:IGyHutbzS83I4bgvdPChnSPEHV6QwFKxgg9cgDnYaUY= +github.com/GrayCodeAI/tok v0.1.4/go.mod h1:Mme6ckmjVRPddekhCnTC3MQbEHkPFKa5ULZjQOU4B3k= +github.com/GrayCodeAI/trace v0.1.4 h1:gD3mqFzMcr8DRPM+FE1kMf6pjsdgLfnSlsoj/MzWtGA= +github.com/GrayCodeAI/trace v0.1.4/go.mod h1:G9EPYZQKiTl1OHwhXLdpsgLZaS8zesQcd1YfLFIOeH8= +github.com/GrayCodeAI/yaad v0.1.4 h1:dbJ/rOae+648leF+2KprJI02sfWbvtXgb8SD6WKpDf0= +github.com/GrayCodeAI/yaad v0.1.4/go.mod h1:sz0RjliLeW9DLQi5MWEXGWro5K8RyaWCPevIYYW93/k= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= @@ -78,20 +78,12 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa h1:rRT2qwk9xbontVloCXEUIsl1ePz0XFcIWkGi2bvmSTY= github.com/charmbracelet/ultraviolet v0.0.0-20260608091853-35bcb7319efa/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= @@ -142,8 +134,6 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/entireio/auth-go v0.4.0 h1:2Z12fsIKOEoDNMsk77AWDLu45z54rZzs1rBozLF2ddM= github.com/entireio/auth-go v0.4.0/go.mod h1:TGgA/d21dPPNL4yYO+gqU+ZfS1Hcr8dU2303nLcVz4U= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/semgroup v1.3.0 h1:pTEnmcEze/BUf4UmVn9f1ZT1OckkBTNRV9w9k/I2/y4= github.com/fatih/semgroup v1.3.0/go.mod h1:thVp+PGZMO9KJ+k96oNGJo06hWgsKOWxTfYfx5R2VaE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -240,8 +230,6 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mholt/archives v0.1.6-0.20260429171216-ef71b7a32fae h1:J5ek2lGxYgdh5SMMmlNTSKLmS1x2oJQla/V0NaAH7vo= @@ -258,8 +246,6 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4 github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -388,7 +374,6 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/go.work b/go.work index 958f73ce..0ad6535f 100644 --- a/go.work +++ b/go.work @@ -1,4 +1,4 @@ -go 1.26.4 +go 1.26.5 use . diff --git a/internal/engine/lifecycle/lifecycle_adapters.go b/internal/engine/lifecycle/lifecycle_adapters.go index 9d585d1a..d2c39f72 100644 --- a/internal/engine/lifecycle/lifecycle_adapters.go +++ b/internal/engine/lifecycle/lifecycle_adapters.go @@ -1,6 +1,10 @@ package lifecycle -import "github.com/GrayCodeAI/hawk/internal/intelligence/memory" +import ( + "fmt" + + "github.com/GrayCodeAI/hawk/internal/intelligence/memory" +) // EvolvingMemoryAdapter bridges memory.EvolvingMemory to the EvolvingMemoryInterface. type EvolvingMemoryAdapter struct { @@ -35,22 +39,51 @@ func (a *EvolvingMemoryAdapter) Format() string { } // SkillDistillerAdapter bridges memory.SkillDistiller to SkillStoreInterface. -// Skill distillation only builds a prompt for LLM extraction; calling the LLM -// and persisting the distilled skill are not implemented yet. type SkillDistillerAdapter struct { - SD *memory.SkillDistiller + SD *memory.SkillDistiller + Chat func(prompt string) (string, error) + Store func(skill *memory.DistilledSkill) error + Search func(query string) ([]*memory.DistilledSkill, error) } func (a *SkillDistillerAdapter) Distill(goal string, steps []string, outcome string) error { if a.SD == nil { return nil } - // Build the prompt that would be sent to an LLM for skill extraction. - // In a full implementation, this would call the LLM and persist the result. - _ = a.SD.BuildSkillPrompt(goal, steps, nil, outcome) + if a.Chat == nil { + return fmt.Errorf("skill distiller: chat function is not configured") + } + if a.Store == nil { + return fmt.Errorf("skill distiller: store function is not configured") + } + response, err := a.Chat(a.SD.BuildSkillPrompt(goal, steps, nil, outcome)) + if err != nil { + return fmt.Errorf("skill distiller: extract skill: %w", err) + } + skill, err := a.SD.ParseSkill(response) + if err != nil { + return fmt.Errorf("skill distiller: parse skill: %w", err) + } + if err := a.Store(skill); err != nil { + return fmt.Errorf("skill distiller: persist skill: %w", err) + } return nil } -func (a *SkillDistillerAdapter) Retrieve(_ string) []string { - return nil +func (a *SkillDistillerAdapter) Retrieve(query string) []string { + if a == nil || a.Search == nil { + return nil + } + skills, err := a.Search(query) + if err != nil { + return nil + } + out := make([]string, 0, len(skills)) + for _, skill := range skills { + if skill == nil { + continue + } + out = append(out, skill.Name+": "+skill.Description) + } + return out } diff --git a/internal/engine/project/project_analyzer.go b/internal/engine/project/project_analyzer.go index d5ddf8a4..37a35c5c 100644 --- a/internal/engine/project/project_analyzer.go +++ b/internal/engine/project/project_analyzer.go @@ -278,15 +278,15 @@ func (pa *ProjectAnalyzer) detectFramework() string { content := string(data) frameworks := map[string]string{ - "github.com/gin-gonic/gin": "Gin", - "github.com/labstack/echo": "Echo", - "github.com/gorilla/mux": "Gorilla", - "github.com/go-chi/chi": "Chi", - "github.com/gofiber/fiber": "Fiber", - "github.com/spf13/cobra": "Cobra CLI", - "github.com/urfave/cli": "urfave/cli", - "google.golang.org/grpc": "gRPC", - "github.com/charmbracelet/bubbletea": "Bubbletea TUI", + "github.com/gin-gonic/gin": "Gin", + "github.com/labstack/echo": "Echo", + "github.com/gorilla/mux": "Gorilla", + "github.com/go-chi/chi": "Chi", + "github.com/gofiber/fiber": "Fiber", + "github.com/spf13/cobra": "Cobra CLI", + "github.com/urfave/cli": "urfave/cli", + "google.golang.org/grpc": "gRPC", + "charm.land/bubbletea/v2": "Bubbletea TUI", } var detected []string diff --git a/internal/engine/stream_tool_exec.go b/internal/engine/stream_tool_exec.go index b6902eae..64a4939e 100644 --- a/internal/engine/stream_tool_exec.go +++ b/internal/engine/stream_tool_exec.go @@ -321,9 +321,26 @@ func (s *Session) executeSingleToolWithTool(ctx context.Context, tc types.ToolCa toolCtx := tool.WithToolContext(ctx, &tool.ToolContext{ AgentSpawnFn: s.AgentSpawnFn, AskUserFn: s.AskUserFn, - YaadBridge: s.MemorySvc().Yaad(), - SpecSlugGet: func() string { return s.Perm.SpecSlug }, - SpecSlugSet: func(slug string) { s.Perm.SpecSlug = slug }, + CommitMessageChatFn: func(chatCtx context.Context, prompt string) (string, error) { + if s.ChatLLM() == nil { + return "", fmt.Errorf("commit message model is unavailable") + } + resp, err := s.ChatLLM().Chat(chatCtx, []types.EyrieMessage{{Role: "user", Content: prompt}}, types.ChatOptions{ + Provider: s.provider, + Model: s.model, + MaxTokens: 256, + }) + if err != nil { + return "", err + } + if resp == nil { + return "", fmt.Errorf("commit message model returned no response") + } + return resp.Content, nil + }, + YaadBridge: s.MemorySvc().Yaad(), + SpecSlugGet: func() string { return s.Perm.SpecSlug }, + SpecSlugSet: func(slug string) { s.Perm.SpecSlug = slug }, }) if s.Tools().ContainerExecutor() != nil && s.Tools().ContainerExecutor().Running() { toolCtx = tool.WithContainerExecutor(toolCtx, s.Tools().ContainerExecutor()) diff --git a/internal/platform/cloud/client.go b/internal/platform/cloud/client.go new file mode 100644 index 00000000..a622adad --- /dev/null +++ b/internal/platform/cloud/client.go @@ -0,0 +1,201 @@ +// Package cloud contains Hawk's optional, fail-open HTTP integration with Hawk Cloud. +package cloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type Config struct { + Endpoint string + DeviceToken string + HTTPClient *http.Client +} + +type UsageEvent struct { + EventID string `json:"eventId"` + DeviceID string `json:"deviceId"` + ProjectID string `json:"projectId"` + SessionID string `json:"sessionId,omitempty"` + Capability string `json:"capability"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + InputTokens int `json:"inputTokens,omitempty"` + OutputTokens int `json:"outputTokens,omitempty"` + CachedInputTokens int `json:"cachedInputTokens,omitempty"` + ReasoningTokens int `json:"reasoningTokens,omitempty"` + TokensUsed int `json:"tokensUsed"` + TokensSaved int `json:"tokensSaved,omitempty"` + EstimatedCostMicros int `json:"estimatedCostMicros,omitempty"` + DurationMS int `json:"durationMs,omitempty"` + Status string `json:"status,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + OccurredAt string `json:"occurredAt"` +} + +// DeliveryContext is bounded repository and delivery metadata Hawk can sync +// with its project-scoped device token. +type DeliveryContext struct { + ProjectID string `json:"projectId"` + Repository struct { + Provider string `json:"provider"` + ExternalID string `json:"externalId"` + Name string `json:"name"` + URL string `json:"url,omitempty"` + DefaultBranch string `json:"defaultBranch,omitempty"` + } `json:"repository"` + Branch string `json:"branch,omitempty"` + CommitSHA string `json:"commitSha,omitempty"` + CIRun *CIRunContext `json:"ciRun,omitempty"` + Deployment *DeploymentContext `json:"deployment,omitempty"` +} + +type CIRunContext struct { + Provider string `json:"provider"` + ExternalID string `json:"externalId"` + Workflow string `json:"workflow,omitempty"` + Status string `json:"status"` +} + +type DeploymentContext struct { + Provider string `json:"provider"` + ExternalID string `json:"externalId"` + Environment string `json:"environment"` + Status string `json:"status"` +} + +type DeviceLoginStart struct { + DeviceCode string `json:"deviceCode"` + UserCode string `json:"userCode"` + VerificationURI string `json:"verificationUri"` + ExpiresIn int `json:"expiresIn"` + Interval int `json:"interval"` +} + +type DeviceLoginPoll struct { + Status string `json:"status"` + Token string `json:"token"` + DeviceID string `json:"deviceId"` + ProjectID string `json:"projectId"` + PrincipalID string `json:"principalId"` +} + +type Client struct { + endpoint, token string + http *http.Client +} + +func New(cfg Config) *Client { + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: 3 * time.Second} + } + return &Client{endpoint: strings.TrimRight(cfg.Endpoint, "/"), token: cfg.DeviceToken, http: client} +} + +func (c *Client) Enabled() bool { return c.endpoint != "" && c.token != "" } + +func (c *Client) startDeviceLogin(ctx context.Context, label, platform, hawkVersion string) (DeviceLoginStart, error) { + var result DeviceLoginStart + body, err := json.Marshal(map[string]string{"label": label, "platform": platform, "hawkVersion": hawkVersion}) + if err != nil { + return result, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/auth/device/start", bytes.NewReader(body)) + if err != nil { + return result, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return result, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return result, fmt.Errorf("hawk cloud device login start: %s", resp.Status) + } + return result, json.NewDecoder(resp.Body).Decode(&result) +} + +func (c *Client) pollDeviceLogin(ctx context.Context, deviceCode string) (DeviceLoginPoll, error) { + var result DeviceLoginPoll + body, err := json.Marshal(map[string]string{"deviceCode": deviceCode}) + if err != nil { + return result, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/auth/device/poll", bytes.NewReader(body)) + if err != nil { + return result, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return result, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return result, fmt.Errorf("hawk cloud device login poll: %s", resp.Status) + } + return result, json.NewDecoder(resp.Body).Decode(&result) +} + +func (c *Client) StartDeviceLogin(ctx context.Context, label, platform, hawkVersion string) (DeviceLoginStart, error) { + if c.endpoint == "" { + return DeviceLoginStart{}, fmt.Errorf("hawk cloud endpoint is not configured") + } + return c.startDeviceLogin(ctx, label, platform, hawkVersion) +} + +func (c *Client) PollDeviceLogin(ctx context.Context, deviceCode string) (DeviceLoginPoll, error) { + if c.endpoint == "" { + return DeviceLoginPoll{}, fmt.Errorf("hawk cloud endpoint is not configured") + } + return c.pollDeviceLogin(ctx, deviceCode) +} + +// RecordUsage intentionally discards transport failures. Cloud sync must not alter local execution. +func (c *Client) RecordUsage(ctx context.Context, event UsageEvent) { + if !c.Enabled() { + return + } + body, err := json.Marshal(event) + if err != nil { + return + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/usage", bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err == nil && resp != nil { + _ = resp.Body.Close() + } +} + +// RecordDeliveryContext is fail-open: delivery metadata must not affect local execution. +func (c *Client) RecordDeliveryContext(ctx context.Context, event DeliveryContext) { + if !c.Enabled() { + return + } + body, err := json.Marshal(event) + if err != nil { + return + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/v1/delivery-context", bytes.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err == nil && resp != nil { + _ = resp.Body.Close() + } +} diff --git a/internal/platform/cloud/client_test.go b/internal/platform/cloud/client_test.go new file mode 100644 index 00000000..e89836d2 --- /dev/null +++ b/internal/platform/cloud/client_test.go @@ -0,0 +1,67 @@ +package cloud + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRecordUsageSendsAggregateEvent(t *testing.T) { + var gotAuth string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + if r.URL.Path != "/v1/usage" { + t.Errorf("path = %s", r.URL.Path) + } + w.WriteHeader(http.StatusAccepted) + })) + defer s.Close() + c := New(Config{Endpoint: s.URL, DeviceToken: "hwc_test"}) + c.RecordUsage(context.Background(), UsageEvent{EventID: "event_0123456789", DeviceID: "device_0123456789", ProjectID: "project_0123456789", Capability: "hawk", OccurredAt: "2026-07-10T00:00:00Z"}) + if gotAuth != "Bearer hwc_test" { + t.Fatalf("authorization = %q", gotAuth) + } +} + +func TestDisabledClientDoesNotSend(t *testing.T) { + New(Config{}).RecordUsage(context.Background(), UsageEvent{}) +} + +func TestRecordDeliveryContextUsesDeviceScopedEndpoint(t *testing.T) { + var gotAuth, gotPath string + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth, gotPath = r.Header.Get("Authorization"), r.URL.Path + w.WriteHeader(http.StatusAccepted) + })) + defer s.Close() + event := DeliveryContext{ProjectID: "project_0123456789", Branch: "main", CommitSHA: "abc123"} + event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = "git", "hawk-eco", "GrayCodeAI/hawk-eco" + New(Config{Endpoint: s.URL, DeviceToken: "hwc_test"}).RecordDeliveryContext(context.Background(), event) + if gotPath != "/v1/delivery-context" || gotAuth != "Bearer hwc_test" { + t.Fatalf("path/auth = %q/%q", gotPath, gotAuth) + } +} + +func TestRecordDeliveryContextIncludesCIRunAndDeployment(t *testing.T) { + var body DeliveryContext + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + w.WriteHeader(http.StatusAccepted) + })) + defer s.Close() + event := DeliveryContext{ProjectID: "project_0123456789"} + event.Repository.Provider, event.Repository.ExternalID, event.Repository.Name = "github", "1", "GrayCodeAI/hawk" + event.CIRun = &CIRunContext{Provider: "github", ExternalID: "run-1", Workflow: "test", Status: "succeeded"} + event.Deployment = &DeploymentContext{Provider: "github", ExternalID: "deploy-1", Environment: "production", Status: "succeeded"} + New(Config{Endpoint: s.URL, DeviceToken: "hwc_test"}).RecordDeliveryContext(context.Background(), event) + if body.CIRun == nil || body.CIRun.ExternalID != "run-1" { + t.Fatalf("CI run = %+v", body.CIRun) + } + if body.Deployment == nil || body.Deployment.Environment != "production" { + t.Fatalf("deployment = %+v", body.Deployment) + } +} diff --git a/internal/platform/cloud/config.go b/internal/platform/cloud/config.go new file mode 100644 index 00000000..00760332 --- /dev/null +++ b/internal/platform/cloud/config.go @@ -0,0 +1,58 @@ +package cloud + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/GrayCodeAI/hawk/internal/auth" + "github.com/GrayCodeAI/hawk/internal/storage" +) + +const ( + tokenService = "hawk-cloud" + tokenAccount = "device-token" +) + +type DeviceConfig struct { + Endpoint string `json:"endpoint"` + DeviceID string `json:"device_id"` + ProjectID string `json:"project_id"` +} + +func configPath() string { return filepath.Join(storage.ConfigDir(), "cloud.json") } + +func LoadDeviceConfig() (DeviceConfig, error) { + var cfg DeviceConfig + b, err := os.ReadFile(configPath()) // #nosec G304 -- fixed Hawk config path + if err != nil { + return cfg, err + } + return cfg, json.Unmarshal(b, &cfg) +} + +func SaveDeviceConfig(cfg DeviceConfig, token string) error { + if err := auth.NewSecureStorage(tokenService).Set(tokenAccount, token); err != nil { + return err + } + b, err := json.Marshal(cfg) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(configPath()), 0o700); err != nil { + return err + } + return os.WriteFile(configPath(), b, 0o600) +} + +func LoadClient() (*Client, DeviceConfig, error) { + cfg, err := LoadDeviceConfig() + if err != nil { + return nil, cfg, err + } + token, err := auth.NewSecureStorage(tokenService).Get(tokenAccount) + if err != nil { + return nil, cfg, err + } + return New(Config{Endpoint: cfg.Endpoint, DeviceToken: token}), cfg, nil +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go index b8d047a0..6ff79b65 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -9,85 +9,88 @@ package theme -import "github.com/charmbracelet/lipgloss" +import ( + lipgloss "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/compat" +) // Theme holds all resolved theme colors and styles for the TUI. type Theme struct { // Brand identity - BrandColor lipgloss.Color + BrandColor lipgloss.Style // UI state - PanelBg lipgloss.Color - PromptBg lipgloss.Color - LineColor lipgloss.Color - LineColor2 lipgloss.Color + PanelBg lipgloss.Style + PromptBg lipgloss.Style + LineColor lipgloss.Style + LineColor2 lipgloss.Style // Semantic feedback - SuccessColor lipgloss.Color - WarnColor lipgloss.Color - ErrorColor lipgloss.Color - InfoColor lipgloss.Color + SuccessColor lipgloss.Style + WarnColor lipgloss.Style + ErrorColor lipgloss.Style + InfoColor lipgloss.Style // Tooling & agents - ToolColor lipgloss.Color - AgentColor lipgloss.Color - DoneColor lipgloss.Color - ContainerColor lipgloss.Color + ToolColor lipgloss.Style + AgentColor lipgloss.Style + DoneColor lipgloss.Style + ContainerColor lipgloss.Style // Autonomy tiers - TierInspect lipgloss.Color - TierEdit lipgloss.Color - TierRun lipgloss.Color - TierTrust lipgloss.Color + TierInspect lipgloss.Style + TierEdit lipgloss.Style + TierRun lipgloss.Style + TierTrust lipgloss.Style // HUD overlay - HudBorderColor lipgloss.Color - HudLabelColor lipgloss.Color + HudBorderColor lipgloss.Style + HudLabelColor lipgloss.Style // Status bar - CostColor lipgloss.Color - BranchColor lipgloss.Color - TokenColor lipgloss.Color - CwdColor lipgloss.Color + CostColor lipgloss.Style + BranchColor lipgloss.Style + TokenColor lipgloss.Style + CwdColor lipgloss.Style // Text hierarchy (AdaptiveColor for dark/light variant) - TextPrimary lipgloss.AdaptiveColor - TextMuted lipgloss.AdaptiveColor - TextPlaceholder lipgloss.Color - TextDisabled lipgloss.AdaptiveColor - TextWhite lipgloss.Color + TextPrimary compat.AdaptiveColor + TextMuted compat.AdaptiveColor + TextPlaceholder lipgloss.Style + TextDisabled compat.AdaptiveColor + TextWhite lipgloss.Style // Structure - BorderColor lipgloss.AdaptiveColor - BgCode lipgloss.Color + BorderColor compat.AdaptiveColor + BgCode lipgloss.Style // Diff colors - DiffAddBg lipgloss.Color - DiffDelBg lipgloss.Color - DiffAddBgWord lipgloss.Color - DiffDelBgWord lipgloss.Color + DiffAddBg lipgloss.Style + DiffDelBg lipgloss.Style + DiffAddBgWord lipgloss.Style + DiffDelBgWord lipgloss.Style // Permission - PermBg lipgloss.Color - PermBgWord lipgloss.Color - SelBg lipgloss.Color + PermBg lipgloss.Style + PermBgWord lipgloss.Style + SelBg lipgloss.Style // On-accent for text on brand backgrounds - OnBrandColor lipgloss.Color - OnAccentColor lipgloss.Color + OnBrandColor lipgloss.Style + OnAccentColor lipgloss.Style // Card colors for specialist cards - CardRun lipgloss.Color - CardErr lipgloss.Color - CardPerm lipgloss.Color + CardRun lipgloss.Style + CardErr lipgloss.Style + CardPerm lipgloss.Style // Git colors - GitAdd lipgloss.Color - GitDel lipgloss.Color - GitAddBg lipgloss.Color - GitDelBg lipgloss.Color - GitAddBgWord lipgloss.Color - GitDelBgWord lipgloss.Color + GitAdd lipgloss.Style + GitDel lipgloss.Style + GitAddBg lipgloss.Style + GitDelBg lipgloss.Style + GitAddBgWord lipgloss.Style + GitDelBgWord lipgloss.Style // Status line colors (raw ANSI for hot path) AnsiBrand string diff --git a/internal/tool/git_commit.go b/internal/tool/git_commit.go index 62d8f751..a27c5898 100644 --- a/internal/tool/git_commit.go +++ b/internal/tool/git_commit.go @@ -16,13 +16,9 @@ var lastAutoCommitHash string // model's raw response. It is reused as the ChatFn of the existing // CommitMessageGenerator (see smart_commit.go). // -// Hawk's tools do not yet hold a direct handle to the eyrie model client, so -// the default value is nil and GenerateCommitMessage falls back to the -// deterministic rule-based generator. Assign this at startup once the session -// model client is threaded through the tool layer to enable real LLM output. -// -// TODO(commit-llm): wire an eyrie-backed function into CommitMessageChatFn when -// the session model client is available to tools. +// Session execution supplies this through ToolContext. The package-level seam +// remains available for embedders and tests; when neither is set, +// GenerateCommitMessage falls back to the deterministic generator. var CommitMessageChatFn func(ctx context.Context, prompt string) (string, error) // errNoCommitModel signals that no real model is wired up. Retained for callers diff --git a/scripts/check-internal-layer-imports.sh b/scripts/check-internal-layer-imports.sh new file mode 100755 index 00000000..2b102b7e --- /dev/null +++ b/scripts/check-internal-layer-imports.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +failed=0 + +check_layer() { + local directory=$1 + local forbidden=$2 + local matches + matches=$(git grep -n -E "github.com/GrayCodeAI/hawk/(${forbidden})([\"/])" -- "${directory}/**/*.go" \ + ':(exclude)**/*_test.go' 2>/dev/null || true) + if [[ -n "$matches" ]]; then + echo "forbidden upward/sideways imports from ${directory}:" >&2 + echo "$matches" >&2 + failed=1 + fi +} + +# These are existing stable package boundaries. Delivery packages may compose +# them, but domain/runtime packages must not depend back on delivery or concrete +# platform/bridge adapters. +check_layer internal/engine 'cmd|internal/daemon|internal/platform|internal/bridge' +check_layer internal/permissions 'cmd|internal/daemon|internal/engine|internal/platform|internal/bridge' +check_layer internal/session 'cmd|internal/daemon|internal/engine|internal/platform|internal/bridge' +check_layer internal/platform 'cmd|internal/daemon|internal/engine|internal/bridge' +check_layer internal/bridge 'cmd|internal/daemon|internal/engine|internal/platform' + +if ((failed)); then + echo "Hawk internal layer boundary check failed" >&2 + exit 1 +fi +echo "Hawk internal layer boundary check passed" diff --git a/scripts/check-submodule-release-parity.sh b/scripts/check-submodule-release-parity.sh new file mode 100755 index 00000000..42c9e288 --- /dev/null +++ b/scripts/check-submodule-release-parity.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Compare each engine Gitlink (from the superproject index) to the commit +# resolved by the go.mod module version. Uses `git ls-tree` so the check +# works without `git submodule update` / checkout-eyrie having populated +# external/* working trees. + +repos=(hawk-core-contracts eyrie inspect sight tok trace yaad) +failed=0 + +printf '%-24s %-14s %-14s %s\n' MODULE GITLINK MODULE_COMMIT STATUS +for repo in "${repos[@]}"; do + module="github.com/GrayCodeAI/${repo}" + gitlink=$(git ls-tree HEAD "external/${repo}" | awk '{print $3}') + if [[ -z "$gitlink" ]]; then + printf '%-24s %-14s %-14s %s\n' "$repo" missing - MISSING_GITLINK + failed=1 + continue + fi + + version=$(GOWORK=off go list -m -f '{{.Version}}' "$module") + metadata=$(GOWORK=off go mod download -json "${module}@${version}" 2>/dev/null || true) + module_commit=$(printf '%s\n' "$metadata" | sed -n 's/.*"Hash": "\([0-9a-f]*\)".*/\1/p' | head -1) + if [[ -z "$module_commit" ]]; then + printf '%-24s %-14s %-14s %s\n' "$repo" "${gitlink:0:12}" unknown UNRESOLVED + failed=1 + elif [[ "$module_commit" == "$gitlink" ]]; then + printf '%-24s %-14s %-14s %s\n' "$repo" "${gitlink:0:12}" "${module_commit:0:12}" OK + else + printf '%-24s %-14s %-14s %s\n' "$repo" "${gitlink:0:12}" "${module_commit:0:12}" MISMATCH + failed=1 + fi +done + +if ((failed)); then + echo "submodule/module release parity failed" >&2 + exit 1 +fi