From 2a313f3f6804de17cd7a5cfa98c27a31c8bb6539 Mon Sep 17 00:00:00 2001 From: JoJowAkaGioGiow Date: Thu, 16 Jul 2026 16:30:45 -0300 Subject: [PATCH 01/14] feat: adds helium browser support --- CLAUDE.md | 2 +- INSTALL.md | 2 +- README.md | 2 +- docs/chromium-shell.md | 7 ++++--- install.sh | 4 ++-- internal/chromium/candidates_unix.go | 1 + internal/chromium/candidates_unix_test.go | 24 +++++++++++++++++++++++ internal/chromium/chromium.go | 2 +- 8 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 internal/chromium/candidates_unix_test.go diff --git a/CLAUDE.md b/CLAUDE.md index c8a273384..048a45c63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ product, it is a bespoke tool. Linux first; an experimental Windows build ships ConPTY on Windows, where npm's `claude.cmd` shim runs through `cmd.exe /c`). - **Shell**: system Chromium-family browser launched in `--app` mode (`internal/chromium`), persistent profile under the user config dir (`~/.config/lich/chromium-profile`; `%AppData%\lich` on Windows). Window closed = app exit. - Runtime needs: a Chromium-family browser — chromium/chrome/brave on PATH on Linux, plus zenity for the folder + Runtime needs: a Chromium-family browser — chromium/chrome/helium-browser/brave on PATH on Linux, plus zenity for the folder picker; chrome/edge/brave via their conventional install paths on Windows (picker is native win32, and Edge being everywhere guarantees a window). - **Frontend**: React 18 + TypeScript + Vite. Terminal is xterm.js 6 + `@xterm/addon-webgl`. Service shapes are diff --git a/INSTALL.md b/INSTALL.md index 6c4d872fb..39203b2cd 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -14,7 +14,7 @@ Pick your system: - [Verifying checksums](#verifying-checksums) **Runtime dependencies** — lich opens its window in a Chromium-family browser; -none is bundled. On Linux any of `chromium`, `google-chrome` or `brave` +none is bundled. On Linux any of `chromium`, `google-chrome`, `helium-browser` or `brave` satisfies it, and `zenity` provides the folder picker. On Windows, Chrome, Edge or Brave are found via their conventional install paths (Edge ships with Windows) and the folder picker is native. diff --git a/README.md b/README.md index 3836de846..38a529466 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ release. > **Platform** — Linux only. lich is a single static Go binary that opens its > UI in your system's Chromium-family browser in `--app` mode (no Electron, no > bundled webview — decision record: `docs/chromium-shell.md`). At runtime it -> needs a Chromium-family browser (chromium, google-chrome, brave, …) on PATH +> needs a Chromium-family browser (chromium, google-chrome, helium-browser, brave, …) on PATH > and `zenity` for the folder picker. ## Features diff --git a/docs/chromium-shell.md b/docs/chromium-shell.md index 5e65bc8db..aa0a35792 100644 --- a/docs/chromium-shell.md +++ b/docs/chromium-shell.md @@ -37,9 +37,10 @@ its CDP surface): `chromium --app=http://127.0.0.1: --user-data-dir= --class=lich`. - Window closed → WebSocket drops → Go shuts down. No CDP needed for v1. - Still a single Go binary. **Zero Node, zero Electron, no new bundle weight.** - New runtime requirement: chromium/chrome installed (fine for a personal - harness on Arch; the launcher should probe `chromium`, `google-chrome`, - `chromium-browser` and fail with a clear message). + New runtime requirement: a Chromium-family browser installed (fine for a + personal harness on Arch; the launcher should probe `chromium`, + `google-chrome`, `helium-browser`, `chromium-browser` and fail with a clear + message). Why lich is ~80% there already: diff --git a/install.sh b/install.sh index 6220c0c73..b7974451e 100755 --- a/install.sh +++ b/install.sh @@ -49,11 +49,11 @@ as_root() { check_runtime_deps() { browser="" - for candidate in chromium chromium-browser google-chrome-stable google-chrome brave; do + for candidate in chromium chromium-browser google-chrome-stable google-chrome helium-browser brave; do if have "$candidate"; then browser="$candidate"; break; fi done missing="" - [ -n "$browser" ] || missing="a Chromium-family browser (chromium, google-chrome, brave, ...)" + [ -n "$browser" ] || missing="a Chromium-family browser (chromium, google-chrome, helium-browser, brave, ...)" if ! have zenity; then [ -z "$missing" ] || missing="${missing} and " missing="${missing}zenity" diff --git a/internal/chromium/candidates_unix.go b/internal/chromium/candidates_unix.go index cf2fc121c..be2ab2745 100644 --- a/internal/chromium/candidates_unix.go +++ b/internal/chromium/candidates_unix.go @@ -11,6 +11,7 @@ func browserCandidates() []string { "chromium-browser", "google-chrome-stable", "google-chrome", + "helium-browser", "brave", } } diff --git a/internal/chromium/candidates_unix_test.go b/internal/chromium/candidates_unix_test.go new file mode 100644 index 000000000..b737fff72 --- /dev/null +++ b/internal/chromium/candidates_unix_test.go @@ -0,0 +1,24 @@ +//go:build !windows + +package chromium + +import ( + "slices" + "testing" +) + +func TestUnixBrowserCandidatesIncludeSupportedChromiumFamilyBrowsers(t *testing.T) { + candidates := browserCandidates() + for _, want := range []string{ + "chromium", + "chromium-browser", + "google-chrome-stable", + "google-chrome", + "helium-browser", + "brave", + } { + if !slices.Contains(candidates, want) { + t.Fatalf("browserCandidates() = %v, missing %q", candidates, want) + } + } +} diff --git a/internal/chromium/chromium.go b/internal/chromium/chromium.go index 680e129c2..571280d34 100644 --- a/internal/chromium/chromium.go +++ b/internal/chromium/chromium.go @@ -23,7 +23,7 @@ func FindBrowser(lookPath func(name string) (string, error)) (string, error) { } } return "", errors.New("no chromium-family browser found (tried " + - fmt.Sprint(candidates) + "); install chromium, chrome or edge") + fmt.Sprint(candidates) + "); install chromium, chrome, edge, brave, or helium") } // windowsBrowserCandidates builds the Windows candidate list: chrome, then From fa613fd4ed963727815da580e977ba039869d1db Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:51:34 -0300 Subject: [PATCH 02/14] ci: surface test counts + coverage in the pipeline (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ci.yml — a per-PR and push-to-main gate that runs gofmt, go vet and both suites; release.yml now runs its test steps through the same shared scripts. Both render pass/fail counts and coverage into the GitHub Actions job summary (.github/scripts/{go,web}-test-summary.sh) and still fail the job on a red test. Frontend coverage is measured over the .ts logic layer via a new @vitest/coverage-v8 devDep; the .tsx DOM/xterm boundary stays out of the denominator (invariant #1). CLAUDE.md documents the local gate — fmt, vet, test, build before every commit or PR. Previously CI ran no tests on PRs at all. --- .github/scripts/go-test-summary.sh | 41 ++++++++++ .github/scripts/web-test-summary.sh | 31 ++++++++ .github/workflows/ci.yml | 61 +++++++++++++++ .github/workflows/release.yml | 12 ++- .gitignore | 1 + CLAUDE.md | 16 ++++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 112 +++++++++++++++++++++++++++- frontend/vitest.config.ts | 11 +++ 9 files changed, 280 insertions(+), 6 deletions(-) create mode 100755 .github/scripts/go-test-summary.sh create mode 100755 .github/scripts/web-test-summary.sh create mode 100644 .github/workflows/ci.yml diff --git a/.github/scripts/go-test-summary.sh b/.github/scripts/go-test-summary.sh new file mode 100755 index 000000000..a57e1715f --- /dev/null +++ b/.github/scripts/go-test-summary.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Runs the Go suite once, appends pass/fail/skip counts and total statement +# coverage to the GitHub Actions job summary, and exits with the suite's own +# status so a red test still fails the job. Arg $1: optional label (e.g. the OS) +# appended to the heading. Falls back to stdout when run outside Actions. +set -uo pipefail + +label="${1:+ — $1}" +json="$(mktemp)" +cover="$(mktemp)" + +go test -json -coverprofile="$cover" ./... >"$json" +code=$? + +read -r pass fail skip < <(jq -rs ' + map(select(.Test != null and (.Action == "pass" or .Action == "fail" or .Action == "skip"))) + | [ (map(select(.Action == "pass")) | length), + (map(select(.Action == "fail")) | length), + (map(select(.Action == "skip")) | length) ] | @tsv +' "$json") + +total="$(go tool cover -func="$cover" | awk '/^total:/ {print $3}')" + +summary="${GITHUB_STEP_SUMMARY:-/dev/stdout}" +{ + echo "### Backend (Go)${label}" + echo "" + echo "| Passed | Failed | Skipped | Coverage |" + echo "|-------:|-------:|--------:|---------:|" + echo "| ${pass:-0} | ${fail:-0} | ${skip:-0} | ${total:-n/a} |" + echo "" + if [ "${fail:-0}" != "0" ]; then + echo "
Failed tests" + echo "" + jq -rs 'map(select(.Action == "fail" and .Test != null)) | .[] | "- `\(.Package) \(.Test)`"' "$json" | sort -u + echo "" + echo "
" + fi +} >>"$summary" + +exit "$code" diff --git a/.github/scripts/web-test-summary.sh b/.github/scripts/web-test-summary.sh new file mode 100755 index 000000000..f01b2027b --- /dev/null +++ b/.github/scripts/web-test-summary.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Runs the frontend (Vitest) suite with coverage, appends file/pass/fail counts +# and line coverage to the GitHub Actions job summary, and exits with the +# suite's own status. node_modules must already be installed. Falls back to +# stdout when run outside Actions. +set -uo pipefail + +cd "$(dirname "$0")/../../frontend" +out="$(mktemp)" + +pnpm exec vitest run --coverage --reporter=default --reporter=json --outputFile.json="$out" +code=$? + +read -r files passed failed < <(jq -r ' + [ (.testResults | length), .numPassedTests, .numFailedTests ] | @tsv +' "$out") + +cov="$(jq -r '.total.lines.pct' coverage/coverage-summary.json 2>/dev/null || echo n/a)" +[ "$cov" != "n/a" ] && cov="${cov}%" + +summary="${GITHUB_STEP_SUMMARY:-/dev/stdout}" +{ + echo "### Frontend (Vitest)" + echo "" + echo "| Files | Passed | Failed | Coverage (lines) |" + echo "|------:|-------:|-------:|-----------------:|" + echo "| ${files:-0} | ${passed:-0} | ${failed:-0} | ${cov:-n/a} |" + echo "" +} >>"$summary" + +exit "$code" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..279ef6ed8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: ci + +# Fast per-PR gate: format/vet + both suites, with pass/fail counts and +# coverage rendered into the job summary. Windows backend is validated at +# release time (release.yml); a PR runs the linux path only for quick feedback. +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: frontend/package.json + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + # dist must exist before any Go step: the backend go:embeds frontend/dist, + # so it will not even compile without it. build also runs tsc (typecheck). + - name: Frontend install + build + working-directory: frontend + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "::error::gofmt: these files need formatting:" + echo "$unformatted" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: Frontend tests + run: bash .github/scripts/web-test-summary.sh + + - name: Backend tests + run: bash .github/scripts/go-test-summary.sh linux diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a8fa1a5d..5c38fc2d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,15 +46,19 @@ jobs: # Frontend first: the backend embeds frontend/dist (go:embed), so Go # tests only compile once the frontend has been built. - - name: Frontend install + tests + build + - name: Frontend install + build working-directory: frontend run: | pnpm install --frozen-lockfile - pnpm test pnpm build + # Both suites emit pass/fail counts + coverage into the job summary and + # still fail the job on a red test (shared with ci.yml). + - name: Frontend tests + run: bash .github/scripts/web-test-summary.sh + - name: Backend tests - run: go test ./... + run: bash .github/scripts/go-test-summary.sh linux - name: Build Linux packages run: task package @@ -120,7 +124,7 @@ jobs: run: task package:windows - name: Backend tests (windows) - run: go test ./... + run: bash .github/scripts/go-test-summary.sh windows - name: Assemble Windows assets run: | diff --git a/.gitignore b/.gitignore index dfe51416e..2367951bb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ bin .idea/ frontend/dist frontend/node_modules +frontend/coverage diff --git a/CLAUDE.md b/CLAUDE.md index 048a45c63..b5499a527 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,22 @@ task package:windows # bin/lich-setup.exe installer (needs Inno Setup 6 — CI/ Frontend in isolation: `cd frontend && pnpm run build` (runs `tsc` + `vite build`). +## Local Gate (before every commit / PR) + +Run the whole check locally before pushing a commit or opening a PR — never lean +on CI to find what a local run catches in seconds: + +- `gofmt -l .` clean (fix with `gofmt -w .`) and `go vet ./...` clean. +- `go test ./...` (backend) and `cd frontend && pnpm test` (frontend) green — + or `task test` for both at once. +- `cd frontend && pnpm build` succeeds (tsc typecheck + vite). + +CI mirrors exactly these: `ci.yml` runs them on every PR and push to `main`, +`release.yml` on a tag. Both render pass/fail counts and coverage into the +Actions job summary (`.github/scripts/*-test-summary.sh`), so the numbers are +visible per run rather than buried in the log. A red test fails the job — the +summary is transparency, never a substitute for the gate. + --- ## Hard Invariants diff --git a/frontend/package.json b/frontend/package.json index e5023c2d0..d9b3cc869 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^6.0.0", + "@vitest/coverage-v8": "4.1.10", "tailwindcss": "^4.3.2", "typescript": "^5.2.2", "vite": "^8.0.5", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5abac94cb..617f344a2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.0 version: 6.0.3(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) tailwindcss: specifier: ^4.3.2 version: 4.3.2 @@ -107,7 +110,7 @@ importers: version: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) + version: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) packages: @@ -271,6 +274,10 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@codemirror/autocomplete@6.20.3': resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} @@ -765,6 +772,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -852,6 +868,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + atomically@1.7.0: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} @@ -1255,6 +1274,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -1267,6 +1290,9 @@ packages: resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} engines: {node: '>=16.9.0'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1390,6 +1416,18 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1397,6 +1435,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1537,6 +1578,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1956,6 +2004,10 @@ packages: style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + systeminformation@5.31.13: resolution: {integrity: sha512-iUJXJoKzm4vtLSeT3nwe2s9QjoJAxHg7wYJ0KaQ54Xy2u9jsTq0ULWQQ0+T72FXjX2XnGqubazNx9lUfng7ELw==} engines: {node: '>=8.0.0'} @@ -2402,6 +2454,8 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 + '@bcoe/v8-coverage@1.0.2': {} + '@codemirror/autocomplete@6.20.3': dependencies: '@codemirror/language': 6.12.4 @@ -3049,6 +3103,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -3132,6 +3200,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + atomically@1.7.0: {} balanced-match@4.0.4: {} @@ -3522,6 +3596,8 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} hasown@2.0.4: @@ -3530,6 +3606,8 @@ snapshots: hono@4.12.28: {} + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3611,10 +3689,25 @@ snapshots: isexe@3.1.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} jose@6.2.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.0: @@ -3720,6 +3813,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -4161,6 +4264,10 @@ snapshots: style-mod@4.1.3: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + systeminformation@5.31.13: {} tailwind-merge@3.6.0: {} @@ -4249,7 +4356,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)): + vitest@4.1.10(@types/node@26.1.0)(@vitest/coverage-v8@4.1.10)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) @@ -4273,6 +4380,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.0 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index fd6a639ec..daa419317 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -13,5 +13,16 @@ export default defineConfig({ test: { environment: "node", include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text-summary", "json-summary"], + reportsDirectory: "coverage", + // The suite owns the pure logic layer (`.ts`: lib, hooks, stores); the + // `.tsx` components are the DOM/xterm boundary invariant #1 exempts, so + // they stay out of the denominator — including them would report a + // misleadingly low number against a suite that never targeted them. + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/*.d.ts"], + }, }, }) From 3eb367d8421f1325ab1caa7dd014738e89516527 Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:57:49 -0300 Subject: [PATCH 03/14] chore: mutation testing via gremlins (#24) `task mutation` runs go-gremlins/gremlins over the module; scope a package with `task mutation -- ./internal/store/`. .gremlins.yaml pins workers to 1, which is load-bearing: the default one-worker-per-CPU runs mutant test binaries in parallel and the SQLite-backed store tests then contend and time out en masse (every mutant TIMED OUT, efficacy 0%). Single-worker efficacy on internal/store is ~97% with zero timeouts. timeout-coefficient gives slow packages (terminal spawns real PTYs) headroom before a genuine hang is declared timed out. --- .gremlins.yaml | 11 +++++++++++ Taskfile.yml | 10 ++++++++++ 2 files changed, 21 insertions(+) create mode 100644 .gremlins.yaml diff --git a/.gremlins.yaml b/.gremlins.yaml new file mode 100644 index 000000000..63ab701cb --- /dev/null +++ b/.gremlins.yaml @@ -0,0 +1,11 @@ +# Mutation-testing config for `task mutation` (go-gremlins/gremlins). +# +# workers: 1 is load-bearing. Gremlins defaults to one worker per CPU and runs +# mutant test binaries in parallel; the SQLite-backed store tests then contend +# and start timing out — every mutant reported TIMED OUT, efficacy 0%. That is +# machine contention, not a weak suite (single-worker efficacy on internal/store +# is ~97%). timeout-coefficient buys slow packages (terminal spawns real PTYs) +# headroom before a genuine hang is declared timed out. +unleash: + workers: 1 + timeout-coefficient: 20 diff --git a/Taskfile.yml b/Taskfile.yml index 093040485..451381b9f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -64,6 +64,16 @@ tasks: - cmd: go test ./... - cmd: cd frontend && pnpm test + mutation: + summary: 'Mutation tests via gremlins (config: .gremlins.yaml). Scope with -- ./pkg/' + preconditions: + - sh: command -v gremlins + msg: "gremlins is required: go install github.com/go-gremlins/gremlins/cmd/gremlins@latest" + cmds: + # No path → whole module (slow: single-worker, terminal spawns real PTYs). + # Scope a run with: task mutation -- ./internal/store/ + - cmd: gremlins unleash {{.CLI_ARGS}} + package: summary: Builds the Linux packages (deb, rpm, archlinux) into bin/ deps: [build] From 887a6bc028855e58e718e1728ab0c3410a94ea54 Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:57:58 -0300 Subject: [PATCH 04/14] test: cover the pre-migration store schema path (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open()'s ADD COLUMN migrations were only ever run against a current-schema database, where every ALTER is a duplicate-column no-op — the branch where a migration actually applies (err == nil) had no test. Seed a database with the pre-migration schema (sessions without kind/path/…, projects without position) and assert open() adds the missing columns and they round-trip through AddSession/LoadState. Mutation testing flagged the gap: internal/store efficacy goes 97.4% -> 100%, killing the surviving `err != nil` -> `err == nil` mutant at store.go:130. --- internal/store/store_test.go | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 098d4f49b..5cb666076 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1,6 +1,7 @@ package store import ( + "database/sql" "os" "path/filepath" "testing" @@ -521,3 +522,64 @@ func TestReopenProjectKeepsDraggedPosition(t *testing.T) { t.Errorf("project order = %v, want [p3 p1 p2]", got) } } + +// TestOpenMigratesPreMigrationDatabase proves open() applies the ADD COLUMN +// migrations to a database created before those columns existed — the path +// where a migration actually succeeds (err == nil). A current-schema database +// never reaches it: every ALTER there is a duplicate-column no-op, which is why +// only this test kills the mutant that flips migrate's `err != nil` to +// `err == nil` (that inversion dereferences a nil error here and panics). +func TestOpenMigratesPreMigrationDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "old.db") + + // The schema as it stood before the migrations: sessions without + // kind/path/claude_session_id/label_auto/position, projects without + // position. + const oldSchema = ` +CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + is_open INTEGER NOT NULL DEFAULT 1, + next_seq INTEGER NOT NULL DEFAULT 1, + active_session_id TEXT NOT NULL DEFAULT '' +); +CREATE TABLE sessions ( + id TEXT NOT NULL PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + label TEXT NOT NULL +);` + + seed, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open seed db: %v", err) + } + if _, err := seed.Exec(oldSchema); err != nil { + t.Fatalf("seed old schema: %v", err) + } + _ = seed.Close() + + svc, err := open(path) + if err != nil { + t.Fatalf("open pre-migration db: %v", err) + } + t.Cleanup(func() { _ = svc.Close() }) + + // The columns the migrations added must now be usable end to end. + if err := svc.AddProject("p1", "alpha", "/tmp/alpha"); err != nil { + t.Fatalf("AddProject: %v", err) + } + if err := svc.AddSession("p1", "s1", "mellow-otter", "shell", "/data/wt", 2); err != nil { + t.Fatalf("AddSession: %v", err) + } + got, err := svc.LoadState() + if err != nil { + t.Fatalf("LoadState: %v", err) + } + if len(got) != 1 || len(got[0].Sessions) != 1 { + t.Fatalf("state after migration = %+v", got) + } + if s := got[0].Sessions[0]; s.Kind != "shell" || s.Path != "/data/wt" { + t.Errorf("migrated session = %+v, want kind=shell path=/data/wt", s) + } +} From fd00f93ed6c5f8b872231a4162bf859426bf1518 Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:34:37 -0300 Subject: [PATCH 05/14] feat: experimental macOS build (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS is Unix, so the terminal (creack/pty), shell and folder picker (osascript via ncruces/zenity) already run through the shared !windows seams and the whole binary cross-compiles for darwin. The one gap was browser discovery: macOS installs live in .app bundles, never on PATH. Add candidates_darwin.go + darwinBrowserCandidates (Chrome/Chromium/Edge/Brave under /Applications and ~/Applications), narrowing candidates_unix.go to `!windows && !darwin`, mirroring the Windows seam and keeping the pure list testable off-macOS. task build:mac cross-compiles arm64 + amd64; the release workflow gains a macos-latest job that runs the backend suite — real PTY tests on Apple Silicon — and ships both-arch raw binaries. Unsigned (Gatekeeper) and no .dmg yet; recorded in Known Ceilings. --- .github/workflows/release.yml | 60 +++++++++++++++++++++++++- CHANGELOG.md | 9 ++++ CLAUDE.md | 19 ++++++-- Taskfile.yml | 10 +++++ internal/chromium/candidates_darwin.go | 13 ++++++ internal/chromium/candidates_unix.go | 6 ++- internal/chromium/chromium.go | 26 +++++++++++ internal/chromium/chromium_test.go | 46 ++++++++++++++++++++ 8 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 internal/chromium/candidates_darwin.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c38fc2d0..7621422c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,8 +140,66 @@ jobs: name: lich-windows path: bin/lich-* + mac: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: frontend/package.json + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install task + run: | + go install github.com/go-task/task/v3/cmd/task@latest + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + # dist must exist before the backend suite (go:embed). macOS is Unix, so + # this runs the real PTY tests on Apple Silicon; frontend tests already + # gate the linux job. + - name: Frontend install + build + working-directory: frontend + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: Backend tests (macos) + run: bash .github/scripts/go-test-summary.sh macos + + - name: Build macOS binaries + run: task build:mac + + - name: Assemble macOS assets + run: | + cd bin + tag="${GITHUB_REF_NAME//\//-}" + mv lich-darwin-arm64 "lich-${tag}-darwin-arm64" + mv lich-darwin-amd64 "lich-${tag}-darwin-amd64" + ls -la lich-${tag}-* + + - name: Upload macOS assets + uses: actions/upload-artifact@v4 + with: + name: lich-macos + path: bin/lich-* + release: - needs: [linux, windows] + needs: [linux, windows, mac] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c8efe63..c58d8aa1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 npm's `claude.cmd` shim is spawned through `cmd.exe /c`. Releases now ship a `windows-amd64.exe` asset built — and backend-tested — on a Windows runner in parallel with the Linux packages. +- Experimental macOS build (`lich-*-darwin-arm64`, `lich-*-darwin-amd64`, + `task build:mac`). macOS is Unix, so the terminal (creack/pty), the shell and + the native folder picker already work through the shared seams; only the + Chromium launcher gained a macOS list, finding Chrome/Chromium/Edge/Brave in + their `.app` bundles under `/Applications` and `~/Applications` (they never + land on PATH). Releases now ship both-arch darwin binaries, built and + backend-tested — the PTY included, on a real macOS runner — alongside the + Linux and Windows jobs. Unsigned: Gatekeeper quarantines them until + notarization ships (see the README/Known Ceilings). ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index b5499a527..0ddd0b2ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,10 +15,11 @@ product, it is a bespoke tool. Linux first; an experimental Windows build ships small seams, never by runtime checks — the PTY is the model (`internal/terminal`'s `startPTY`: creack/pty on Unix, ConPTY on Windows, where npm's `claude.cmd` shim runs through `cmd.exe /c`). - **Shell**: system Chromium-family browser launched in `--app` mode (`internal/chromium`), persistent profile under - the user config dir (`~/.config/lich/chromium-profile`; `%AppData%\lich` on Windows). Window closed = app exit. - Runtime needs: a Chromium-family browser — chromium/chrome/helium-browser/brave on PATH on Linux, plus zenity for the folder - picker; chrome/edge/brave via their conventional install paths on Windows (picker is native win32, and Edge being - everywhere guarantees a window). + the user config dir (`~/.config/lich/chromium-profile`; `%AppData%\lich` on Windows; `~/Library/Application + Support/lich` on macOS — all via `os.UserConfigDir`). Window closed = app exit. Runtime needs: a Chromium-family + browser — chromium/chrome/helium-browser/brave on PATH on Linux, plus zenity for the folder picker; chrome/edge/brave via their + conventional install paths on Windows (picker is native win32, and Edge being everywhere guarantees a window); + Chrome/Chromium/Edge/Brave in their `.app` bundles on macOS (picker is osascript, built in). - **Frontend**: React 18 + TypeScript + Vite. Terminal is xterm.js 6 + `@xterm/addon-webgl`. Service shapes are hand-owned in `frontend/src/lib/api-types.ts` (mirrors of the Go structs' JSON tags — keep in sync). - **Build/tasks**: [Task](https://taskfile.dev) — see Commands. @@ -151,3 +152,13 @@ Deliberate limits and shortcuts, with the upgrade path when it matters: before the tag can narrow), and the shell session is `COMSPEC`/cmd.exe — no PowerShell preference yet. The build is GUI subsystem (`-H=windowsgui`): no console rides along, stdout/stderr go nowhere, and `%AppData%\lich\lich.log` is the diagnostic surface — "double-clicked and nothing happened" means read the log. +- **macOS is experimental.** What holds it up: the codebase is pure Go and cross-compiles for darwin, and because + macOS is Unix the terminal (creack/pty), shell and folder picker (osascript, via ncruces/zenity) already run + through the shared `!windows` seams — the only macOS-specific code is `candidates_darwin.go`, which finds + Chrome/Chromium/Edge/Brave in their `.app` bundles under `/Applications` and `~/Applications` (macOS installs + never land on PATH). The backend suite — the real PTY tests included — runs on a `macos-latest` (Apple Silicon) + runner every release, and both-arch raw binaries (`darwin-arm64`, `darwin-amd64`) ship as assets. What's missing: + no code signing or notarization (Gatekeeper quarantines an unsigned binary — right-click-Open or + `xattr -d com.apple.quarantine com.apple.provenance` until an Apple Developer cert + notarization ship), no + `.dmg`/Homebrew packaging (raw binaries only, like the portable Windows exe), and the window path has not been + hand-smoke-tested on real hardware yet. diff --git a/Taskfile.yml b/Taskfile.yml index 451381b9f..77ec8cc11 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -33,6 +33,16 @@ tasks: # nowhere — the persistent lich.log is the diagnostic surface. - cmd: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -H=windowsgui" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe . + build:mac: + summary: Cross-compiles the macOS binaries — arm64 + amd64 (experimental) + deps: [build:frontend] + cmds: + # Both arches: Apple Silicon (arm64) and Intel (amd64). Pure Go, so both + # cross-compile from any host. Unsigned — Gatekeeper quarantines until + # notarization ships (see CLAUDE.md Known Ceilings). + - cmd: CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build {{.GO_BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-darwin-arm64 . + - cmd: CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build {{.GO_BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-darwin-amd64 . + run: summary: Builds and runs the application deps: [build] diff --git a/internal/chromium/candidates_darwin.go b/internal/chromium/candidates_darwin.go new file mode 100644 index 000000000..7d44ab785 --- /dev/null +++ b/internal/chromium/candidates_darwin.go @@ -0,0 +1,13 @@ +//go:build darwin + +package chromium + +import "os" + +// browserCandidates lists candidate browsers in preference order. macOS +// installs land in .app bundles under /Applications, not on PATH, so the list +// is absolute paths built from the install roots (darwinBrowserCandidates in +// chromium.go, where the logic stays testable from any OS). +func browserCandidates() []string { + return darwinBrowserCandidates(os.Getenv) +} diff --git a/internal/chromium/candidates_unix.go b/internal/chromium/candidates_unix.go index be2ab2745..9aaed03fd 100644 --- a/internal/chromium/candidates_unix.go +++ b/internal/chromium/candidates_unix.go @@ -1,10 +1,12 @@ -//go:build !windows +//go:build !windows && !darwin package chromium // browserCandidates lists candidate binaries in preference order. Any // Chromium gives the same compositor; the preference only picks the most -// conventional install. All bare names — Unix installs live on PATH. +// conventional install. All bare names — Linux/BSD installs live on PATH. +// macOS keeps browsers in .app bundles off PATH, so it has its own list +// (candidates_darwin.go). func browserCandidates() []string { return []string{ "chromium", diff --git a/internal/chromium/chromium.go b/internal/chromium/chromium.go index 571280d34..c250b0ff5 100644 --- a/internal/chromium/chromium.go +++ b/internal/chromium/chromium.go @@ -49,6 +49,32 @@ func windowsBrowserCandidates(getenv func(string) string) []string { return append(out, "chrome", "msedge") } +// darwinBrowserCandidates builds the macOS candidate list: chrome, then +// chromium, then edge, then brave, each as its .app executable under the +// system (/Applications) and per-user (~/Applications) install roots, with +// bare PATH names last for a Homebrew-formula install. Paths are joined with a +// literal slash so the pure logic tests the same on any OS. Kept out of the +// build-tagged file for exactly that reason. +func darwinBrowserCandidates(getenv func(string) string) []string { + apps := []string{ + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Chromium.app/Contents/MacOS/Chromium", + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "Brave Browser.app/Contents/MacOS/Brave Browser", + } + roots := []string{"/Applications"} + if home := getenv("HOME"); home != "" { + roots = append(roots, home+"/Applications") + } + var out []string + for _, app := range apps { + for _, root := range roots { + out = append(out, root+"/"+app) + } + } + return append(out, "chromium", "google-chrome") +} + // Args builds the --app invocation. The dedicated user-data-dir is // load-bearing twice over: without it Chromium adopts the window into an // already-running instance (the spawned process exits immediately, breaking diff --git a/internal/chromium/chromium_test.go b/internal/chromium/chromium_test.go index 173f9a036..c9d1b4c3c 100644 --- a/internal/chromium/chromium_test.go +++ b/internal/chromium/chromium_test.go @@ -70,6 +70,52 @@ func TestWindowsBrowserCandidatesEmptyEnv(t *testing.T) { } } +// TestDarwinBrowserCandidates proves the macOS list expands each browser under +// both the system and per-user Applications roots, prefers chrome > chromium > +// edge > brave, and ends with the bare PATH names. +func TestDarwinBrowserCandidates(t *testing.T) { + got := darwinBrowserCandidates(func(k string) string { + if k == "HOME" { + return "/Users/u" + } + return "" + }) + + want := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Users/u/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Users/u/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Users/u/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Users/u/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "chromium", + "google-chrome", + } + if !slices.Equal(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } +} + +// TestDarwinBrowserCandidatesNoHome proves a missing HOME drops the per-user +// root but still leaves the system paths and PATH names, so FindBrowser never +// iterates an empty list. +func TestDarwinBrowserCandidatesNoHome(t *testing.T) { + got := darwinBrowserCandidates(func(string) string { return "" }) + want := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "chromium", + "google-chrome", + } + if !slices.Equal(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } +} + func TestArgs(t *testing.T) { args := Args("http://127.0.0.1:47821/?token=x", "/home/u/.config/lich/chromium-profile", "lichdev", []string{"--ozone-platform=wayland"}) for _, want := range []string{ From 13665fe9cf121dc30391b38169bc9bd99ea94f3b Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:39:21 -0300 Subject: [PATCH 06/14] docs: cut CHANGELOG v0.6.0 (#28) Move the [Unreleased] entries under a 0.6.0 - 2026-07-16 heading and refresh the compare links. 0.6.0 ships the PR-badge-on-cards feature, persistent structured logging, the Windows installer + GUI-subsystem build, discreet scrollbars, the single-line diff-reference fix, and the experimental macOS build. --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c58d8aa1a..b164dc9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-07-16 + ### Added - An open pull request now badges the session card too, not just the footer. The @@ -39,8 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 their `.app` bundles under `/Applications` and `~/Applications` (they never land on PATH). Releases now ship both-arch darwin binaries, built and backend-tested — the PTY included, on a real macOS runner — alongside the - Linux and Windows jobs. Unsigned: Gatekeeper quarantines them until - notarization ships (see the README/Known Ceilings). + Linux and Windows jobs. Unsigned: Gatekeeper quarantines the binary until + notarization ships — right-click-Open, or clear the quarantine attribute, to + run it. ### Changed @@ -342,7 +345,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 CPU, costing ~40ms per frame in a full-size window. Under Xwayland typing is stall-free at full frame rate. -[Unreleased]: https://github.com/omartelo/lich/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/omartelo/lich/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/omartelo/lich/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/omartelo/lich/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/omartelo/lich/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/omartelo/lich/compare/v0.2.0...v0.3.0 From 8eec2e1160c01d30df959afdefdab363e3c7228a Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 16 Jul 2026 14:44:07 -0300 Subject: [PATCH 07/14] docs: README platform is no longer Linux-only v0.6.0 ships experimental macOS and Windows builds. Update the platform badge and the Platform note with the per-OS runtime needs (browser + folder picker). --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 38a529466..76d9b9ef5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Go Shell - Platform + Platform

@@ -21,11 +21,15 @@ is deliberately bespoke: not a generic product, a tool built for one workflow. It's public because there's no reason to hide it, not because it's a supported release. -> **Platform** — Linux only. lich is a single static Go binary that opens its -> UI in your system's Chromium-family browser in `--app` mode (no Electron, no -> bundled webview — decision record: `docs/chromium-shell.md`). At runtime it -> needs a Chromium-family browser (chromium, google-chrome, helium-browser, brave, …) on PATH -> and `zenity` for the folder picker. +> **Platform** — Linux first; experimental macOS and Windows builds ship +> alongside it. lich is a single static Go binary that opens its UI in your +> system's Chromium-family browser in `--app` mode (no Electron, no bundled +> webview — decision record: `docs/chromium-shell.md`). At runtime it needs a +> Chromium-family browser: on **Linux**, chromium/google-chrome/helium-browser/brave on PATH +> plus `zenity` for the folder picker; on **macOS**, Chrome/Chromium/Edge/Brave +> in `/Applications` (the picker is built in); on **Windows**, Chrome/Edge/Brave +> via their usual install paths. The macOS and Windows binaries are unsigned — +> Gatekeeper and SmartScreen warn until notarization/signing ship. ## Features From 4246e9594a46209043400659ef323d7bb5b7c91f Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:13:43 -0300 Subject: [PATCH 08/14] fix: count untracked additions in a repo with no commits (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff ran `git diff --numstat HEAD`, which errors in a freshly `git init`'d repo (no HEAD) and returned early, skipping the untracked-file counting block. The +/- badge on the session card and footer showed +0 -0 even though the review panel rendered the full diff. Resolve the diff base the way DiffText already does — fall back to git's empty tree when HEAD is missing — and wrap the numstat parse in an err == nil guard so a numstat failure never skips the untracked block. --- internal/project/project.go | 30 +++++++++++++++++------------- internal/project/project_test.go | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/internal/project/project.go b/internal/project/project.go index eb05a132c..a8a012186 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -142,20 +142,24 @@ func (s *Service) Diff(path string) DiffStats { if out, err := exec.Command("git", "-C", path, "status", "--porcelain").Output(); err == nil { stats.Files = countLines(out) } - out, err := exec.Command("git", "-C", path, "diff", "--numstat", "HEAD").Output() - if err != nil { - return stats - } - for line := range strings.Lines(string(out)) { - cols := strings.Fields(line) - if len(cols) < 3 { - continue + // A repository without commits has no HEAD; diff against git's empty tree, + // same as DiffText. Errors here must not skip the untracked block below. + base := "HEAD" + if _, err := runGit(path, "rev-parse", "--verify", "HEAD"); err != nil { + base = emptyTreeHash + } + if out, err := exec.Command("git", "-C", path, "diff", "--numstat", base).Output(); err == nil { + for line := range strings.Lines(string(out)) { + cols := strings.Fields(line) + if len(cols) < 3 { + continue + } + // Binary files report "-" for both counts; Atoi fails and adds zero. + added, _ := strconv.Atoi(cols[0]) + deleted, _ := strconv.Atoi(cols[1]) + stats.Added += added + stats.Deleted += deleted } - // Binary files report "-" for both counts; Atoi fails and adds zero. - added, _ := strconv.Atoi(cols[0]) - deleted, _ := strconv.Atoi(cols[1]) - stats.Added += added - stats.Deleted += deleted } // Untracked files are invisible to `git diff`; count their lines as // additions, the way Warp and forge diff views present a fresh file. diff --git a/internal/project/project_test.go b/internal/project/project_test.go index caea9a802..52dd2d4c3 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -150,3 +150,21 @@ func TestDiff(t *testing.T) { t.Errorf("Diff(non-repo) = %+v, want zero", got) } } + +// TestDiffNoCommits covers a freshly `git init`'d repo with no HEAD: numstat +// against HEAD fails, and the untracked additions must still be counted rather +// than the whole stat collapsing to +0 -0. +func TestDiffNoCommits(t *testing.T) { + repo := t.TempDir() + if out, err := exec.Command("git", "init", repo).CombinedOutput(); err != nil { + t.Skipf("git init unavailable: %v (%s)", err, out) + } + if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("x\ny\nz\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := New(nil).Diff(repo) + if got.Files != 1 || got.Added != 3 || got.Deleted != 0 { + t.Errorf("Diff(no-commit repo) = %+v, want {Files:1 Added:3 Deleted:0}", got) + } +} From c86173c4ca6fe3d07822e386058dedc9f54b50ef Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:39:50 -0300 Subject: [PATCH 09/14] feat: rich session card tooltip (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session card tooltip showed only the working directory, so a long label clipped in the card was unreadable without widening the sidebar. Expand it into a mini card that opens to the right: full label, path, branch, PR and diff badge — reusing the git/pr data the card already computes. Theme it to match the card (bg-card, border, foreground/muted text) instead of the default inverted pill, and let the tooltip arrow inherit the popup background so it follows any themed instance. --- .../src/components/sidebar/SessionCard.tsx | 28 ++++++++++++++++++- frontend/src/components/ui/tooltip.tsx | 2 +- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/sidebar/SessionCard.tsx b/frontend/src/components/sidebar/SessionCard.tsx index 08758bf80..0f970dd98 100644 --- a/frontend/src/components/sidebar/SessionCard.tsx +++ b/frontend/src/components/sidebar/SessionCard.tsx @@ -206,7 +206,33 @@ export function SessionCard({ - {shownPath} + +
+ {session.label} + {shownPath} + {git?.branch && ( + + + + {git.branch} + + {pr && ( + + #{pr.number} + + )} + {git.files > 0 && ( + + + + )} + + )} +
+
setEditing(true)}> diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx index 96a9ec257..dc8b59d6b 100644 --- a/frontend/src/components/ui/tooltip.tsx +++ b/frontend/src/components/ui/tooltip.tsx @@ -54,7 +54,7 @@ function TooltipContent({ {...props} > {children} - + From 8a3aa3ff0c8ef6ed496d7c2581832f5cd0e8b2b8 Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:49:59 -0300 Subject: [PATCH 10/14] docs: changelog for session card tooltip and no-commit diff count (#31) Backfill the two [Unreleased] entries for PRs merged after the v0.6.0 cut that did not touch CHANGELOG.md: the rich session card tooltip (#30) and the fix for the +/- diff badge showing +0 -0 in a repo with no commits (#29). --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b164dc9eb..db2b51f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The session card tooltip is now a rich mini-card: full label, working path, + branch, open-PR badge and diff stat, opening to the right and themed to match + the card. A long label clipped in the card is readable on hover without + widening the sidebar. It reuses the git and PR data the card already computes. + +### Fixed + +- The session card and footer diff badge showed `+0 −0` in a repository with no + commits, even though the review panel rendered the full diff: `git diff + --numstat HEAD` errors without a HEAD and skipped the untracked-file count. It + now diffs against git's empty tree when HEAD is missing, and a numstat failure + no longer skips counting untracked additions. + ## [0.6.0] - 2026-07-16 ### Added From 8db199aa4c80f8127f00cbb107f841c960d73764 Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:54:07 -0300 Subject: [PATCH 11/14] fix: stop Windows console-window flood from child processes (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lich's Windows binary is built for the GUI subsystem (-H=windowsgui) and has no console of its own. Any console tool it spawns via exec.Command — git, gh, claude — therefore got a fresh console window allocated by the OS per call. With git status polled every ~3s per session card and the gh PR lookup firing on every window focus, the desktop filled with console windows and the machine became unusable. Add internal/winexec.Hide, which sets CREATE_NO_WINDOW on Windows and is a no-op everywhere else, and route every Windows-reachable console spawn through it: git/gh in internal/project (via a package-local command() helper) and the claude CLI in internal/claudeplugin. --- CHANGELOG.md | 7 +++++++ internal/claudeplugin/claudeplugin.go | 6 +++++- internal/project/difftext.go | 4 ++-- internal/project/project.go | 21 +++++++++++++++---- internal/project/worktree.go | 5 ++--- internal/winexec/winexec_other.go | 8 ++++++++ internal/winexec/winexec_other_test.go | 18 ++++++++++++++++ internal/winexec/winexec_windows.go | 25 +++++++++++++++++++++++ internal/winexec/winexec_windows_test.go | 26 ++++++++++++++++++++++++ 9 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 internal/winexec/winexec_other.go create mode 100644 internal/winexec/winexec_other_test.go create mode 100644 internal/winexec/winexec_windows.go create mode 100644 internal/winexec/winexec_windows_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index db2b51f79..c5afa0677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Windows no longer floods the desktop with console windows. lich's Windows + binary runs in the GUI subsystem (no console of its own), so every console + tool it shells out to — `git`, `gh`, `claude` — spawned a fresh console window + per call; with git status polled every few seconds per session and the PR + lookup firing on every window focus, the screen filled with windows and the + machine became unusable. Child console processes are now created with + `CREATE_NO_WINDOW` (`internal/winexec`), a no-op on every other OS. - The session card and footer diff badge showed `+0 −0` in a repository with no commits, even though the review panel rendered the full diff: `git diff --numstat HEAD` errors without a HEAD and skipped the untracked-file count. It diff --git a/internal/claudeplugin/claudeplugin.go b/internal/claudeplugin/claudeplugin.go index fb7bb0b65..5e31c724b 100644 --- a/internal/claudeplugin/claudeplugin.go +++ b/internal/claudeplugin/claudeplugin.go @@ -17,6 +17,8 @@ import ( "strconv" "strings" "time" + + "github.com/omartelo/lich/internal/winexec" ) const ( @@ -116,7 +118,9 @@ func (s *Service) runClaude(args ...string) error { } ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) defer cancel() - out, err := exec.CommandContext(ctx, bin, args...).CombinedOutput() + cmd := exec.CommandContext(ctx, bin, args...) + winexec.Hide(cmd) + out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("claude %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) } diff --git a/internal/project/difftext.go b/internal/project/difftext.go index 8ac53463e..3a5ffbf3e 100644 --- a/internal/project/difftext.go +++ b/internal/project/difftext.go @@ -41,7 +41,7 @@ func (s *Service) DiffText(path string) (string, error) { // untrackedFiles lists paths unknown to git, relative to the work tree root. // Errors yield an empty list — the tracked diff is still worth returning. func untrackedFiles(path string) []string { - out, err := exec.Command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output() + out, err := command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output() if err != nil { return nil } @@ -61,7 +61,7 @@ func untrackedDiff(dir, rel string) string { if info, err := os.Stat(filepath.Join(dir, rel)); err != nil || !info.Mode().IsRegular() || info.Size() > maxUntrackedDiffSize { return "" } - cmd := exec.Command("git", "-C", dir, "diff", "--no-index", "--", os.DevNull, rel) + cmd := command("git", "-C", dir, "diff", "--no-index", "--", os.DevNull, rel) out, err := cmd.Output() var exitErr *exec.ExitError if err != nil && (!errors.As(err, &exitErr) || exitErr.ExitCode() != 1) { diff --git a/internal/project/project.go b/internal/project/project.go index a8a012186..416a79ccf 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -17,8 +17,20 @@ import ( "strconv" "strings" "time" + + "github.com/omartelo/lich/internal/winexec" ) +// command builds an exec.Cmd for a child console tool (git, gh) with its +// console window suppressed on Windows — lich's GUI-subsystem binary would +// otherwise pop one per spawn (winexec.Hide). Used across this package instead +// of exec.Command so no polling call site can forget it. +func command(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + winexec.Hide(cmd) + return cmd +} + // Project identifies an opened project directory. type Project struct { ID string `json:"id"` @@ -66,7 +78,7 @@ func newProject(path string) *Project { // the current state on call, so a checkout made after opening is not reflected // until the branch is resolved again. func (s *Service) Branch(path string) string { - out, err := exec.Command("git", "-C", path, "symbolic-ref", "--short", "HEAD").Output() + out, err := command("git", "-C", path, "symbolic-ref", "--short", "HEAD").Output() if err != nil { return "" } @@ -95,6 +107,7 @@ func (s *Service) PullRequest(path string) *PullRequest { defer cancel() cmd := exec.CommandContext(ctx, "gh", "pr", "view", "--json", "number,url,state") cmd.Dir = path + winexec.Hide(cmd) out, err := cmd.Output() if err != nil { return nil @@ -139,7 +152,7 @@ type DiffStats struct { // Branch's contract. func (s *Service) Diff(path string) DiffStats { var stats DiffStats - if out, err := exec.Command("git", "-C", path, "status", "--porcelain").Output(); err == nil { + if out, err := command("git", "-C", path, "status", "--porcelain").Output(); err == nil { stats.Files = countLines(out) } // A repository without commits has no HEAD; diff against git's empty tree, @@ -148,7 +161,7 @@ func (s *Service) Diff(path string) DiffStats { if _, err := runGit(path, "rev-parse", "--verify", "HEAD"); err != nil { base = emptyTreeHash } - if out, err := exec.Command("git", "-C", path, "diff", "--numstat", base).Output(); err == nil { + if out, err := command("git", "-C", path, "diff", "--numstat", base).Output(); err == nil { for line := range strings.Lines(string(out)) { cols := strings.Fields(line) if len(cols) < 3 { @@ -163,7 +176,7 @@ func (s *Service) Diff(path string) DiffStats { } // Untracked files are invisible to `git diff`; count their lines as // additions, the way Warp and forge diff views present a fresh file. - if out, err := exec.Command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output(); err == nil { + if out, err := command("git", "-C", path, "ls-files", "--others", "--exclude-standard", "-z").Output(); err == nil { for _, rel := range strings.Split(string(out), "\x00") { if rel != "" { stats.Added += countFileLines(filepath.Join(path, rel)) diff --git a/internal/project/worktree.go b/internal/project/worktree.go index e05aa4247..556362e8a 100644 --- a/internal/project/worktree.go +++ b/internal/project/worktree.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "os" - "os/exec" "path/filepath" "strings" ) @@ -28,7 +27,7 @@ type Branches struct { // deliberately swallow errors for polling, failures here carry git's stderr in // the message so the frontend can show it verbatim. func runGit(dir string, args ...string) (string, error) { - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd := command("git", append([]string{"-C", dir}, args...)...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() @@ -198,7 +197,7 @@ func (s *Service) WorktreeDirty(wtPath string) (bool, error) { // branchExists reports whether refs/heads/ exists in the repository. func branchExists(projectPath, name string) bool { - err := exec.Command("git", "-C", projectPath, "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run() + err := command("git", "-C", projectPath, "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run() return err == nil } diff --git a/internal/winexec/winexec_other.go b/internal/winexec/winexec_other.go new file mode 100644 index 000000000..ca8ec560b --- /dev/null +++ b/internal/winexec/winexec_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package winexec + +import "os/exec" + +// Hide is a no-op off Windows, where a console child has no window to hide. +func Hide(cmd *exec.Cmd) {} diff --git a/internal/winexec/winexec_other_test.go b/internal/winexec/winexec_other_test.go new file mode 100644 index 000000000..cd5badb1c --- /dev/null +++ b/internal/winexec/winexec_other_test.go @@ -0,0 +1,18 @@ +//go:build !windows + +package winexec + +import ( + "os/exec" + "testing" +) + +// Off Windows there is no console window to hide, so Hide must leave the +// command untouched — anything else would be a portability surprise. +func TestHideIsNoOp(t *testing.T) { + cmd := exec.Command("git", "--version") + Hide(cmd) + if cmd.SysProcAttr != nil { + t.Fatalf("Hide set SysProcAttr off Windows: %#v", cmd.SysProcAttr) + } +} diff --git a/internal/winexec/winexec_windows.go b/internal/winexec/winexec_windows.go new file mode 100644 index 000000000..3920d95be --- /dev/null +++ b/internal/winexec/winexec_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +// Package winexec suppresses the console window Windows would otherwise +// allocate for every console tool lich shells out to (git, gh, claude). lich's +// Windows binary is built for the GUI subsystem (-H=windowsgui) and so has no +// console of its own; without CREATE_NO_WINDOW, each such child pops a fresh +// console window on the user's desktop — and the git-status poll spawning one +// every few seconds per session buries the screen. Off Windows this is a no-op. +package winexec + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// Hide sets the creation flag that keeps cmd from allocating a console window. +func Hide(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.HideWindow = true + cmd.SysProcAttr.CreationFlags |= windows.CREATE_NO_WINDOW +} diff --git a/internal/winexec/winexec_windows_test.go b/internal/winexec/winexec_windows_test.go new file mode 100644 index 000000000..0d9ffe475 --- /dev/null +++ b/internal/winexec/winexec_windows_test.go @@ -0,0 +1,26 @@ +//go:build windows + +package winexec + +import ( + "os/exec" + "testing" + + "golang.org/x/sys/windows" +) + +// The whole point on Windows: Hide must set CREATE_NO_WINDOW so lich's +// windowless GUI binary does not spawn a console window per child. +func TestHideSetsCreateNoWindow(t *testing.T) { + cmd := exec.Command("git", "--version") + Hide(cmd) + if cmd.SysProcAttr == nil { + t.Fatal("Hide left SysProcAttr nil on Windows") + } + if cmd.SysProcAttr.CreationFlags&windows.CREATE_NO_WINDOW == 0 { + t.Fatalf("CREATE_NO_WINDOW not set: flags=%#x", cmd.SysProcAttr.CreationFlags) + } + if !cmd.SysProcAttr.HideWindow { + t.Fatal("HideWindow not set") + } +} From bdd314fd8b77c8b0c899b96623c47f53139f996c Mon Sep 17 00:00:00 2001 From: martelo <125408621+omartelo@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:59:10 -0300 Subject: [PATCH 12/14] chore: cut v0.7.0 (#33) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5afa0677..67e48227c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2026-07-16 + ### Added - The session card tooltip is now a rich mini-card: full label, working path, @@ -367,7 +369,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 CPU, costing ~40ms per frame in a full-size window. Under Xwayland typing is stall-free at full frame rate. -[Unreleased]: https://github.com/omartelo/lich/compare/v0.6.0...HEAD +[Unreleased]: https://github.com/omartelo/lich/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/omartelo/lich/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/omartelo/lich/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/omartelo/lich/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/omartelo/lich/compare/v0.3.0...v0.4.0 From 37b33e0191c673c0c0d3f0db5acde17b8edcd351 Mon Sep 17 00:00:00 2001 From: JoJowAkaGioGiow Date: Fri, 17 Jul 2026 10:56:10 -0300 Subject: [PATCH 13/14] docs: changelog for Helium browser support --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e48227c..83efb27a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Helium Browser is now accepted as a Linux Chromium-family shell. The launcher + probes `helium-browser` alongside Chromium, Google Chrome and Brave, and the + install/runtime dependency checks document it as a supported browser. + ## [0.7.0] - 2026-07-16 ### Added From d98cc666441904d190565e552ce90af4bf4a504a Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 17 Jul 2026 12:17:30 -0300 Subject: [PATCH 14/14] test: fix build tag that broke the macOS release suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit candidates_unix_test.go was tagged `!windows`, but the file it exercises (candidates_unix.go) is `!windows && !darwin`. On darwin the test still compiled in and called the *darwin* browserCandidates(), whose list is the .app paths plus chromium/google-chrome — so chromium-browser, google-chrome-stable, helium-browser and brave were all absent and the membership loop hit t.Fatalf. Per-PR CI is Linux-only so it stayed green; the macOS job only runs on a release tag (release.yml), where it would turn red. - Tag the test `!windows && !darwin` so it compiles only where that browserCandidates() actually lives. - Pin the exact Unix list with slices.Equal, mirroring the darwin/windows tests — membership alone missed order and stray-entry regressions. - CI: cross-compile build + vet for linux/darwin/windows on every PR, so a broken build tag fails the PR instead of a release tag. --- .github/workflows/ci.yml | 13 ++++++++++++ internal/chromium/candidates_unix_test.go | 24 +++++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 279ef6ed8..c8d2dd380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,19 @@ jobs: - name: go vet run: go vet ./... + # Per-PR CI runs on Linux only; the Windows and macOS suites run at release + # time. Compile every OS's build here so a broken build tag — a _test.go + # that falls into the wrong OS's build, an OS-only file that stops + # compiling — fails the PR instead of a release tag. vet builds the + # _test.go files too, so it catches test-only tag mistakes `go build` misses. + - name: Cross-compile all targets + run: | + for os in linux darwin windows; do + echo "== $os ==" + CGO_ENABLED=0 GOOS=$os go build ./... + CGO_ENABLED=0 GOOS=$os go vet ./... + done + - name: Frontend tests run: bash .github/scripts/web-test-summary.sh diff --git a/internal/chromium/candidates_unix_test.go b/internal/chromium/candidates_unix_test.go index b737fff72..d233179e9 100644 --- a/internal/chromium/candidates_unix_test.go +++ b/internal/chromium/candidates_unix_test.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !darwin package chromium @@ -7,18 +7,26 @@ import ( "testing" ) -func TestUnixBrowserCandidatesIncludeSupportedChromiumFamilyBrowsers(t *testing.T) { - candidates := browserCandidates() - for _, want := range []string{ +// TestUnixBrowserCandidates pins the exact Linux/BSD candidate list in +// preference order: bare PATH names (Linux/BSD installs live on PATH), Chromium +// first, Helium probed before Brave. It mirrors the exact-list style of +// TestWindowsBrowserCandidates / TestDarwinBrowserCandidates — a membership +// check would miss an order regression or a stray entry. +// +// The build tag matches candidates_unix.go (`!windows && !darwin`) on purpose: +// under a bare `!windows` this test also compiles into the darwin build, where +// browserCandidates() returns the .app list and none of these bare names, so +// the assertion would fail the macOS release suite. +func TestUnixBrowserCandidates(t *testing.T) { + want := []string{ "chromium", "chromium-browser", "google-chrome-stable", "google-chrome", "helium-browser", "brave", - } { - if !slices.Contains(candidates, want) { - t.Fatalf("browserCandidates() = %v, missing %q", candidates, want) - } + } + if got := browserCandidates(); !slices.Equal(got, want) { + t.Fatalf("browserCandidates() = %v, want %v", got, want) } }