From bd25b73b70ae4cb425bb4e4293e0a974442297bc Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 12:58:17 -0400 Subject: [PATCH 01/16] docs: add 3-repo release orchestration design spec Approved design for finishing the GitHub App/OIDC migration, a self-owned release.py single-source-of-truth tool, fail-loud promotion, and broadened distribution (Scoop/GHCR/Nix/WinGet). Phased P0->P2 roadmap. Produced via a multi-agent design workflow, ground-truthed against the live repos. --- ...2026-05-31-release-orchestration-design.md | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-release-orchestration-design.md diff --git a/docs/superpowers/specs/2026-05-31-release-orchestration-design.md b/docs/superpowers/specs/2026-05-31-release-orchestration-design.md new file mode 100644 index 00000000..0fbc4117 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-release-orchestration-design.md @@ -0,0 +1,324 @@ +--- +title: Release & Distribution Orchestration — Design Spec +status: approved-design +date: 2026-05-31 +scope: TechMatrix-labs/{pythinker-code, homebrew-pythinker, pythinker-home} +provenance: multi-agent design workflow wf_620b356a-d6e (5 subsystem designs -> adversarial stress-tests -> synthesis -> completeness critic -> final merge); ground-truthed against live repos + PyPI on 2026-05-31 +--- + +# Pythinker-code 3-Repo Release & Distribution Orchestration — Final Design Spec + +**Scope:** TechMatrix-labs/{pythinker-code, homebrew-pythinker, pythinker-home}. Coherent evolution — no consolidation, no churn. Respects C1–C5. This is the approval-ready architecture; every punch-list item from the completeness critique is resolved inline (fixed, or justified out-of-scope in one line). Ground-truthed against the live repos on 2026-05-31. + +--- + +## 1. Target Architecture Overview + +``` + ┌──────────────────────────────────────────────────────────────┐ + │ SSOT = pyproject.toml:3 (the ONLY authoritative version) │ + │ scripts/release.py rewrites all derived files + uv.lock, │ + │ runs local gates, opens release/X.Y.Z PR (never pushes main) │ + └───────────────────────────────┬──────────────────────────────┘ + │ merge (CodeRabbit status=success, C2) + │ then human pushes tag(s): sub-pkg tags first, then vX.Y.Z + ┌──────────────────────────────────────────────────────────▼───────────────────────────────────────────┐ + │ TechMatrix-labs/pythinker-code (PUBLIC, source of truth) │ + │ │ + │ on tag v* : release-cli ┃ linux-installer ┃ windows-installer ┃ homebrew-tap ┃ docker(P2) ┃ promote │ + └───┬──────────────┬──────────────┬───────────────┬───────────────────┬─────────────────────┬───────────┘ + │ OIDC │ GITHUB_TOKEN │ GITHUB_TOKEN │ App: tap-publisher │ GITHUB_TOKEN │ App: + │ (trusted pub) │ contents:write│ contents:write │ (per-run token) │ packages:write (GHCR) │ release-bot + ▼ ▼ ▼ ▼ ▼ ▼ (per-run) + ┌─────────┐ ┌───────────────────────────────────┐ ┌──────────────────────┐ ┌────────────┐ ┌─────────────────┐ + │ PyPI │ │ GitHub Release (~28 assets+.sha) │ │ homebrew-pythinker │ │ ghcr.io/ │ │ pythinker-home │ + │ TestPyPI│ │ prerelease→(promote)→make_latest │ │ (PUBLIC) Formula/*.rb│ │ pythinker- │ │ (PRIVATE site) │ + │ (OIDC) │ │ /releases/latest (prerelease-excl)│ │ App contents:write │ │ code (P2) │ │ │ + └────┬────┘ └─────────────────┬─────────────────┘ └──────────────────────┘ └────────────┘ │ repository_ │ + │ │ ▲ ▲ │ dispatch │ + │ pip/pipx/uv │ curl|sh, irm|iex, │ scoop(P2)│ winget(P2 manual PAT) │ sync-pythinker- │ + │ (core/host/review pins │ in-app updater read here │ App:scoop│ │ products │ + │ resolve = BLOCKING) │ │ publisher│ └────────┬────────┘ + ▼ ▼ │ │ │ own GITHUB_TOKEN + end users end users scoop-pythinker microsoft/winget-pkgs │ (pull data) + (PUBLIC, P2) (external, P2) ▼ + sync-upstream-products.ts + pulls /releases/latest + raw@tag, + writes pythinkerCodeRelease.ts + + public/install.{sh,ps1} + version.json, + commit→push main ─► Dokploy build-from-source + (nixpacks → bun run server.ts) ─► pythinker.com + ▲ + daily cron 04:17 + drift reconcile (backstops) + + Nix: nix run github:TechMatrix-labs/pythinker-code (P2: needs apps.default — currently UNVERIFIED; auth=none) + + AUTH EDGE SUMMARY (every cross-repo / privileged write): + PyPI/TestPyPI ............ OIDC trusted publishing (no token) [unchanged, GOOD] + GH Release asset upload .. same-repo GITHUB_TOKEN contents:write [unchanged, least-priv] + promote flip ............. same-repo GITHUB_TOKEN contents:write [unchanged] + Homebrew tap write ....... App pythinker-tap-publisher (per-run) [unchanged, the pattern] + Site dispatch (×2 files) . App pythinker-release-bot (per-run) [NEW P0 — replaces PAT] + Scoop bucket write (P2) .. App pythinker-scoop-publisher (per-run) [NEW, P2] + GHCR push (P2) ........... same-repo GITHUB_TOKEN packages:write [NEW, P2, OIDC-native] + WinGet submit (P2) ....... fine-grained PAT, MANUAL workflow_dispatch [P2, deferred, isolated] + core gh-pages docs ....... RETIRED (target 404); PAT deleted [PAT eliminated] + Site data pull ........... site's own GITHUB_TOKEN [unchanged, token-safe] +``` + +After P0+P1: **zero long-lived cross-repo PATs** on the release path. The only remaining PAT (WinGet, P2) is isolated to a manual, opt-in workflow. + +--- + +## 2. The Unified Release Flow (end-to-end) + +### Happy path + +1. **Maintainer authors the narrative** (irreducible human step, C5): writes/moves the `## Unreleased` bullets in `CHANGELOG.md` describing this release, and writes the matching README "What's New" bullets. (The tool rewrites the *heading + version snippet + asset names*; the human writes prose — all in one PR, satisfying C4.) +2. **Maintainer runs** `python scripts/release.py --set-version X.Y.Z` (add `--bump-core A.B.C` / `--bump-host A.B.C` if a sub-package also moves). **There is intentionally no `--bump-review`** — see §3. +3. **Tool — Phase 1 (validate preconditions, fail loud, no writes):** clean tree; `git fetch origin` and assert local `main` == `origin/main` (kills the stale-base class); compute target, assert monotonic + semver; assert a `## Unreleased` section exists. If its body is empty → **warn, not abort** (a CI-only/docs patch release may legitimately have no shipped-code entry; the `changelog-entry-required.yml` gate already skips `release/*` PRs). +4. **Tool — Phase 2 (rewrite derived files, atomic):** `pyproject.toml:3`; if `--bump-*`, sub-package `version` + matching root pin `pythinker-core[contrib]==` (`pyproject.toml:28`) / `pythinker-host==` (`pyproject.toml:47`) in the same transaction; **regenerate `uv.lock`** (`uv lock`); promote `## Unreleased` → `## X.Y.Z (DATE)` in `CHANGELOG.md` + `docs/en/release-notes/changelog.md` (+ breaking-changes heading); rewrite README heading/snippet/asset-names + `packages/linux-installer/README.md` + `docs/en/guides/getting-started.md:47` `PythinkerSetup-X.Y.Z.exe`. Pyproject/sub-package writes use **tomlkit** (already a dep at `pyproject.toml:48`) **with a `tomllib` parse-back assertion** that the pin string re-reads to the intended version — not bare regex on the dependency-string array. +5. **Tool — Phase 3 (local gates = the same gates CI runs):** `check_version_tag.py`; `check_pythinker_dependency_versions.py` (**extended to also assert the `pythinker-review==0.1.0` pin matches `packages/pythinker-review` — §3**); README/CHANGELOG fixed-string greps (`grep -qF`, not unescaped-dot regex); `uv sync --frozen --all-extras --all-packages` (catches lock drift before push); `pytest tests/test_version_lockstep.py`. Any failure aborts **before** the branch leaves the laptop. +6. **Tool — Phase 4 (branch+PR, never main, C1):** `git switch -c release/X.Y.Z` → commit `chore(release): prepare X.Y.Z` → push → `gh pr create --base main`. Branch prefix `release/*` and title `chore(release)` are a **hard contract** with `changelog-entry-required.yml`'s skip logic (it guards on **both** the title `chore(release)*` at line 54 AND head branch `release/*` at line 57 — verified; documented + asserted in a test). Prints the post-merge tag command(s); when `--bump-*` was used, prints the ordered sequence: push **sub-package tags first** (`pythinker-core-A.B.C`, `pythinker-host-A.B.C`), wait for their OIDC publish jobs to land on PyPI, **then** `vX.Y.Z`. +7. **Reviewer** confirms CodeRabbit commit status == `success` on the PR head (C2, enforced by the existing merge-gate hook), merges. +8. **Maintainer pushes the tag(s)** (the deliberate last human action) in the printed order. For a pure code release: `git tag vX.Y.Z && git push origin vX.Y.Z`. +9. **CI fires (unchanged build topology):** release-cli (5-platform PyInstaller → GitHub Release as **prerelease**), linux-installer, windows-installer, homebrew-tap, [docker P2], and **promote-release**. +10. **promote-release polls BLOCKING channels only** (see §5 for the exact set): GitHub Release assets + **PyPI publication of `pythinker-code` AND of every pinned sub-package (`pythinker-core`, `pythinker-host`, `pythinker-review`)**. Homebrew is **removed** from the gate (§5). On all-ready → `PATCH prerelease=false, make_latest=true`. `/releases/latest` now serves X.Y.Z; `pip install pythinker-code==X.Y.Z` resolves all transitive pins. Ticks the readiness-issue rows. +11. **Separate `needs: promote` job** mints the `pythinker-release-bot` token and fires `repository_dispatch sync-pythinker-products` to pythinker-home (best-effort; not part of the promote success signal). +12. **pythinker-home** `sync-upstream-products.yml` pulls `/releases/latest` + `raw@`, writes `pythinkerCodeRelease.ts` + `public/install.{sh,ps1}` + `public/version.json`, commits, pushes main → Dokploy build-from-source → `bun run server.ts` → **pythinker.com live at X.Y.Z**. +13. **Every channel live:** PyPI (pip), GitHub Release (curl/irm/updater), Homebrew tap (best-effort, reconciled), website (best-effort, reconciled), [Docker/Scoop/Nix P2]. + +### Failure path (fail-loud, never silent-stuck) + +- **Local gate fails (Phase 3):** tool aborts, nothing pushed. Maintainer fixes locally. +- **Sub-package pin lags PyPI:** the new blocking check (§5, item 10 above) keeps the release in `PRERELEASE` until `pythinker-core/host/review` pins all return 200 on PyPI — `/releases/latest` keeps serving last-good and `pip install pythinker-code==X` is never advertised against an unresolvable pin. This converts today's silent race into a tracked bottleneck row. +- **CI build job crashes / asset missing:** promote's bounded poll (80×30s ≈ 40 min, justified by emulated-arm64 long pole) exhausts → **FAILED_STUCK**: release **stays prerelease** (so `/releases/latest` keeps serving last-good — never stale-but-looks-live; this is *current* behavior at `promote-release.yml:152`, retained deliberately), readiness issue gets the **exact per-channel bottleneck** comment, Slack red alert with issue link (reusing the existing `notify-failure` job), job exits 1 (red CI). **No demotion to draft** (draft hides the partial assets needed for re-run). Re-run: fix/re-run the failing build workflow, then re-run promote (`workflow_dispatch tag=vX.Y.Z`) — re-enters CHECKING; PATCH is idempotent. +- **Site dispatch fails (missing/empty minted token):** the separate dispatch job goes red with Slack "**site sync failed — release IS promoted, /releases/latest is correct, self-heals via drift reconcile / daily cron**" (does NOT contaminate the promote success signal). **Empty minted token fails loud (`exit 1`)** — the old silent `::notice; exit 0` (`promote-release.yml:172-174`) is removed in both files. +- **Site mirrors stale source (Mode B, the 0.24.0 class):** served `version.json` ≠ `/releases/latest` → drift reconcile detects, re-dispatches, and on persistent drift across N cycles escalates to red Slack. (The App migration alone would NOT catch this — see §5.) + +--- + +## 3. Version Single-Source-of-Truth (kill F3 sprawl) + +**Source:** `pyproject.toml:3` `[project].version`. The installed package reads `importlib.metadata.version("pythinker-code")` (`constant.py:14`) — there is no `__version__` constant, so this is genuinely the only authoritative store. + +**The SSOT→site chain (proves distribution + site consume the orchestrator's source):** +``` +pyproject.toml:3 ─(release.py rewrite)→ release PR ─(check_version_tag gate)→ merge + → human tag vX.Y.Z ─(CI)→ GitHub Release ─→ /releases/latest.tag_name + → site sync writes public/version.json {pythinkerCode: X.Y.Z} AND brew formula version "X.Y.Z" AND scoop manifest +``` + +**Three derivation mechanisms (collapses F3's ~41 refs to 1 field + 1 narrative):** +- **(A) reads SSOT at build/run time — never edited:** installed-package runtime (`constant.py`), web bundle (`web/vite.config.ts:9`), web strict-gate (`scripts/build_web.py`), CI release version (`${REF#v}` from tag), Homebrew/Scoop generators (poll the release tag), Nix (`lib.importTOML ./pyproject.toml`), PyPI version badge (shields.io live). +- **(B) templated from SSOT — never edited:** `packages/windows-installer/{installer.iss,versioninfo.txt}` (substituted by `build.ps1 -Version`). +- **(C) rewritten by `release.py` from SSOT:** `pyproject.toml:3`; `uv.lock` (regenerated via `uv lock`); sub-package pyprojects + root pins (atomic with `--bump-*`); `CHANGELOG.md` + `docs/.../changelog.md` + `breaking-changes.md` headings (body preserved, C5); `README.md` heading/snippet/asset-names; `packages/linux-installer/README.md`; `docs/en/guides/getting-started.md:47`. +- **Documented exception:** `--version 0.27.0` *flag examples* in install-script comments + `getting-started.md:34` are NOT rewritten (they teach flag syntax; scripts resolve latest at runtime). The lockstep test asserts they're valid-semver *shape*, not equality. + +**The `pythinker-review` sub-package (critique item 2 — DECIDED, fold-in + freeze):** +`pythinker-review==0.1.0` is a pinned runtime dep (`pyproject.toml:48`), a workspace member (`pyproject.toml:89`), and a `tool.uv.sources` workspace source (`pyproject.toml:97`) — but it has **no release workflow** and is not covered by `check_pythinker_dependency_versions.py` (which checks only core+host at lines 66-67). It escapes the entire anti-sprawl mechanism today. Decision: **freeze it at 0.1.0 AND enforce the freeze in all three guards.** +- `check_pythinker_dependency_versions.py` gains a third tuple `("pythinker-review", args.pythinker_review_pyproject)` so the pin and `packages/pythinker-review` version must agree. +- `tests/test_version_lockstep.py` includes `pythinker-review` in its pin-vs-version assertion. +- The promote PyPI-existence check (§5) treats `pythinker-review` identically to core/host: its pinned version must resolve on PyPI before the flip. +- **No `--bump-review` flag.** Review has no release workflow, so a CLI bump would have nothing to publish it and would break `pip install`. If review ever needs to move, that is a future phase that first adds a release workflow; until then the guards make the freeze *enforced*, not aspirational. (Verified 2026-05-31: `pythinker-review/0.1.0`, `pythinker-core/1.1.1`, `pythinker-host/1.0.0` all return PyPI 200 — the freeze is currently consistent and there is no live `pip install` break.) +- **`pythinker-sdk` is correctly out of scope:** it is a workspace member but is **not** a pinned runtime dependency of `pythinker-code` (absent from `[project].dependencies`), so it cannot strand a `pip install`. No guard needed. + +**Lockstep test** (`tests/test_version_lockstep.py`, stdlib+tomllib, runs in CI test matrix on every PR): semver shape; **core/host/review pins == sub-package versions**; README has `What's New in X` + `pythinker-code==X`; README + linux-README + getting-started.md asset names (`PythinkerSetup-(semver).exe`, `*_(semver)_amd64.deb`, `*-(semver).x86_64.rpm`, `/releases/download/v(semver)/`) all == VERSION; CHANGELOG has `## X (`; install-script flag-examples are valid semver shape. Tests only assert relationships true on **every** main commit (never "a tag exists"). + +**Tool label correction:** `scripts/release.py` is **stdlib + shells out to git/gh/uv** (not stdlib-only). C3 permits this (CI/release tooling exemption); the shipped agent gains zero runtime deps. + +--- + +## 4. Auth Topology (org-migration-proof) + +**Canonical App inventory (3 Apps, all org-owned):** + +| App / credential | Scope | Permissions | Secrets | Status | +|---|---|---|---|---| +| `pythinker-tap-publisher` | `homebrew-pythinker` only | contents:write, metadata:read | `HOMEBREW_TAP_APP_ID/_PRIVATE_KEY` (**leave at repo level — moving = churn**) | exists, unchanged | +| `pythinker-release-bot` | `pythinker-home` only | contents:write, metadata:read | `PYTHINKER_RELEASE_BOT_APP_ID/_PRIVATE_KEY` (org-level) | **NEW (P0)** — one App for **both** dispatch sites | +| `pythinker-scoop-publisher` | `scoop-pythinker` only | contents:write, metadata:read | `SCOOP_BUCKET_APP_ID/_PRIVATE_KEY` | **NEW (P2)** | + +**Same-repo / OIDC (no App needed):** PyPI+TestPyPI (OIDC trusted publishing), all GitHub Release asset uploads + promote flip (same-repo `GITHUB_TOKEN` contents:write), GHCR push P2 (`GITHUB_TOKEN` packages:write), site data pull (site's own `GITHUB_TOKEN`). + +**Decision — dedicated `pythinker-release-bot`, not broadening the tap App:** `homebrew-pythinker` is **public**, `pythinker-home` is **private**. A single key spanning that trust boundary means one leak compromises both; a dedicated key contains each leak to one trust domain and keeps `git log`/audit honest. Marginal cost is two org secrets + one install click. + +**PATs eliminated:** +- `PYTHINKER_HOME_REPO_DISPATCH_TOKEN` → `pythinker-release-bot` (replaces it in **both** `promote-release.yml:168` AND `dispatch-pythinker-home-sync.yml:28`). Delete only after both files migrate + one green cycle. +- `PYTHINKER_CORE_PAGES_TOKEN` → **retired**. Target `PythinkerAI/pythinker-core` is a genuine 404 post-migration (and `TechMatrix-labs/pythinker-core` is also 404 — core exists only as a monorepo sub-package). **Drop the pdoc gh-pages step entirely** (`release-pythinker-core.yml:99-127`, including the PAT-skip swallow at lines 104-105) — Auth Option A. The VitePress docs are the docs surface and nothing user-facing has rendered there since the migration. (Option B is rejected: pdoc runs on core tags but `docs-pages.yml` deploys only on push-to-main, so it would silently deploy nothing.) + +**Residual blast radius (stated honestly):** `contents:write` is the floor for `POST /dispatches` (there is no narrower fine-grained permission). A leaked ~1h `pythinker-release-bot` token could push commits to the private site, not merely dispatch. Mitigated by single-repo install + ~1h TTL + per-run minting. Receiver-side defense: `sync-upstream-products.yml` adds a job-level `if:` gating on `client_payload.source_repo == 'TechMatrix-labs/pythinker-code'` (cron/manual carry no payload → allowed; the sync workflow currently has only `if: failure()` on notify and no payload gate — this is genuinely net-new); payload fields are **never** interpolated into a `run:` shell line (injection vector — the sync derives everything from the live API by repo name). + +--- + +## 5. Fail-Loud Reliability State Machine + +**What is ALREADY current behavior (critique item 3 — do not re-build):** `promote-release.yml` already has a `notify-failure` Slack job (lines 188-200) and its bare `exit 1` (line 152) already leaves the release as **prerelease** (it never demotes to draft). So "stays-prerelease + Slack + red CI" is *current behavior*. The genuine net-new in this section is **only**: (a) the sub-package PyPI-existence blocking check, (b) per-channel bottleneck detail inside the Slack message, (c) the `release-readiness` tracking issue, and (d) removing Homebrew from the gate. + +**Terminal states:** `PROMOTED` (success), `FAILED_STUCK` (loud failure, release retained as prerelease, tracking issue open, red CI). `DEGRADED` is transient (promoted but best-effort channel reconciling) and **never un-promotes**. + +``` +tag push → PRERELEASE (/latest = last good) + → CHECKING (poll BLOCKING set: GH assets + PyPI(pythinker-code) + PyPI(core,host,review pins)) + ├─ all blocking ready → PATCH prerelease=false, make_latest → PROMOTED (/latest = X.Y.Z; pip fully resolvable) + │ └─ [separate needs:promote job] best-effort site dispatch → (site live) or → DEGRADED + └─ budget exhausted → FAILED_STUCK (stays PRERELEASE; per-channel bottleneck → issue comment + + Slack + step-summary; exit 1) → fix/re-run build (or wait on lagging sub-pkg publish) + → re-run promote → back to CHECKING + DEGRADED → daily drift reconcile (tap formula != latest OR served version.json != latest) + → re-dispatch/re-run (idempotent) → PROMOTED ; persistent N cycles → red Slack +``` + +**Blocking vs best-effort — discriminator "does it feed `/releases/latest` or pip?":** +- **BLOCKING (gate the flip):** GitHub Release assets; PyPI publication of `pythinker-code`; **PyPI resolvability of every pinned sub-package — `pythinker-core` 1.1.1, `pythinker-host` 1.0.0, `pythinker-review` 0.1.0** (critique item 1 + item 2, unified into one uniform "all pinned sub-packages resolve" check via `GET pypi.org/pypi///json == 200`). Today the poll checks only `pythinker-code/${version}` (`promote-release.yml:95`), so a near-simultaneous tag push can flip `/releases/latest` to PROMOTED while `pip install pythinker-code==X` 500s on a lagging `pythinker-core`. This is the single largest reliability gap and it is closed here. +- **BEST-EFFORT (report, never block):** Homebrew tap, website, [Docker/Scoop P2]. +- **Change:** Homebrew is **release-blocking today** (`promote-release.yml:126-129` `&& homebrew_ready`). **Remove it from the gate.** Justification is **blast-radius, not symmetry**: a cross-repo tap push failure should not strand `/releases/latest`. The tap is reliably reconciled (its own flag-safe re-run) — best-effort is correct. + +**Why prerelease is the complete gate:** all four "latest" consumers honor it — `install-native.sh` + updater `native.py` + site sync use REST `/releases/latest` (prerelease-excluding by contract); `install.ps1` explicitly skips `$release.prerelease` (`scripts/install.ps1:152`). So a stuck prerelease leaves every consumer on **last-good** — the failure is silent-stuck, not consumer-breakage. **Terminal failure = KEEP-PRERELEASE, never draft.** + +**The two 0.24.0 modes (ONE story across §4/§5/§7):** +- **Mode A — credential breaks** (PAT owner leaves org). Defense: org-owned App (no user dependency, ~1h tokens) **+ fail-loud on empty minted token**. +- **Mode B — source identity goes stale** (owner/org literal baked into a sync URL builder; `raw.githubusercontent` doesn't follow transfer redirects). **0.24.0 was Mode B.** The daily cron did NOT save it (re-ran the same wrong owner). Defense: per-product owner config (already present, §7) + a new lockstep assertion + fixing the one residual hardcoded literal **+ served `version.json` drift detection** that turns a silent freeze into a loud tracked DEGRADED + re-dispatch. The App migration alone would NOT have caught 0.24.0. + +**Idempotent re-runs:** PATCH flip (no-op if already promoted), PyPI `skip-existing`, tap flag-safe diff, dispatch naturally idempotent (commit-on-diff), readiness issue **upsert by REST exact-title list** (not search API — search is async-indexed and races duplicates). Per-tag `concurrency` + `cancel-in-progress:false` serializes tag-push and manual re-promote. **Guard:** never re-run a build workflow against an already-promoted release (it re-marks prerelease=true); reconcile detects `latest==version && prerelease==true` and re-flips. Reconcile auto-closes stale `release-readiness` issues older than `/latest`. + +**Observability = ONE artifact:** a per-release `release-readiness` GitHub issue (now a 6-row checklist: GH assets, PyPI code, PyPI sub-package pins, tap, site `version.json`, [docker P2]) is both the "is X.Y.Z fully live?" pane and the failure-tracking artifact. (`promote` job needs `issues:write`.) + +**Installer robustness (minimal):** `install-native.sh` — exponential backoff (4→120s, ≈6m) on asset-wait; keep REST `/releases/latest` (no prerelease-skip added — over-correction). `install.ps1` — fix the genuine `per_page=20` pagination cliff (`scripts/install.ps1:144`): hit `/releases/latest` first, **preserve the existing `.exe` + `.sha256` asset-pair guard** (`scripts/install.ps1:158`) and the `$release.prerelease` skip (line 152), paginated scan only as fallback; add matching backoff. Both keep SHA-256 verification (the real "half-published unreachable" guarantee). + +**Monotonic guard correction (Trigger §5.4):** strict `version >= committed` **breaks rollback** (a yanked release legitimately lowers `/latest`) and misses higher-but-wrong-repo values. Replace with: **valid-semver tag that exists as a published non-prerelease release**. The served `version.json` exact-match is what catches Mode B. + +**Ordering constraint (critique item 6):** `release-readiness-reconcile.yml` (the new Homebrew backstop) **must land before or in the same change set as Homebrew-gate-removal**. Otherwise there is a window where the tap has neither a promote gate nor a reconcile backstop. This is a hard P0 sequencing rule. + +--- + +## 6. Broadened Distribution Matrix + +| Channel | Produced | Stored | Auto-updated | Auth | Block? | +|---|---|---|---|---|---| +| **PyPI/TestPyPI** | wheel/sdist (+ core/host/review sub-pkgs) | PyPI | tag CI | OIDC | **BLOCKING** (code + all pinned sub-pkgs resolve) | +| **GitHub Release** | PyInstaller ×5 + installers + .sha256 | repo releases | tag CI | GITHUB_TOKEN | **BLOCKING** | +| **Homebrew tap** | `generate-formula.py` polls onedir tarballs | homebrew-pythinker | `homebrew-tap.yml` poll | App tap-publisher | best-effort | +| **Website** | sync.ts pulls latest+raw@tag | pythinker.com (Dokploy) | dispatch + cron + reconcile | App release-bot | best-effort | +| **Docker (GHCR)** P2 | `python:3.14-slim` + `pip install pythinker-code==${V}` (reuses PyPI wheel, C3-safe), multi-arch native amd64+arm64, push-by-digest, imagetools stitch, **ancestor-check** before `:latest` (`fetch-depth:0` + refuse-on-unknown-:latest) | `ghcr.io/TechMatrix-labs/pythinker-code` | `push: tags: v*` | GITHUB_TOKEN packages:write (**zero new secrets, best auth fit**) | best-effort | +| **Scoop** P2 | NEW `packages/scoop-bucket/generate-manifest.py` points at the **EXISTING** `pythinker-{v}-x86_64-pc-windows-msvc-onedir.zip` + its `.sha256` (verified produced by `release-pythinker-cli.yml`); `bin: pythinker\pythinker.exe`. Generator polls the **windows zip specifically** (NOT a clone of generate-formula.py's mac/linux NATIVE_TARGETS) | `TechMatrix-labs/scoop-pythinker` (PUBLIC, **org-owned**) `bucket/pythinker-code.json` | `scoop-bucket.yml` poll | App scoop-publisher (**org-owned**) | best-effort | +| **Nix** P2 | flake builds via uv2nix with `packages.default = pythinker-code` (`flake.nix:129`) — **but there is NO `apps` stanza** (verified), so `apps.default` + `nix build .#default`/`nix run` CI check is genuine net-new; `nix run github:…` working is **UNVERIFIED** until `apps.default` exists; monthly `update-flake-lock` PR | git repo (no artifact store); version from `pyproject.toml` | flake.lock cron PR | none | build-time CI gate | +| **WinGet** P2-late | `wingetcreate update ... --submit` | `microsoft/winget-pkgs` fork | **MANUAL `workflow_dispatch` only** (hard gate) | fine-grained PAT `WINGET_SUBMIT_TOKEN` (isolated; can't be an App for external-repo PRs) | never | +| **AUR** | DEFER | — | — | SSH key (re-introduces non-App secret) | — | + +**Required code change (don't ship non-self-updating channels without it):** `src/pythinker_code/ui/shell/update.py` — add a `PYTHINKER_MANAGED=` env read at the top of `_detect_upgrade_command()` (mirrors hermes `HERMES_MANAGED`); Docker/Nix set it, Scoop/WinGet manifests set it → channel-native upgrade hint. **Brew must NOT set `PYTHINKER_MANAGED`** (keep its existing path-sniff so behavior is unchanged); **mandatory regression test** that brew still maps to `['brew','upgrade','pythinker-code']` (the `.pythinker-native` marker means brew also trips `is_native_build()`; precedence is load-bearing). This change ships in P1 as prep so P2 channels are not released non-self-updating. + +**C4 for new channels:** README install snippets MUST be **version-less** (`scoop install pythinker-code`, `docker run ghcr.io/techmatrix-labs/pythinker-code`, `nix run github:TechMatrix-labs/pythinker-code`) so they never enter the F3 sprawl set. + +**Recommended adoption order:** **Docker (GHCR)** → **Scoop** → **Nix polish** → WinGet (manual) → defer AUR. Rationale: Docker = highest reach-per-fragility (OIDC auth, reuses wheel, self-protecting ancestor-check); Scoop = closes the Windows gap as a mechanical clone of the trusted tap pattern; Nix needs the `apps.default` net-new but the package build already works. + +--- + +## 7. Cross-Repo Trigger + Site Coupling + Deploy + +**Chosen model: hybrid (formalize the existing one — no churn).** Trigger and data source failed for different reasons, so they get different fixes: +- **Data source → pull (keep).** Site fetches with its own `GITHUB_TOKEN` (token-migration-safe). Do not push data via dispatch payload (would be a second source of truth + injection surface). +- **Trigger → dispatch, App-authed (finish migration).** Keep `repository_dispatch` fast-path; migrate auth PAT → `pythinker-release-bot` in **both** files. Fire from a separate `needs:promote` job (§5). +- **Backstop → daily cron 04:17 (keep)** + drift reconcile (§5). + +**Dispatch `tag` semantics differ — handle both (critique item 5, RECONCILED):** the two dispatch sources send different `tag` values: +- `promote-release.yml:170` sends `vX.Y.Z` (the release tag). +- `dispatch-pythinker-home-sync.yml:30` sends `github.sha` (a 40-char commit SHA) on the install-script/README push path. + +The discriminator that matters in `sync-upstream-products.ts`: +- **Raw README/script fetch** (`raw.githubusercontent.com/owner/repo//path`) accepts **either** a `vX.Y.Z` tag **or** a 40-char SHA — so the SHA push-path is fine here. Pinning raw fetch to the dispatched `` (instead of `main`) ensures the served installer matches the source at that ref. +- **Release-asset download URLs** (`/releases/download//...`, plus the deb/rpm/exe URLs + shas in `pythinkerCodeRelease.ts`) **must be built from the live API's `release.tag_name`, never from the payload ref** — a SHA there 404s. The sync already derives release data from `/releases/latest`; this codifies that the payload `tag` is used only for raw-source pinning and **never** for constructing asset URLs. + +**Deploy chain (the commit-push IS the deploy trigger):** +``` +release → promote → dispatch → sync-upstream-products.ts writes files → git push main + → Dokploy build-from-source (nixpacks → bun run server.ts: serves dist/ + bun:sqlite install-counter) → pythinker.com +``` + +**Deploy resolution (F5):** **Canonical = Dokploy build-from-source.** GitHub Pages is **disqualified** — `server.ts` needs a runtime + SQLite + POST endpoint. GHCR+Watchtower path is **provably dead** (no image since `docker.yml` deleted; `deploy/.env.example` `SITE_IMAGE` points at the wrong org `mohamed-elkholy95`). **Retire** (after confirming the live host runs Dokploy, not compose): `docker-compose.yml`, `docker-compose.private-ghcr.yml`, `deploy/traefik/`, Watchtower + all GHCR refs, rewrite `deploy/README.md` around Dokploy. **Keep:** `Dockerfile` (documented single-container fallback), `nixpacks.toml`, `server.ts`. **State the dependency:** the deploy chain relies on `pythinker-home` main being unprotected (verified) so the workflow can push; if it's ever protected, exempt `github-actions[bot]` or the chain breaks. + +**Install-script locations — distinguish code-repo sources from site mirrors (critique item 7):** +- **Code repo (KEEP — they are dispatch triggers):** `scripts/install.ps1` + `scripts/install-native.sh` are the **source** scripts; `dispatch-pythinker-home-sync.yml` watches them (`paths:` lines 16-17). Do **not** delete these. +- **Site repo (`git rm` the dead mirrors):** the canonical served pair is **`public/install.sh` + `public/install.ps1`** (the sole Vite-served dir, confirmed kept). The 3 dead, git-tracked mirrors — `scripts/install.ps1`, `web/public/install.ps1`, `docs/public/install.ps1` (all verified tracked + byte-identical) — get `git rm`'d and dropped from `installMirrors[].targetPaths`. + +**0.24.0 root-cause fix (Mode B) — re-scoped to what is actually net-new (critique item 4):** the per-product `owner`/`repo` config block **already exists** in `sync-upstream-products.ts` (lines 82-83 for the AI product `owner:"mohamed-elkholy95", repo:"Pythinker-ai"`; lines 98-99 for `TechMatrix-labs/pythinker-code`). Do **NOT** "create the config block" (overstated) and do **NOT** collapse to a single `ORG` const (would break the AI product, owner `mohamed-elkholy95`, a different org from code's `TechMatrix-labs`). The real residual risk is two things the config block does not cover: +1. A **hardcoded literal at line 366**: `readme.replaceAll("github.com/mohamed-elkholy95/Pythinker/releases", "github.com/mohamed-elkholy95/Pythinker-ai/releases")` — bypasses the config block. **Fix:** derive both sides from the product's `owner`/`repo` config, or drop the rewrite if it's a migration vestige. +2. **No assertion** that each product's `owner`/`repo`/derived URLs/`brewCommand` agree. **Fix:** add a lockstep/typecheck assertion over the existing config so a stale literal fails loudly. + +Add a **`.sha256` sidecar existence check** (right-sized: defense-in-depth for the cron-mid-upload race; promote already gates sidecars on the normal path). **Circular dependency is cosmetic** — `pythinker.com/install.sh` appears only in installer comments/fallback text; real downloads target `github.com/.../releases/latest/download`. Add a documented raw-GitHub `` fallback line to the installer headers. + +--- + +## 8. OLD → NEW Replacement Table + +| # | OLD mechanism | NEW / disposition | +|---|---|---| +| 1 | Manual ~18-file version bump (F3) | `scripts/release.py` rewrites all column-C files + `uv.lock` from `pyproject.toml:3` | +| 2 | Manual sub-package pin sequencing (core+host) | `--bump-core/--bump-host` write sub-pkg version + root pin atomically; gate verifies pre-push; tool prints sub-pkg-tags-first order | +| 3 | `pythinker-review==0.1.0` escapes dep-check/lockstep/promote (F3 hole) | Folded into all 3 guards + frozen at 0.1.0 (no `--bump-review`, no release workflow); sdk explicitly out of scope (unpinned) | +| 4 | No version-drift safety net | `tests/test_version_lockstep.py` (CI, every PR) + tomlkit + tomllib parse-back assertion | +| 5 | Manual "update_files" prose in release SKILL | Repoint `.agents/skills/release/SKILL.md` at `scripts/release.py` | +| 6 | Site-dispatch PAT in `promote-release.yml:168` + `dispatch-pythinker-home-sync.yml:28` (F2) | App `pythinker-release-bot`, per-run token, **both** files | +| 7 | Silent `::notice; exit 0` on missing token (0.24.0-Mode-A swallow, `promote-release.yml:172-174`) | Fail loud `exit 1` on empty minted token, both files | +| 8 | `PYTHINKER_CORE_PAGES_TOKEN` → 404 `PythinkerAI/pythinker-core` (`release-pythinker-core.yml:99-127`) | Retire step + delete PAT (VitePress is the docs surface) | +| 9 | promote PyPI poll checks only `pythinker-code/${version}` (`:95`) → sub-pkg publish race breaks `pip install` (F1) | BLOCKING check: core+host+review pinned versions all 200 on PyPI before flip | +| 10 | Homebrew as hard promote-gate member (`:126-129`, F1 blast radius) | Removed from gate → best-effort + drift reconcile (reconcile must land first, §5) | +| 11 | promote bottleneck framing | Slack `notify-failure` (`:188`) + prerelease-retention (`:152`) **already exist**; net-new = per-channel bottleneck detail + `release-readiness` issue + Homebrew-gate removal | +| 12 | Site dispatch as promote's last step (red = "promotion failed") | Separate `needs:promote` job; distinct "site sync failed, release promoted" Slack | +| 13 | "Daily cron is the only safety net" (implicit, F4) | Explicit `release-readiness-reconcile.yml` (drift detect + re-dispatch + alert + close issue) | +| 14 | Site mirrors README/scripts from `main` (can lead binaries) | Pin raw fetch to dispatched `` (tag OR sha); asset URLs always from API `tag_name` | +| 15 | 4 install.ps1 copies in site (3 dead, git-tracked) | `git rm` the 3 site mirrors; canonical `public/install.{sh,ps1}`. Code-repo `scripts/install.{ps1,sh}` KEPT (dispatch triggers) | +| 16 | Hardcoded `replaceAll(...mohamed-elkholy95/Pythinker/releases...)` at sync-TS:366 (0.24.0 Mode B) | Derive from existing per-product config (:82-99) + add lockstep assertion (config block already exists — not net-new) | +| 17 | No deterministic served-version signal | `public/version.json` emitted by sync; drift check exact-matches it | +| 18 | GHCR+Watchtower+Traefik compose (orphaned, wrong-org image) | Retire after host-confirm; canonical = Dokploy build-from-source | +| 19 | install.ps1 `per_page=20` pagination cliff (`:144`) | `/releases/latest`-first + asset-pair guard (`:158`) + prerelease skip (`:152`) + backoff | +| 20 | Flat 6×10s installer asset-wait | Exponential backoff (≈6m cap), both installers | +| 21 | Windows = manual .exe / irm\|iex only | + Scoop bucket (P2) pointing at existing onedir zip | +| 22 | No container/Nix/WinGet distribution; Nix has no `apps` stanza | + GHCR (P2), Nix `apps.default` net-new + CI (P2), WinGet manual (P2-late); AUR deferred | + +--- + +## 9. Phased Migration Roadmap + +### P0 — Quick wins (low risk, high value; fixes F2 + the F1 sub-package race + the real 0.24.0 class) +**Scope:** (a) Create org App `pythinker-release-bot` + org secrets; migrate **both** dispatch files to App token **and** fail-loud-on-empty (auth fix + fail-loud fix are the same edit). (b) Receiver `if:` source-repo gate in `sync-upstream-products.yml`. (c) Remove dead core-pages gh-pages step + delete `PYTHINKER_CORE_PAGES_TOKEN`. (d) promote-release: **add the sub-package PyPI-existence blocking check (core+host+review)**, drop Homebrew from gate, per-channel bottleneck detail in the existing Slack job, `issues:write` + `release-readiness` issue, dispatch as separate `needs:promote` job, asset URLs from API `tag_name` only. (e) **Mode-B fix**: `public/version.json` emit (pythinker-home) + lockstep assertion over the existing per-product config + fix the sync-TS:366 literal + pin raw fetch to dispatched ref (tolerate tag OR sha); `git rm` the 3 dead **site** install.ps1 mirrors (keep code-repo source scripts). (f) `release-readiness-reconcile.yml` (drift + alert + issue close) — **lands before/with the Homebrew-gate removal in (d)**. (g) Installer backoff + `install.ps1` pagination fix. +**Risk:** Low (CI/site only; no agent runtime change; no branch-protection change). **Reversibility:** High (keep PAT until both files green + one cycle; revert is a workflow revert). **Effort:** ~**3 days** across two repos + 3 PRs (code, code, home) — revised up from the optimistic ~1.5–2 day estimate, given the org App + 2 workflow rewrites + the new blocking check + receiver gate + `version.json` + reconcile workflow + per-product lockstep + 2 installer fixes. +*Note:* P0 fixes BOTH 0.24.0 modes and the `pip install` race only because (d)'s sub-pkg check and (e)'s `version.json`/literal fix are included — App migration alone (a) covers neither Mode B nor the publish race. + +### P1 — Structural (release tool + single-source; finishes F3) +**Scope:** `scripts/release.py` (stdlib + git/gh/uv) with all 4 phases incl. `uv lock` regen + `uv sync --frozen` local gate + tomlkit + tomllib parse-back; `tests/test_version_lockstep.py` covering **core+host+review pins**; extend `check_pythinker_dependency_versions.py` with the review tuple; getting-started.md:47 into column-C + test; ensure `## Unreleased` anchors; repoint release SKILL; document the `release/*` + `chore(release)` contract with `changelog-entry-required.yml`. Add `PYTHINKER_MANAGED` env to `update.py` + brew-regression test (prep for P2 channels). +**Risk:** Low-medium (tool runs locally before any push; gates mirror CI; no workflow topology change). **Reversibility:** High (additive; manual bump still works if tool unused). **Effort:** ~2–3 days incl. tests + a dry-run release rehearsal. + +### P2 — Breadth (new channels; broaden distribution) +**Scope, in order:** (1) Docker/GHCR (`Dockerfile` + `docker.yml`, ancestor-check, `GITHUB_TOKEN`); (2) Scoop (`scoop-pythinker` **org repo** + `pythinker-scoop-publisher` **org App** + `generate-manifest.py` polling the windows zip + `scoop-bucket.yml`); (3) Nix (`apps.default` net-new + `nix build .#default`/`nix run` CI + monthly `update-flake-lock` PR); (4) WinGet (manual `workflow_dispatch`, isolated PAT) — last; AUR deferred. Each new channel: version-less README snippet (C4), `PYTHINKER_MANAGED` set, best-effort (never gates promote). +**Risk:** Medium (new repos/Apps/secrets; all best-effort so they can't worsen F1). **Reversibility:** High (each channel is an independent additive workflow; delete to remove). **Effort:** Docker ~1 day, Scoop ~1.5 days, Nix ~0.5 day, WinGet ~0.5 day — adopt incrementally, one PR each. + +**Cross-phase invariants:** every PR goes branch → PR → checks pass → CodeRabbit `success` (C2) → merge → (release PRs) tag (C1); no direct main push; no new agent runtime dep (C3); README/badges move with the bump (C4); authored CHANGELOG narrative preserved, contributor footer only as release-notes addendum (C5). + +--- + +## Punch-list disposition (every critique item resolved) + +| # | Critique item | Disposition | +|---|---|---| +| **1** | Sub-package→PyPI publish race unguarded | **FIXED** §2 step 10, §5 BLOCKING set, §6 table, §8 row 9, §9 P0(d) — new blocking check core+host+review resolve on PyPI before flip | +| **2** | `pythinker-review==0.1.0` escapes anti-sprawl | **FIXED + DECIDED** §3 — folded into all 3 guards + frozen (no `--bump-review`); sdk justified out of scope (unpinned) | +| **3** | F1 fail-loud net-new overstated | **REFRAMED** §5 + §8 row 11 — Slack (`:188`) + prerelease-retention (`:152`) already exist; net-new = bottleneck detail + issue + gate removal | +| **4** | Mode-B config block already exists | **RE-SCOPED** §7 + §8 row 16 — net-new is lockstep assertion + the line-366 literal, not "create config block" | +| **5** | Two dispatch sources send different `tag` (tag vs sha) | **RECONCILED** §7 — raw fetch tolerates both; asset URLs always from API `tag_name` | +| **6** | P0 sequencing hazard + optimistic estimate | **FIXED** §5 ordering rule (reconcile before gate-removal) + §9 P0 estimate → ~3 days | +| **7** | Code-repo source scripts vs site mirrors conflated | **FIXED** §7 + §8 row 15 — `git rm` only the 3 site mirrors; keep code-repo `scripts/install.{ps1,sh}` (dispatch triggers) | +| Nix | "Nix already works" overstated | **SOFTENED** §1 + §6 + §8 row 22 — `apps.default` net-new, `nix run` UNVERIFIED until added | + +C1–C5 confirmed complete: C1 (release.py opens `release/X.Y.Z` PR, human tags post-merge); C2 (existing merge-gate hook); C3 (release.py is CI tooling, shipped agent gains zero deps); C4 (version-less new-channel snippets + lockstep-enforced README strings, bump in same change set); C5 (`## Unreleased`→`## X.Y.Z (DATE)` preserves body, contributor footer addendum only). + +**Files referenced (absolute paths):** +- `/home/ai/Projects/pythinker-code-main/pyproject.toml` (SSOT `:3`; pins `:28` core, `:47` host, `:48` review; workspace `:89`; sources `:97`) +- `/home/ai/Projects/pythinker-code-main/scripts/check_pythinker_dependency_versions.py` (`:66-67` core+host only — extend for review) +- `/home/ai/Projects/pythinker-code-main/.github/workflows/promote-release.yml` (`:95` PyPI poll, `:126-129` homebrew gate, `:152` exit/prerelease, `:168-186` dispatch PAT, `:172-174` silent skip, `:188-200` Slack) +- `/home/ai/Projects/pythinker-code-main/.github/workflows/dispatch-pythinker-home-sync.yml` (`:16-17` watched paths, `:28` PAT, `:30` sends `github.sha`) +- `/home/ai/Projects/pythinker-code-main/.github/workflows/changelog-entry-required.yml` (`:54` title skip, `:57` branch skip) +- `/home/ai/Projects/pythinker-code-main/.github/workflows/release-pythinker-core.yml` (`:99-127` pdoc gh-pages step to retire) +- `/home/ai/Projects/pythinker-code-main/flake.nix` (`:129` `packages.default`; NO `apps` stanza) +- `/home/ai/Projects/pythinker-code-main/scripts/install.ps1` (`:144` per_page=20, `:152` prerelease skip, `:158` asset-pair guard) +- `/home/ai/Projects/pythinker-site/site/scripts/sync-upstream-products.ts` (`:82-83`/`:98-99` per-product owner config, `:366` hardcoded `replaceAll` literal) From 88286c9202b467d4e7d89e9bc058acc4cff6ec54 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 13:06:12 -0400 Subject: [PATCH 02/16] docs: add welcome banner redesign spec (footer chip) --- ...26-05-31-welcome-banner-redesign-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-welcome-banner-redesign-design.md diff --git a/docs/superpowers/specs/2026-05-31-welcome-banner-redesign-design.md b/docs/superpowers/specs/2026-05-31-welcome-banner-redesign-design.md new file mode 100644 index 00000000..6ec8575c --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-welcome-banner-redesign-design.md @@ -0,0 +1,204 @@ +# Welcome Banner Redesign — Design Spec + +**Date:** 2026-05-31 +**Branch:** `feat/welcome-banner-redesign` +**Scope:** Visual redesign of the startup welcome banner only. The robot logo art and its colors are unchanged. + +--- + +## 1. Goal + +Make the startup banner cleaner, more readable, and more professional, without changing +the robot logo glyphs or palette. Success = the banner renders as the approved +"footer chip" layout, all existing banner tests pass, and the contiguous welcome +string e2e tests depend on is preserved. + +**One-sentence success criterion:** `pytest tests/ui_and_conv/test_shell_welcome_info.py` +stays green, the e2e substring `"Welcome to Pythinker — think first, then code."` +still appears verbatim, and the rendered panel matches the approved mock below. + +--- + +## 2. Current vs. Proposed + +### Current (v0.27.0) + +``` +╭─ Pythinker Code v0.27.0 ───────────────────────────────────────────╮ +│ ● ✦ What's new in v0.27.0 · /changelog │ +│ │ │ +│ ▛▀▀▀▀▀▀▀▜ │ +│ ◖█ ◉ ◉ █◗ Welcome to Pythinker — think first, then code. │ +│ ▙▄▄▄≡▄▄▄▟ Review · Secure · Diagnose · then Create. Send /help│ +│ │ +│ Directory │ ~ │ +│ Session │ be1c9425-... │ +│ Auto-save │ ~/.pythinker/sessions/.../context.jsonl │ +│ Model │ MiniMax M2.7 │ +│ Tips ... │ +╰────────────────────────────────────────────────────────────────────╯ +``` + +Problems: the "What's new" chip sits awkwardly above the antenna and competes with the +logo for the top-left; the strapline overflows past `/help`; the `│` separator column +adds visual noise to the info grid. + +### Proposed ("Footer chip") + +``` +╭─ Pythinker Code v0.27.0 ─────────────────────────────────────────╮ +│ │ +│ ● │ +│ │ │ +│ ▛▀▀▀▀▀▀▀▜ Welcome to Pythinker — think first, then code. │ +│ ◖█ ◉ ◉ █◗ Review · Secure · Diagnose · then Create. │ +│ ▙▄▄▄≡▄▄▄▟ Send /help for help. │ +│ │ +│ Directory ~ │ +│ Session be1c9425-6f1f-47d5-8e33-01d2d13c44c9 │ +│ Auto-save ~/.pythinker/sessions/…/context.jsonl │ +│ Model MiniMax M2.7 │ +│ │ +│ Tips │ +│ • send /login to use Pythinker for Coding │ +│ • Pythinker reviews before it writes … │ +│ • Spot a bug? Type /feedback … │ +│ │ +╰─────────────────── ✦ What's new in v0.27.0 · /changelog ────────╯ +``` + +(Verbatim-reproduced output of this layout was generated against the installed Rich +15.0.0 during design; see §6 verification.) + +--- + +## 3. Approved Changes + +1. **Chip → footer.** The "What's new" / "↑ Update available" chip moves from a + top-right inline cell to the **Panel subtitle** (bottom border), right-aligned. + It keeps its own accent color (info-cyan or warning-yellow) — confirmed it does + not collapse to the border grey. + +2. **Headline block aligned to the robot body.** The right-hand cell holds three lines — + headline / strapline / help — bottom-aligned against the 5-line logo + (`vertical="bottom"`), so the antenna (`●` / `│`) floats above and the three text + lines sit beside the robot's face/body/mouth rows. + +3. **Strapline on its own line.** `"Review · Secure · Diagnose · then Create."` and + `"Send /help for help."` become two separate lines instead of one overflowing line. + The headline `"Welcome to Pythinker — think first, then code."` remains a single + contiguous line (e2e dependency). + +4. **Drop the `│` separator** in the info grid. Two columns: right-aligned label, + left-aligned value, separated by a 2-space gutter (`padding=(0, 1)` on a 2-col table). + +5. **Theme-token border (light-mode polish).** Replace the raw `_PYTHINKER_BORDER = + "grey39"` literal and the per-label grey fallbacks with the active theme's `border` / + `muted` / `dim` tokens, so the frame reads correctly in light mode too. No test + asserts border or title color, so this is safe. The brand title text + ("Pythinker Code") and version suffix keep their muted/dim treatment via tokens. + +**Out of scope (logged, not changed):** the robot glyphs and palette; the Tips strings +(owned by `app.py`); `_welcome_banner_chip()` precedence logic; the `WelcomeInfoItem` +data shape; anything in `app.py`. + +--- + +## 4. Architecture & Touched Code + +Single function, single file: **`src/pythinker_code/ui/shell/__init__.py`**. + +| Unit | Lines (current) | Change | +|------|-----------------|--------| +| `_LOGO`, `_LOGO_*` color consts | 1859–1874 | **Unchanged.** | +| `_PYTHINKER_BORDER` | 1864 | Replaced by a theme-token lookup (helper or inline `_t.border`). | +| `WelcomeInfoItem` | 1877–1886 | **Unchanged** (public-ish; gated by tests). | +| `_value_style_for_label` | 1889–1907 | **Unchanged** (Directory→info token gated by test). | +| `_welcome_banner_chip` | 1910–1931 | **Unchanged** (precedence gated by test). | +| `_print_welcome_info` | 1934–2008 | **Rewritten body** per §3. Signature unchanged: `(name, info_items, *, banner: Text \| None = None)`. | + +### `_print_welcome_info` new structure + +- Build `logo = Text.from_markup(_LOGO)` (unchanged). +- Build the header `Table` with two columns: `[logo]` (top), `[Group(head, strapline, help)]` + with `vertical="bottom"`. One row. The previous `banner is not None` top/bottom-padding + branch is **removed** — the chip no longer lives in this table. +- `head = Text.from_markup("Welcome to Pythinker — think first, then code.")` (contiguous). +- `strapline` + `help_text` become two separate `Text` renderables; `/help` keeps its + bold-warning highlight. +- Info grid: 2-column `Table` (right label / left value), label style `muted`, value style + via `_value_style_for_label`. No separator column. +- Tips: **unchanged** 2-column hang-indent table. +- `Panel(Group(*rows), title=version_title, title_align="left", + subtitle=banner, subtitle_align="right", border_style=, box=box.ROUNDED, + expand=False, padding=(1, 2))`. When `banner is None`, `subtitle` is `None` → + byte-identical to omitting it (verified). + +### Data flow (unchanged) + +`app.run_shell()` builds `welcome_info: list[WelcomeInfoItem]` → `Shell.run()` calls +`_print_welcome_info(name, self._welcome_info, banner=_welcome_banner_chip())`. No +changes to `app.py` or the call site. + +--- + +## 5. Test Impact + +**Gating tests — `tests/ui_and_conv/test_shell_welcome_info.py` (5):** + +| Test | Holds because | +|------|---------------| +| `test_shell_welcome_uses_pythinker_code_copy` | title, "Welcome to Pythinker", "think first" all still rendered. | +| `test_directory_label_uses_brand_info_token` | `_value_style_for_label` unchanged. | +| `test_welcome_banner_chip_shown_in_output` | chip passed via `banner=` now renders in the **subtitle**; verified its text (incl. `/update`) appears in `export_text()`. | +| `test_welcome_banner_chip_update_wins_over_whats_new` | `_welcome_banner_chip()` unchanged. | +| `test_welcome_banner_no_chip_unchanged` | `subtitle=None` is byte-identical to omitting subtitle (verified on Rich 15.0.0). | + +**e2e tests** (`test_shell_pty_e2e.py`, `test_shell_modal_e2e.py`, +`test_subagent_smoke_e2e.py`, `test_slash_completion_enter_tmux.py`): assert only the +contiguous substring `"Welcome to Pythinker — think first, then code."` → preserved. + +**New tests to add** (to lock the redesign so it can't silently regress): + +1. `test_welcome_chip_renders_in_footer_not_header` — with a chip, the chip text appears + in the **last** non-empty rendered line and **not** in the first three lines. +2. `test_welcome_info_grid_has_no_pipe_separator` — render with info items; assert the + `" │ "` separator no longer appears between a label and its value (guard the cleanup). +3. `test_welcome_strapline_and_help_on_separate_lines` — assert no single rendered line + contains both "then Create." and "Send /help". + +No snapshot/golden files exist, so none to regenerate. + +--- + +## 6. Verification Plan + +1. **Unit:** `pytest tests/ui_and_conv/test_shell_welcome_info.py -q` → green (5 existing + 3 new). +2. **Visual:** a throwaway script renders `_print_welcome_info` with the real + `welcome_info` shape at widths 80 / 100 / 120 and with each chip variant + (none / what's-new / update) — eyeball against the §2 mock. (Already prototyped in + design; will re-run against final code.) +3. **Lint/type:** `ruff check` + the project's type gate on the changed file. +4. **e2e smoke (if feasible):** confirm the welcome substring still trips + `read_until_contains` — covered transitively by keeping the headline contiguous. + +### Design-time facts established (Rich 15.0.0, the installed version) + +- `Panel` supports `subtitle`, `subtitle_align`; `Table.add_column(vertical=...)` supports + `top|middle|bottom` — confirmed by introspecting the installed package and Context7 docs. +- `subtitle=None` ≡ subtitle omitted (byte-identical export). ✔ invariant for the no-chip test. +- A styled chip keeps its accent color when used as a subtitle (does not inherit border grey). ✔ +- The chip never clips: the fixed headline line forces a panel wider than the longest chip. ✔ +- The blank spacer between the header block and info grid survives. ✔ + +--- + +## 7. Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Subtitle text not captured by `export_text()` → breaks `chip_shown_in_output`. | Verified it **is** captured on Rich 15.0.0. | +| Light-mode border token regresses a color test. | No test asserts border/title color; `color_system=None` in unit tests ignores color. | +| Splitting the strapline breaks an unseen assertion. | grep across `tests/`, `tests_e2e/`, docs found **no** assertion on strapline contiguity or `/help` adjacency. | +| Narrow terminal (<64 cols) wrapping. | Same `expand=False` behavior as today; not a regression. Logged, not solved. | +``` From 5418e727c11d8205e94781426b4973e698272fc1 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 13:11:02 -0400 Subject: [PATCH 03/16] docs: add welcome banner redesign implementation plan --- .../2026-05-31-welcome-banner-redesign.md | 341 ++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md diff --git a/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md b/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md new file mode 100644 index 00000000..e38969a6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md @@ -0,0 +1,341 @@ +# Welcome Banner Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the startup welcome banner to the approved "footer chip" layout — cleaner, more readable, more professional — without changing the robot logo glyphs or its colors. + +**Architecture:** All changes live in one function, `_print_welcome_info`, in `src/pythinker_code/ui/shell/__init__.py`. The "What's new / Update available" chip moves from a top-right inline cell to the Rich `Panel` **subtitle** (bottom border). The headline/strapline/help block bottom-aligns beside the 5-line robot logo. The info grid drops its `│` separator column. The panel border and title switch from a raw `grey39` literal to theme tokens so they adapt to light mode. + +**Tech Stack:** Python 3.12, Rich 15.0.0 (`Panel`, `Table`, `Text`, `Group`, `box.ROUNDED`), pytest, ruff (line-length 100), `uv`. + +--- + +## Background facts (already verified against the installed Rich 15.0.0) + +- `Panel(subtitle=, subtitle_align='right', title_align='left', border_style= + + +
+ {_PYTHINKER_CALLBACK_LOGO_SVG.replace("

{escaped_body}

+
+ +""" + + async def _handle_browser_callback( reader: asyncio.StreamReader, writer: asyncio.StreamWriter, state: str ) -> tuple[str | None, str | None]: @@ -251,17 +321,15 @@ async def _handle_browser_callback( error = "OpenAI callback did not include an authorization code." ok = code is not None and error is None - title = "OpenAI login complete" if ok else "OpenAI login failed" - body = "You can close this window and return to Pythinker." if ok else error - html = f"

{title}

{body}

" + response_html = _callback_html(ok=ok, message=error) status = "200 OK" if ok else "400 Bad Request" writer.write( bytes( f"HTTP/1.1 {status}\r\n" "Content-Type: text/html; charset=utf-8\r\n" - f"Content-Length: {len(html.encode('utf-8'))}\r\n" + f"Content-Length: {len(response_html.encode('utf-8'))}\r\n" "Connection: close\r\n\r\n" - f"{html}", + f"{response_html}", encoding="utf-8", ) ) @@ -530,21 +598,197 @@ async def refresh_openai_chatgpt_token(refresh_token: str) -> OAuthToken: return _token_from_openai_response(payload) -async def _discover_chatgpt_models(access_token: str) -> list[ModelInfo]: - platform = Platform( - id=OPENAI_CHATGPT_PLATFORM_ID, - name="OpenAI ChatGPT Codex", - base_url=OPENAI_CHATGPT_BASE_URL, - allowed_prefixes=None, +def build_chatgpt_codex_headers( + *, access_token: str | None = None, account_id: str | None = None +) -> dict[str, str]: + """Headers expected by the ChatGPT-backed Codex endpoint. + + The ChatGPT Codex backend is account-scoped. The bearer token carries the + subscription, and ``ChatGPT-Account-ID`` disambiguates the active ChatGPT + account when OpenAI returns it in the OAuth claims. + """ + headers = { + "User-Agent": f"codex_cli_rs/0.0.0 ({USER_AGENT})", + "originator": "codex_cli_rs", + } + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + if account_id: + headers["ChatGPT-Account-ID"] = account_id + return headers + + +def _optional_bool(value: object) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no"}: + return False + return None + + +def _optional_int(value: object) -> int | None: + if isinstance(value, int | float | str): + try: + return int(value) + except (TypeError, ValueError): + return None + return None + + +def _field(item: dict[str, Any], *names: str) -> object: + for name in names: + if name in item and item[name] is not None: + return item[name] + return None + + +def _string_field(item: dict[str, Any], *names: str) -> str | None: + value = _field(item, *names) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _looks_like_reasoning_model(model_id: str) -> bool: + normalized = model_id.lower() + return normalized.startswith(("gpt-5", "o1", "o3", "o4")) or "codex" in normalized + + +def _chatgpt_model_context(model_id: str, item: dict[str, Any]) -> int: + value = _optional_int( + _field( + item, + "context_window", + "contextWindow", + "context_length", + "contextLength", + "max_context_size", + ) ) - try: - return await list_models(platform, access_token) - except aiohttp.ClientResponseError as exc: - if exc.status in {401, 403}: - raise - return list(OPENAI_CHATGPT_FALLBACK_MODELS) - except Exception: - return list(OPENAI_CHATGPT_FALLBACK_MODELS) + if value and value > 0: + return value + return 128_000 if model_id.lower().endswith("spark") else 272_000 + + +def _chatgpt_model_supports_image(model_id: str, item: dict[str, Any]) -> bool: + value = _optional_bool( + _field( + item, + "supports_image_in", + "supportsImageIn", + "supports_vision", + "supportsVision", + "vision", + ) + ) + if value is not None: + return value + # OpenAI documents Spark as a text-only research-preview model. + return not model_id.lower().endswith("spark") + + +def _is_visible_chatgpt_model(item: dict[str, Any]) -> bool: + visibility = item.get("visibility") + if isinstance(visibility, str) and visibility.strip().lower() in {"hide", "hidden"}: + return False + for key in ("disabled", "unavailable"): + if item.get(key) is True: + return False + return all(item.get(key) is not False for key in ("available", "is_available", "isAvailable")) + + +def _parse_chatgpt_models_payload(payload: object) -> list[ModelInfo]: + if not isinstance(payload, dict): + raise ValueError("Unexpected OpenAI ChatGPT Codex models response.") + payload_dict = cast(dict[str, Any], payload) + raw_models = payload_dict.get("models") + if not isinstance(raw_models, list): + # Keep a small compatibility path in case OpenAI ever aligns this with + # the public /v1/models shape. ChatGPT Codex currently returns + # {"models": [{"slug": ...}]}. + raw_models = payload_dict.get("data") + if not isinstance(raw_models, list): + raise ValueError("OpenAI ChatGPT Codex models response did not include models.") + + raw_model_items = cast(list[object], raw_models) + sortable: list[tuple[int, int, ModelInfo]] = [] + seen: set[str] = set() + for index, raw_item in enumerate(raw_model_items): + if not isinstance(raw_item, dict): + continue + item = cast(dict[str, Any], raw_item) + model_id = _string_field(item, "slug", "id") + if not model_id or model_id in seen or not _is_visible_chatgpt_model(item): + continue + seen.add(model_id) + reasoning = _optional_bool( + _field(item, "supports_reasoning", "supportsReasoning", "reasoning") + ) + supports_reasoning = ( + reasoning if reasoning is not None else _looks_like_reasoning_model(model_id) + ) + priority = _optional_int(item.get("priority")) + display_name = _string_field(item, "display_name", "displayName", "name", "title") + sortable.append( + ( + priority if priority is not None else 10_000, + index, + ModelInfo( + id=model_id, + context_length=_chatgpt_model_context(model_id, item), + supports_reasoning=supports_reasoning, + supports_image_in=_chatgpt_model_supports_image(model_id, item), + supports_video_in=bool( + _optional_bool( + _field( + item, + "supports_video_in", + "supportsVideoIn", + "supports_video", + "supportsVideo", + ) + ) + ), + display_name=display_name, + ), + ) + ) + + sortable.sort(key=lambda entry: (entry[0], entry[1])) + models = [model for _, _, model in sortable] + if not models: + raise ValueError("No OpenAI ChatGPT Codex models are available for this account.") + return models + + +def _chatgpt_models_url(base_url: str | None = None) -> str: + root = (base_url or OPENAI_CHATGPT_BASE_URL).rstrip("/") + return f"{root}/models?client_version=1.0.0" + + +async def discover_chatgpt_models( + access_token: str, + *, + account_id: str | None = None, + base_url: str | None = None, +) -> list[ModelInfo]: + async with ( + new_client_session() as session, + session.get( + _chatgpt_models_url(base_url), + headers=build_chatgpt_codex_headers( + access_token=access_token, + account_id=account_id, + ), + raise_for_status=True, + ) as response, + ): + payload = await response.json(content_type=None) + return _parse_chatgpt_models_payload(payload) def _token_from_openai_response(payload: dict[str, Any]) -> OAuthToken: @@ -555,17 +799,15 @@ def _token_from_openai_response(payload: dict[str, Any]) -> OAuthToken: **payload, } # ChatGPT does not return `account_id` as a top-level OAuth response field; - # it lives inside the id_token JWT claims under + # it lives inside the OAuth JWT claims under # `https://api.openai.com/auth.chatgpt_account_id`. Hoist it onto the # response so OAuthToken.from_response() picks it up. Without this the - # ChatGPT usage adapter (and any other code calling - # OAuthManager.get_chatgpt_account_id) sees None. - if ( - "account_id" not in normalized - and (id_token := payload.get("id_token")) - and (account_id := _extract_chatgpt_account_id(str(id_token))) - ): - normalized["account_id"] = account_id + # ChatGPT usage adapter, model catalog endpoint, and Codex request headers + # cannot scope requests to the active Plus/Pro account. + if "account_id" not in normalized: + jwt_token = payload.get("id_token") or payload.get("access_token") + if jwt_token and (account_id := _extract_chatgpt_account_id(str(jwt_token))): + normalized["account_id"] = account_id return OAuthToken.from_response(normalized) @@ -610,7 +852,7 @@ async def _finish_chatgpt_login( api_key = "" if id_token := token_payload.get("id_token"): api_key = await _exchange_id_token_for_api_key(str(id_token)) - models = await _discover_chatgpt_models(token.access_token) + models = await discover_chatgpt_models(token.access_token, account_id=token.account_id) selected_model, thinking = _select_default_openai_model(models) except Exception as exc: from pythinker_code.telemetry.errors import report_handled_error diff --git a/src/pythinker_code/auth/platforms.py b/src/pythinker_code/auth/platforms.py index f7f1174f..9d0e81bc 100644 --- a/src/pythinker_code/auth/platforms.py +++ b/src/pythinker_code/auth/platforms.py @@ -176,10 +176,11 @@ def _select_retry_api_keys( def _openai_fallback_models(platform_id: str) -> list[ModelInfo] | None: + # ChatGPT Codex model availability is subscription/account-specific. Do not + # replace the user's live catalog with a static fallback; stale fallback + # slugs surface as 400 "model is not supported with a ChatGPT account". if platform_id == OPENAI_CHATGPT_PLATFORM_ID: - from pythinker_code.auth.openai import OPENAI_CHATGPT_FALLBACK_MODELS - - return list(OPENAI_CHATGPT_FALLBACK_MODELS) + return None if platform_id == OPENAI_API_PLATFORM_ID: from pythinker_code.auth.openai import OPENAI_API_FALLBACK_MODELS @@ -208,6 +209,24 @@ def _fallback_or_log( return models +async def _list_models_for_managed_platform( + *, + platform_id: str, + platform: Platform, + api_key: str, + account_id: str | None = None, +) -> list[ModelInfo]: + if platform_id == OPENAI_CHATGPT_PLATFORM_ID: + from pythinker_code.auth.openai import discover_chatgpt_models + + return await discover_chatgpt_models( + api_key, + account_id=account_id, + base_url=platform.base_url, + ) + return await list_models(platform, api_key) + + async def refresh_managed_models(config: Config) -> bool: if not config.is_from_default_location: return False @@ -276,8 +295,20 @@ async def refresh_managed_models(config: Config) -> bool: ) continue effective_platform = platform._replace(base_url=provider.base_url) + account_id = ( + oauth_manager.get_chatgpt_account_id(provider.oauth) + if platform_id == OPENAI_CHATGPT_PLATFORM_ID + and provider.oauth is not None + and oauth_manager is not None + else None + ) try: - models = await list_models(effective_platform, api_key) + models = await _list_models_for_managed_platform( + platform_id=platform_id, + platform=effective_platform, + api_key=api_key, + account_id=account_id, + ) except aiohttp.ClientResponseError as exc: if exc.status != 401 or provider.oauth is None or oauth_manager is None: fallback_models = _fallback_or_log(platform_id=platform_id, error=exc) @@ -311,6 +342,8 @@ async def refresh_managed_models(config: Config) -> bool: resolved_api_key=oauth_manager.resolve_api_key(provider.api_key, provider.oauth), fallback_api_key=fallback_api_key, ) + if platform_id == OPENAI_CHATGPT_PLATFORM_ID: + account_id = oauth_manager.get_chatgpt_account_id(provider.oauth) if not retry_api_keys: fallback_models = _fallback_or_log( platform_id=platform_id, @@ -325,7 +358,12 @@ async def refresh_managed_models(config: Config) -> bool: retry_exc: Exception | None = None for retry_api_key in retry_api_keys: try: - models = await list_models(effective_platform, retry_api_key) + models = await _list_models_for_managed_platform( + platform_id=platform_id, + platform=effective_platform, + api_key=retry_api_key, + account_id=account_id, + ) break except Exception as exc3: retry_exc = exc3 diff --git a/src/pythinker_code/cli/update.py b/src/pythinker_code/cli/update.py index 6045357e..366ec36b 100644 --- a/src/pythinker_code/cli/update.py +++ b/src/pythinker_code/cli/update.py @@ -10,6 +10,7 @@ @cli.callback(invoke_without_command=True) def update( + ctx: typer.Context, check_only: Annotated[ bool, typer.Option( @@ -17,10 +18,52 @@ def update( help="Only check whether an update is available; don't install.", ), ] = False, -): +) -> None: """Check for and install Pythinker CLI updates.""" - from pythinker_code.ui.shell.update import UpdateResult, do_update + if ctx.invoked_subcommand is not None: + return - result = asyncio.run(do_update(print=True, check_only=check_only)) + from pythinker_code.ui.shell.update import UpdateResult + from pythinker_code.ui.shell.update_orchestrator import run_update_job + + result = asyncio.run(run_update_job(print_output=True, check_only=check_only, source="cli")) if result in (UpdateResult.FAILED, UpdateResult.UNSUPPORTED): raise typer.Exit(1) + + +@cli.command("status") +def status_command() -> None: + """Show the last recorded update job status.""" + from pythinker_code.ui.shell.update_orchestrator import read_update_status + + status = read_update_status() + if status is None: + typer.echo("No update job recorded.") + return + typer.echo(f"State: {status.state.value}") + if status.result: + typer.echo(f"Result: {status.result}") + if status.current_version: + typer.echo(f"Current version: {status.current_version}") + if status.target_version: + typer.echo(f"Target version: {status.target_version}") + if status.message: + typer.echo(f"Message: {status.message}") + typer.echo(f"Log: {status.log_path}") + + +@cli.command("log") +def log_command( + lines: Annotated[ + int, + typer.Option("--lines", "-n", min=1, help="Number of log lines to show."), + ] = 80, +) -> None: + """Show the tail of the update log.""" + from pythinker_code.ui.shell.update_orchestrator import read_update_log_tail + + tail = read_update_log_tail(lines) + if not tail: + typer.echo("No update log recorded.") + return + typer.echo("\n".join(tail)) diff --git a/src/pythinker_code/config.py b/src/pythinker_code/config.py index b90c754e..4a65ab49 100644 --- a/src/pythinker_code/config.py +++ b/src/pythinker_code/config.py @@ -20,6 +20,10 @@ from tomlkit.exceptions import TOMLKitError from pythinker_code.exception import ConfigError +from pythinker_code.feedback_repo import ( + DEFAULT_FEEDBACK_GITHUB_REPO, + normalize_feedback_github_repo, +) from pythinker_code.hooks.config import HookDef from pythinker_code.llm import ModelCapability, ProviderType from pythinker_code.share import get_share_dir @@ -279,10 +283,15 @@ class FeedbackConfig(BaseModel): ), ) github_repo: str = Field( - default="TechMatrix-labs/pythinker-code", + default=DEFAULT_FEEDBACK_GITHUB_REPO, description="GitHub owner/repo used by /feedback GitHub OAuth submissions.", ) + @field_validator("github_repo") + @classmethod + def migrate_legacy_github_repo(cls, value: str) -> str: + return normalize_feedback_github_repo(value) + @field_serializer("api_key", when_used="json") def dump_secret(self, v: SecretStr | None): return v.get_secret_value() if v is not None else None diff --git a/src/pythinker_code/feedback.py b/src/pythinker_code/feedback.py index 6f45df75..e299ae60 100644 --- a/src/pythinker_code/feedback.py +++ b/src/pythinker_code/feedback.py @@ -14,6 +14,10 @@ import aiohttp from pythinker_code.constant import VERSION +from pythinker_code.feedback_repo import ( + DEFAULT_FEEDBACK_GITHUB_REPO, + normalize_feedback_github_repo, +) from pythinker_code.telemetry.errors import RecentError, recent_errors from pythinker_code.ui.shell.oauth import current_model_key from pythinker_code.utils.aiohttp import new_client_session @@ -44,16 +48,28 @@ "other": "other", "feedback": "other", } -_SENSITIVE_KEY_RE = re.compile(r"(api[_-]?key|token|secret|password|passwd|authorization)", re.I) +_SENSITIVE_KEY_RE = re.compile( + r"(api[_-]?key|token|secret|password|passwd|authorization|proxy|" + r"pip[_-]?[\w-]*index[_-]?url)", + re.I, +) _SECRET_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( ( - re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token)\s+)[A-Za-z0-9._~+/=-]{8,}"), + re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer|token|basic)\s+)[A-Za-z0-9._~+/=-]{8,}"), r"\1", ), ( re.compile(r"(?i)((?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*)[^\s'\"]+"), r"\1", ), + ( + re.compile( + r"(?i)((?:pip[_-]?index[_-]?url|pip[_-]?extra[_-]?index[_-]?url|" + r"https?[_-]?proxy|all[_-]?proxy)\s*[:=]\s*)[^\s'\"]+" + ), + r"\1", + ), + (re.compile(r"(?i)(https?://)[^\s/@:]+:[^\s/@]+@"), r"\1@"), (re.compile(r"\bsk-(?:ant|proj|[A-Za-z0-9])[A-Za-z0-9_-]{16,}\b"), ""), (re.compile(r"\bxox(?:a|b|p|r|s)-[A-Za-z0-9-]{10,}\b"), ""), (re.compile(r"\bAIza[0-9A-Za-z_-]{30,45}\b"), ""), @@ -263,10 +279,11 @@ async def submit_feedback_payload( def build_feedback_issue_url( - payload: dict[str, Any], repo: str = "TechMatrix-labs/pythinker-code" + payload: dict[str, Any], repo: str = DEFAULT_FEEDBACK_GITHUB_REPO ) -> str: from urllib.parse import urlencode + repo = normalize_feedback_github_repo(repo) title = build_feedback_title(payload) body = build_feedback_issue_body(payload) labels = f"feedback,feedback:{payload.get('type') or 'other'}" diff --git a/src/pythinker_code/feedback_repo.py b/src/pythinker_code/feedback_repo.py new file mode 100644 index 00000000..0edf0abf --- /dev/null +++ b/src/pythinker_code/feedback_repo.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +DEFAULT_FEEDBACK_GITHUB_REPO = "TechMatrix-labs/pythinker-code" + +_LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS = { + "mohamed-elkholy95/Pythinker-Code", + "mohamed-elkholy95/pythinker-code", +} +_LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS_LOWER = { + repo.lower() for repo in _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS +} + + +def normalize_feedback_github_repo(repo: str) -> str: + """Return the active feedback repo, migrating stale bundled defaults.""" + cleaned = repo.strip().strip("/") + if not cleaned: + return DEFAULT_FEEDBACK_GITHUB_REPO + if cleaned.lower() in _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS_LOWER: + return DEFAULT_FEEDBACK_GITHUB_REPO + return cleaned diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index af5f9617..47041177 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -238,12 +238,19 @@ def create_llm( case "openai_codex": from pythinker_core.contrib.chat_provider.openai_responses import OpenAIResponses + from pythinker_code.auth.openai import build_chatgpt_codex_headers + + default_headers = build_chatgpt_codex_headers( + account_id=oauth.get_chatgpt_account_id(provider.oauth) if oauth else None + ) + if provider.custom_headers: + default_headers.update(provider.custom_headers) chat_provider = OpenAIResponses( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, system_prompt_as_instructions=True, - default_headers=dict(provider.custom_headers) if provider.custom_headers else None, + default_headers=default_headers, http_client=rl_http_client, ) case "anthropic": diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 6dd5d226..bb4bc746 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -4,6 +4,7 @@ import contextlib import re import shlex +import textwrap import time from collections import deque from collections.abc import Awaitable, Callable, Coroutine @@ -38,8 +39,13 @@ run_soul, ) from pythinker_code.soul.pythinkersoul import FLOW_COMMAND_PREFIX, PythinkerSoul -from pythinker_code.ui.shell.components.render_utils import render_message_response, sanitize_ansi -from pythinker_code.ui.shell.console import console +from pythinker_code.ui.shell.components.render_utils import ( + cell_width, + render_message_response, + sanitize_ansi, + truncate_to_width, +) +from pythinker_code.ui.shell.console import console, current_console_width from pythinker_code.ui.shell.echo import render_user_echo_text from pythinker_code.ui.shell.mcp_status import render_mcp_prompt from pythinker_code.ui.shell.prompt import ( @@ -1930,56 +1936,138 @@ def _welcome_banner_chip() -> Text | None: return None +_WELCOME_MAX_WIDTH = 100 +_WELCOME_LABEL_WIDTH = 10 +_WELCOME_PANEL_CHROME_WIDTH = 6 # border + horizontal padding used below + + +def _take_cells_left(text: str, max_width: int) -> str: + if max_width <= 0: + return "" + out: list[str] = [] + used = 0 + for char in text: + width = cell_width(char) + if used + width > max_width: + break + out.append(char) + used += width + return "".join(out) + + +def _take_cells_right(text: str, max_width: int) -> str: + if max_width <= 0: + return "" + out: list[str] = [] + used = 0 + for char in reversed(text): + width = cell_width(char) + if used + width > max_width: + break + out.append(char) + used += width + return "".join(reversed(out)) + + +def _truncate_middle_to_width(text: str, max_width: int) -> str: + """Cell-aware middle truncation for paths and UUID-like values.""" + if max_width <= 0: + return "" + cleaned = sanitize_ansi(text).replace("\r", " ").replace("\n", " ") + if cell_width(cleaned) <= max_width: + return cleaned + if max_width <= 1: + return truncate_to_width(cleaned, max_width) + left_width = max(1, (max_width - 1) // 2) + right_width = max(1, max_width - 1 - left_width) + return f"{_take_cells_left(cleaned, left_width)}…{_take_cells_right(cleaned, right_width)}" + + +def _welcome_panel_width() -> int: + columns = current_console_width(console, default=_WELCOME_MAX_WIDTH) + return max(1, min(columns, _WELCOME_MAX_WIDTH)) + + +def _welcome_value(label: str, value: str, max_width: int) -> str: + cleaned = sanitize_ansi(value).replace("\r", " ").replace("\n", " ") + if label.strip() in {"Directory", "Auto-save", "Session"}: + return _truncate_middle_to_width(cleaned, max_width) + return truncate_to_width(cleaned, max_width) + + +def _welcome_tip_lines(value: str, max_width: int) -> list[str]: + cleaned = sanitize_ansi(value).replace("\r", " ").replace("\n", " ").strip() + if not cleaned: + return [""] + lines = textwrap.wrap( + cleaned, + width=max(1, max_width), + break_long_words=False, + break_on_hyphens=False, + ) or [cleaned] + return [truncate_to_width(line, max_width) for line in lines] + + def _print_welcome_info( name: str, info_items: list[WelcomeInfoItem], *, banner: Text | None = None ) -> None: _t = _get_tui_tokens() - head = Text.from_markup("Welcome to Pythinker — think first, then code.") + panel_width = _welcome_panel_width() + content_width = max(1, panel_width - _WELCOME_PANEL_CHROME_WIDTH) + + head = Text.from_markup("[bold]Welcome to Pythinker — think first, then code.[/]") strapline = Text.from_markup( - f"[{_t.muted}]Review · Secure · Diagnose · then Create.[/]" + f"[{_t.muted}]Review · Secure · Diagnose · Build with confidence.[/]" ) - help_text = Text.from_markup(f"[{_t.muted}]Send /help for help.[/]") + help_text = Text.from_markup(f"[{_t.muted}]Type /help for commands.[/]") help_text.highlight_regex(r"/help\b", f"bold {_t.warning}") - # Logo on the left; the 3-line text block bottom-aligns against the 5-line - # robot so the antenna floats above and the lines sit beside the body. - logo = Text.from_markup(_LOGO) - table = Table(show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False) - table.add_column(justify="left") - table.add_column(justify="left", vertical="bottom") - table.add_row(logo, Group(head, strapline, help_text)) - - rows: list[RenderableType] = [table] + rows: list[RenderableType] = [] + if content_width >= 68: + # Logo on the left; the 3-line text block bottom-aligns against the 5-line + # robot so the antenna floats above and the lines sit beside the body. + logo = Text.from_markup(_LOGO) + table = Table.grid(padding=(0, 1)) + table.add_column(justify="left", no_wrap=True) + table.add_column(justify="left", vertical="bottom", no_wrap=True) + table.add_row(logo, Group(head, strapline, help_text)) + rows.append(table) + else: + rows.extend([head, strapline, help_text]) facts = [item for item in info_items if item.name.strip() != "Tip"] tips = [item for item in info_items if item.name.strip() == "Tip"] if facts: rows.append(Text("")) # empty line - info_table = Table( - show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False + label_width = min(_WELCOME_LABEL_WIDTH, max(4, content_width // 3)) + value_width = max(4, content_width - label_width - 2) + info_table = Table.grid(padding=(0, 1)) + info_table.add_column( + justify="right", + style=tui_rich_style("muted"), + no_wrap=True, + width=label_width, ) - info_table.add_column(justify="right", style=tui_rich_style("muted")) - info_table.add_column(justify="left") + info_table.add_column(justify="left", no_wrap=True, width=value_width) for item in facts: value_style = _value_style_for_label(item.name, item.level) - info_table.add_row(item.name, Text(item.value, style=value_style)) + value = _welcome_value(item.name, item.value, value_width) + info_table.add_row(item.name, Text(value, style=value_style, no_wrap=True)) rows.append(info_table) if tips: rows.append(Text("")) # empty line rows.append(Text("Tips", style=tui_rich_style("muted"))) - # 2-col table → wrapped tip lines hang-indent under the text column, - # not under the bullet. - tips_table = Table( - show_header=False, show_edge=False, box=None, padding=(0, 0), expand=False - ) + tip_width = max(4, content_width - 4) + tips_table = Table.grid(padding=(0, 0)) tips_table.add_column(style=tui_rich_style("muted"), no_wrap=True, width=4) - tips_table.add_column(justify="left", overflow="fold") + tips_table.add_column(justify="left", no_wrap=True, width=tip_width) for item in tips: - tip_text = Text(item.value, style=item.level.value) - tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", "yellow bold") - tips_table.add_row(" • ", tip_text) + for index, line in enumerate(_welcome_tip_lines(item.value, tip_width)): + tip_text = Text(line, style=item.level.value, no_wrap=True) + tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", "yellow bold") + tips_table.add_row(" • " if index == 0 else " ", tip_text) rows.append(tips_table) version_title = Text.assemble( @@ -1997,6 +2085,7 @@ def _print_welcome_info( border_style=tui_rich_style("border"), box=box.ROUNDED, expand=False, + width=panel_width, padding=(1, 2), ) ) diff --git a/src/pythinker_code/ui/shell/components/bash_execution.py b/src/pythinker_code/ui/shell/components/bash_execution.py index 1cf1e34f..d823fb5d 100644 --- a/src/pythinker_code/ui/shell/components/bash_execution.py +++ b/src/pythinker_code/ui/shell/components/bash_execution.py @@ -22,7 +22,7 @@ sanitize_ansi, truncate_middle_to_visual_lines, ) -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER, TRANSCRIPT_ASSISTANT_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import tui_rich_style @@ -147,7 +147,7 @@ def _status_header(state: BashExecutionState) -> Text: header = Text("✘ ", style=tui_rich_style("error")) header.append("Ran", style=tui_rich_style("error") + RichStyle(bold=True)) elif state.status == "cancelled": - header = Text("● ", style=tui_rich_style("warning")) + header = Text(f"{TRANSCRIPT_ACTIVE_MARKER} ", style=tui_rich_style("warning")) header.append("Cancelled", style=tui_rich_style("warning") + RichStyle(bold=True)) else: header = Text(f"{TRANSCRIPT_ASSISTANT_MARKER} ", style=tui_rich_style("success")) diff --git a/src/pythinker_code/ui/shell/components/bordered_loader.py b/src/pythinker_code/ui/shell/components/bordered_loader.py index 862c5a47..3ea7d18e 100644 --- a/src/pythinker_code/ui/shell/components/bordered_loader.py +++ b/src/pythinker_code/ui/shell/components/bordered_loader.py @@ -16,6 +16,7 @@ from rich.text import Text from pythinker_code.ui.shell.components.dynamic_border import DynamicBorder +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.keymap import key_text from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import tui_rich_style @@ -47,7 +48,11 @@ def render_bordered_loader(state: BorderedLoaderState) -> RenderableType: """Build the bordered loader renderable for *state*.""" muted = tui_rich_style("muted") - glyph = "●" if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 else " " + glyph = ( + TRANSCRIPT_ACTIVE_MARKER + if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 + else " " + ) loading = Text(f"{glyph} ", style=muted) loading.append(state.message, style=muted) diff --git a/src/pythinker_code/ui/shell/components/tool_execution.py b/src/pythinker_code/ui/shell/components/tool_execution.py index ea324415..3d4edaf0 100644 --- a/src/pythinker_code/ui/shell/components/tool_execution.py +++ b/src/pythinker_code/ui/shell/components/tool_execution.py @@ -29,7 +29,7 @@ from pythinker_code.ui.shell.components.key_hints import key_hint from pythinker_code.ui.shell.components.render_utils import render_message_response -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ASSISTANT_MARKER +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER, TRANSCRIPT_ASSISTANT_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.shell.spacing import TINTED_CARD_PADDING from pythinker_code.ui.shell.tool_renderers import ( @@ -260,7 +260,7 @@ def _call_fallback(self) -> RenderableType: glyph = "✘ " glyph_style = tui_rich_style("error") + Style(bold=True) elif self._status == ToolExecutionStatus.CANCELLED: - glyph = "● " + glyph = f"{TRANSCRIPT_ACTIVE_MARKER} " glyph_style = tui_rich_style("warning") + Style(bold=True) else: active = reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 diff --git a/src/pythinker_code/ui/shell/design_system.py b/src/pythinker_code/ui/shell/design_system.py index d348d5d9..9e3f187b 100644 --- a/src/pythinker_code/ui/shell/design_system.py +++ b/src/pythinker_code/ui/shell/design_system.py @@ -10,6 +10,7 @@ from rich.text import Text from pythinker_code.ui.shell.components.render_utils import cell_width, truncate_to_width +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.theme import tui_rich_style @@ -46,9 +47,9 @@ class ShellTone(StrEnum): } _STATUS: dict[StatusName, tuple[str, ShellTone]] = { - "running": ("●", ShellTone.ACCENT), + "running": (TRANSCRIPT_ACTIVE_MARKER, ShellTone.MUTED), "completed": ("✓", ShellTone.SUCCESS), - "failed": ("!", ShellTone.ERROR), + "failed": ("✘", ShellTone.ERROR), "denied": ("×", ShellTone.WARNING), "interrupted": ("■", ShellTone.MUTED), "waiting": ("○", ShellTone.MUTED), diff --git a/src/pythinker_code/ui/shell/glyphs.py b/src/pythinker_code/ui/shell/glyphs.py index 157040e8..2a786ac8 100644 --- a/src/pythinker_code/ui/shell/glyphs.py +++ b/src/pythinker_code/ui/shell/glyphs.py @@ -9,18 +9,29 @@ from typing import Final -#: Braille dotted spinner frames, rendered left-to-right per tick. -SPINNER_FRAMES: Final = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") -#: Solid dot pulse for the Composing / Thinking activity lines instead of the -#: dotted braille spinner. The blank frame keeps the label column stable while -#: making the dot appear and disappear. -SHAPE_FRAMES: Final = ("●", " ") +from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled + +_ASCII_GLYPHS = ascii_glyphs_enabled() + +#: Braille dotted spinner frames, rendered left-to-right per tick. ASCII mode is +#: available for legacy Windows code pages, ``TERM=dumb``, and users who set +#: ``PYTHINKER_TUI_GLYPHS=ascii``. +SPINNER_FRAMES: Final = ( + ("-", "\\", "|", "/") if _ASCII_GLYPHS else ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") +) +#: Text-safe pulse for the Composing / Thinking activity lines instead of the +#: dotted braille spinner. Use the filled text circle so active transcript, +#: tool, and subagent markers read at the same weight as the reference CLI. +#: ASCII mode keeps legacy Windows code pages, ``TERM=dumb``, and explicit safe +#: glyph requests on a plain star. The blank frame keeps the label column stable +#: while making the marker appear and disappear. +SHAPE_FRAMES: Final = ("*", " ") if _ASCII_GLYPHS else ("●", " ") #: Seconds each braille spinner frame stays on screen. SPINNER_FRAME_INTERVAL_S: Final = 0.08 -#: Seconds each solid-dot pulse frame stays on screen. +#: Seconds each text-safe pulse frame stays on screen. SHAPE_FRAME_INTERVAL_S: Final = 0.45 #: Static stand-in used when motion is disabled. -REDUCED_MOTION_GLYPH: Final = "●" +REDUCED_MOTION_GLYPH: Final = "*" if _ASCII_GLYPHS else "●" #: Braille dotted frames for the active task/status marker beside pinned todos. ACTIVE_MARKER_FRAMES: Final = SPINNER_FRAMES #: Seconds each active-marker braille frame stays on screen. @@ -29,16 +40,18 @@ STAR_SPINNER_FRAMES: Final = ACTIVE_MARKER_FRAMES STAR_SPINNER_FRAME_INTERVAL_S: Final = ACTIVE_MARKER_FRAME_INTERVAL_S -#: Transcript row marker for assistant/tool-call lines. -TRANSCRIPT_ASSISTANT_MARKER: Final = "⏺" +#: Transcript row marker for assistant/tool-call lines. U+23FA (record button) +#: renders as a blue emoji tile on some Windows terminals; keep this as a +#: monochrome text circle. +TRANSCRIPT_ASSISTANT_MARKER: Final = "*" if _ASCII_GLYPHS else "●" #: Transcript prompt marker for submitted user input. -TRANSCRIPT_PROMPT_MARKER: Final = "❯" +TRANSCRIPT_PROMPT_MARKER: Final = ">" if _ASCII_GLYPHS else "❯" #: Transcript marker for completed thinking/status timing rows. -TRANSCRIPT_STATUS_MARKER: Final = "✻" +TRANSCRIPT_STATUS_MARKER: Final = "*" if _ASCII_GLYPHS else "✻" #: Transcript marker for active task/status rows when motion is disabled. TRANSCRIPT_ACTIVE_MARKER: Final = REDUCED_MOTION_GLYPH #: Transcript gutter marker for tool results. -TRANSCRIPT_TOOL_GUTTER: Final = "⎿" +TRANSCRIPT_TOOL_GUTTER: Final = "|" if _ASCII_GLYPHS else "⎿" __all__ = [ "SPINNER_FRAMES", diff --git a/src/pythinker_code/ui/shell/mcp_status.py b/src/pythinker_code/ui/shell/mcp_status.py index 25c09ba0..c66e21dc 100644 --- a/src/pythinker_code/ui/shell/mcp_status.py +++ b/src/pythinker_code/ui/shell/mcp_status.py @@ -8,6 +8,7 @@ from rich.text import Text from pythinker_code.ui.shell.components.render_utils import sanitize_ansi +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import get_mcp_prompt_colors, tui_rich_style from pythinker_code.wire.types import MCPServerSnapshot, MCPStatusSnapshot @@ -55,7 +56,7 @@ def mcp_startup_header(snapshot: MCPStatusSnapshot) -> str | None: def render_mcp_startup_text(snapshot: MCPStatusSnapshot, *, now: float | None = None) -> Text: """Render the animated MCP startup status used by live prompt/status areas.""" t = time.monotonic() if now is None else now - glyph = "●" if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append( mcp_startup_header(snapshot) or "Starting MCP servers", @@ -96,7 +97,7 @@ def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType: def render_mcp_inventory_loading(*, now: float | None = None) -> RenderableType: t = time.monotonic() if now is None else now - glyph = "●" if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append("Loading MCP inventory", style=tui_rich_style("tool_title") + Style(bold=True)) line.append("…", style=tui_rich_style("muted")) @@ -134,7 +135,8 @@ def render_mcp_prompt(snapshot: MCPStatusSnapshot, *, now: float | None = None) colors = get_mcp_prompt_colors() t = time.monotonic() if now is None else now - prefix = f"{'●' if int(t / 0.8) % 2 == 0 else ' '} " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + prefix = f"{glyph} " return FormattedText([(colors.text, f"{prefix}{header}"), ("", "\n")]) diff --git a/src/pythinker_code/ui/shell/motion.py b/src/pythinker_code/ui/shell/motion.py index 1757dab1..c77df97f 100644 --- a/src/pythinker_code/ui/shell/motion.py +++ b/src/pythinker_code/ui/shell/motion.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from dataclasses import dataclass from typing import Literal @@ -23,6 +22,7 @@ SPINNER_FRAMES, TRANSCRIPT_ACTIVE_MARKER, ) +from pythinker_code.ui.terminal_capabilities import colors_disabled, motion_disabled from pythinker_code.ui.theme import tui_rich_style from pythinker_code.utils.datetime import format_elapsed @@ -32,6 +32,8 @@ def verb_spinner_style() -> Style: """Muted yellow style for the active verb spinner word.""" + if colors_disabled(): + return Style() return Style(color=Color.parse("#E6B450")) # brand-exception: muted yellow verb shimmer @@ -48,6 +50,8 @@ def shimmer_spinner_style(elapsed_s: float, *, reduced_motion: bool = False) -> Reduced motion pins to the base muted yellow so the word stays calm. """ + if colors_disabled(): + return Style() if reduced_motion or reduced_motion_enabled(): return Style(color=Color.parse(_SHIMMER_BASE)) palette = (_SHIMMER_BASE, _SHIMMER_MID, _SHIMMER_HIGHLIGHT, _SHIMMER_MID) @@ -65,6 +69,8 @@ def _shimmer_segments( """ if not label: return [] + if colors_disabled(): + return [(None, label)] if reduced_motion or reduced_motion_enabled(): return [(_SHIMMER_BASE, label)] @@ -133,12 +139,7 @@ class ActivitySnapshot: def reduced_motion_enabled() -> bool: - return os.environ.get("PYTHINKER_REDUCED_MOTION", "").strip().lower() in { - "1", - "true", - "yes", - "on", - } + return motion_disabled() def spinner_frame_at( @@ -197,7 +198,7 @@ def activity_status_line(snapshot: ActivitySnapshot, *, width: int | None = None glyph_style = thinking_style else: # The dotted braille spinner is a marker; keep it silver while the verb shimmers. - glyph_style = _SPINNER_SILVER_STYLE + glyph_style = Style() if colors_disabled() else _SPINNER_SILVER_STYLE if snapshot.label_style is not None: label_style = snapshot.label_style elif snapshot.spinner == "shape": diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index d0b63e47..5e943a01 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -60,7 +60,11 @@ from pythinker_code.tools.display import TodoDisplayItem from pythinker_code.ui.shell import placeholders as prompt_placeholders from pythinker_code.ui.shell.console import console -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_PROMPT_MARKER, TRANSCRIPT_TOOL_GUTTER +from pythinker_code.ui.shell.glyphs import ( + TRANSCRIPT_ACTIVE_MARKER, + TRANSCRIPT_PROMPT_MARKER, + TRANSCRIPT_TOOL_GUTTER, +) from pythinker_code.ui.shell.motion import shimmer_prompt_fragments from pythinker_code.ui.shell.placeholders import ( PromptPlaceholderManager, @@ -2610,7 +2614,7 @@ def _render_background_working_status( if total <= 0: return FormattedText([]) now = time.monotonic() - frame = "●" if int(now / 0.8) % 2 == 0 else " " + frame = TRANSCRIPT_ACTIVE_MARKER if int(now / 0.8) % 2 == 0 else " " noun = "process" if total == 1 else "processes" detail = f"{total} background {noun}" if counts.agent and counts.bash: @@ -3012,7 +3016,7 @@ def _render_bottom_toolbar(self) -> FormattedText: secondary_style = f"fg:{tokens.muted}" mode = str(self._mode) if self._mode == PromptMode.AGENT and self._model_name: - thinking_dot = "●" if self._thinking else "○" + thinking_dot = TRANSCRIPT_ACTIVE_MARKER if self._thinking else "○" mode_full = f"{mode} ({self._model_name} {thinking_dot})" mode_mid = f"{mode} {thinking_dot}" if _display_width(mode_full) <= remaining - 2: @@ -3182,7 +3186,7 @@ def _append_right(style: str, text: str) -> None: ) _append_right(secondary_style, ctx_compact) if self._model_name: - thinking_dot = "●" if self._thinking else "○" + thinking_dot = TRANSCRIPT_ACTIVE_MARKER if self._thinking else "○" mode = str(self._mode) _append_right(mode_style, f"{mode} {self._model_name} {thinking_dot}") right_text = " ".join(right_parts) diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index dadd7082..4d378811 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -564,14 +564,17 @@ def _feedback_destination(soul: PythinkerSoul) -> tuple[str, dict[str, str]] | N return None provider = soul.runtime.config.providers.get(managed_provider_key(PYTHINKER_CODE_PLATFORM_ID)) - if provider is not None: - if provider.custom_headers: - headers.update(provider.custom_headers) - api_key = provider.api_key.get_secret_value() - if provider.oauth is not None: - api_key = soul.runtime.oauth.resolve_api_key(provider.api_key, provider.oauth) - if api_key: - headers["Authorization"] = f"Bearer {api_key}" + if provider is None: + return None + if provider.custom_headers: + headers.update(provider.custom_headers) + api_key = provider.api_key.get_secret_value() + if provider.oauth is not None: + api_key = soul.runtime.oauth.resolve_api_key(provider.api_key, provider.oauth) + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + if not any(key.lower() == "authorization" and value for key, value in headers.items()): + return None return f"{pythinker_platform.base_url.rstrip('/')}/feedback", headers @@ -1622,8 +1625,14 @@ async def update_command(app: Shell, args: str): """Check for and optionally install the latest Pythinker version.""" _ = args, app from pythinker_code.ui.shell.update import UpdateResult, run_update_prompt + from pythinker_code.ui.shell.update_orchestrator import run_update_job + + async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult: + return await run_update_job( + print_output=print_output, check_only=check_only, source="slash" + ) - result = await run_update_prompt() + result = await run_update_prompt(update_runner=_runner) if result is UpdateResult.UPDATED: console.print("Updated — restart Pythinker to use the new version.") diff --git a/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py b/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py index 6298c5f6..b32eefb0 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py +++ b/src/pythinker_code/ui/shell/tool_renderers/_render_utils.py @@ -70,6 +70,10 @@ def tool_title(label: str) -> Text: return Text(label, style=base + RichStyle(bold=True)) +def _status_marker(style_token: str) -> str: + return "✘" if style_token == "error" else TRANSCRIPT_ASSISTANT_MARKER + + def tool_call_header( name: str, summary: str | Text | None = None, @@ -79,11 +83,12 @@ def tool_call_header( ) -> Text: """Return the Blackbox/Claude-style tool-use row. - Shape: ``• Tool(summary)``. The surrounding ``ToolExecutionComponent`` - owns result gutters; individual renderers should keep this row compact. + Shape: ``● Tool(summary)`` for completed rows, ``✘ Tool(summary)`` for + failed rows. The surrounding ``ToolExecutionComponent`` owns result + gutters; individual renderers should keep this row compact. """ header = Text() - header.append(f"{TRANSCRIPT_ASSISTANT_MARKER} ", style=tui_rich_style(style_token)) + header.append(f"{_status_marker(style_token)} ", style=tui_rich_style(style_token)) header.append_text(tool_title(name)) if summary is not None: paren_style = tui_rich_style(paren_style_token) diff --git a/src/pythinker_code/ui/shell/tool_renderers/agent.py b/src/pythinker_code/ui/shell/tool_renderers/agent.py index 8d4ccf86..f7c2e2ff 100644 --- a/src/pythinker_code/ui/shell/tool_renderers/agent.py +++ b/src/pythinker_code/ui/shell/tool_renderers/agent.py @@ -60,7 +60,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: if resume: summary.append_text(fg(secondary_token, f" · resume {resume[:8]}")) - style_token = "error" if ctx.is_error else "success" if ctx.has_result else secondary_token + style_token = "error" if ctx.is_error else "success" if ctx.has_result else "muted" header = tool_call_header( "Agent", summary, style_token=style_token, paren_style_token=secondary_token ) @@ -78,7 +78,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: rendered, execution_started=ctx.execution_started, has_result=ctx.has_result, - marker_style_token=secondary_token, + marker_style_token="muted", ) rendered = Group(header, *missing) if missing else header @@ -86,7 +86,7 @@ def _render_call(ctx: ToolRenderContext) -> RenderableType: rendered, execution_started=ctx.execution_started, has_result=ctx.has_result, - marker_style_token=secondary_token, + marker_style_token="muted", ) @@ -154,7 +154,7 @@ def _render_run_agents_call(ctx: ToolRenderContext) -> RenderableType: header, execution_started=ctx.execution_started, has_result=ctx.has_result, - marker_style_token="thinking_text", + marker_style_token="muted", ) else: summary_text.append_text(fg("border_accent", _plural(len(agent_summaries), "agent"))) @@ -191,7 +191,7 @@ def _render_run_agents_call(ctx: ToolRenderContext) -> RenderableType: rendered, execution_started=ctx.execution_started, has_result=ctx.has_result, - marker_style_token="thinking_text", + marker_style_token="muted", ) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 74d23641..3493cdcb 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -9,8 +9,9 @@ import subprocess import sys import tarfile +import threading import time -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Mapping from enum import Enum, auto from pathlib import Path from shutil import which @@ -54,6 +55,7 @@ AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60 PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS = 2.0 WINDOWS_UPDATE_STAGING_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 +UPGRADE_COMMAND_TIMEOUT_SECONDS = 30 * 60 _UPDATE_LOCK = asyncio.Lock() _skipped_version_this_session: str | None = None @@ -76,6 +78,9 @@ class UpdatePromptSelection(Enum): EXIT = auto() +type UpdateRunner = Callable[..., Awaitable[UpdateResult]] + + def semver_tuple(version: str) -> tuple[int, int, int]: v = version.strip() if v.startswith("v"): @@ -280,7 +285,7 @@ def _mark_auto_update_check_attempt() -> None: logger.exception("Failed to write last update-check timestamp:") -async def prompt_pre_start_update() -> None: +async def prompt_pre_start_update(update_runner: UpdateRunner | None = None) -> None: """pythinker-x-style blocking update prompt for the interactive shell. Runs once at startup, before the agent loop. When a newer native release @@ -314,7 +319,10 @@ async def prompt_pre_start_update() -> None: _skip_version_this_session(latest_version) return - result = await do_update(print=True) + if update_runner is None: + result = await do_update(print_output=True) + else: + result = await update_runner(print_output=True, check_only=False) if result is UpdateResult.UPDATED: # do_update() already printed "Updated successfully!" + the relaunch # hint. Wait for the user to acknowledge before exiting so the message @@ -474,7 +482,7 @@ async def _refresh_update_cache(*, force: bool) -> UpdateResult | None: if not force and not _should_auto_check_for_updates(): return None try: - result = await do_update(print=False, check_only=True) + result = await do_update(print_output=False, check_only=True) except Exception: logger.exception("Update cache refresh failed:") return None @@ -536,7 +544,7 @@ def pending_update_notice() -> str | None: return f"Update available: {current_version} → {cached}. Run /update to install." -async def run_update_prompt() -> UpdateResult | None: +async def run_update_prompt(update_runner: UpdateRunner | None = None) -> UpdateResult | None: """Interactive ``/update`` flow: refresh, show the 3-choice modal, install. In-shell safe — unlike ``prompt_pre_start_update`` it does not block on raw @@ -546,7 +554,10 @@ async def run_update_prompt() -> UpdateResult | None: """ from pythinker_code.constant import VERSION as current_version - refresh_result = await do_update(print=True, check_only=True) + if update_runner is None: + refresh_result = await do_update(print_output=True, check_only=True) + else: + refresh_result = await update_runner(print_output=True, check_only=True) if refresh_result is UpdateResult.UP_TO_DATE: return UpdateResult.UP_TO_DATE if refresh_result is UpdateResult.FAILED: @@ -567,7 +578,9 @@ async def run_update_prompt() -> UpdateResult | None: if selection is not UpdatePromptSelection.UPDATE_NOW: _skip_version_this_session(latest_version) return None - return await do_update(print=True) + if update_runner is None: + return await do_update(print_output=True) + return await update_runner(print_output=True, check_only=False) async def _prompt_update_selection( @@ -1104,18 +1117,80 @@ async def _maybe_run_native_update(latest_version: str, channel: str = "latest") shutil.rmtree(tmpdir, ignore_errors=True) -async def do_update(*, print: bool = True, check_only: bool = False) -> UpdateResult: +def _run_upgrade_command( + command: list[str], + *, + print_output: bool, + output_callback: Callable[[str], None] | None, +) -> int: + def _emit(text: str) -> None: + if output_callback is not None: + output_callback(text) + if print_output: + console.print(text, markup=False) + + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + env=get_clean_env(), + bufsize=1, + ) + + def _drain_stdout() -> None: + if proc.stdout is None: + return + for line in proc.stdout: + _emit(line.rstrip("\n")) + + output_thread = threading.Thread(target=_drain_stdout, daemon=True) + output_thread.start() + try: + return proc.wait(timeout=UPGRADE_COMMAND_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + _emit(f"Upgrade command timed out after {UPGRADE_COMMAND_TIMEOUT_SECONDS} seconds.") + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + return 124 + finally: + output_thread.join(timeout=5) + + +async def do_update( + *, + print_output: bool = True, + check_only: bool = False, + output_callback: Callable[[str], None] | None = None, +) -> UpdateResult: async with _UPDATE_LOCK: - return await _do_update(print=print, check_only=check_only) + return await _do_update( + print_output=print_output, + check_only=check_only, + output_callback=output_callback, + ) -async def _do_update(*, print: bool, check_only: bool) -> UpdateResult: +async def _do_update( + *, + print_output: bool, + check_only: bool, + output_callback: Callable[[str], None] | None, +) -> UpdateResult: from pythinker_code.constant import VERSION as current_version _t = _get_tui_tokens() def _print(message: str) -> None: - if print: + if output_callback is not None: + output_callback(message) + if print_output: console.print(message) timeout = aiohttp.ClientTimeout(total=15, sock_connect=5, sock_read=10) @@ -1218,14 +1293,18 @@ def _print(message: str) -> None: sys.exit(0) try: - result = subprocess.run(upgrade_command) + returncode = _run_upgrade_command( + upgrade_command, + print_output=print_output, + output_callback=output_callback, + ) except OSError as e: logger.exception("Upgrade failed:") _print(f"[{_t.error}]Upgrade failed:[/] {e}") _print(f"Please run manually: {upgrade_command_text}") return UpdateResult.FAILED - if result.returncode == 0: + if returncode == 0: _print(f"[{_t.success}]Updated successfully![/]") _print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]") return UpdateResult.UPDATED diff --git a/src/pythinker_code/ui/shell/update_orchestrator.py b/src/pythinker_code/ui/shell/update_orchestrator.py new file mode 100644 index 00000000..45e01dc4 --- /dev/null +++ b/src/pythinker_code/ui/shell/update_orchestrator.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +import contextlib +import json +import os +import subprocess +import sys +import time +import uuid +from dataclasses import asdict, dataclass +from enum import StrEnum +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from pythinker_code.constant import VERSION as CURRENT_VERSION +from pythinker_code.native import is_native_build +from pythinker_code.share import get_share_dir +from pythinker_code.ui.shell.console import console +from pythinker_code.ui.theme import get_tui_tokens as _get_tui_tokens +from pythinker_code.utils.logging import logger +from pythinker_code.utils.subprocess_env import get_clean_env + +if TYPE_CHECKING: + from pythinker_code.ui.shell.update import UpdateResult + +UPDATE_STATUS_FILE = get_share_dir() / "update_status.json" +UPDATE_LOG_FILE = get_share_dir() / "update.log" +UPDATE_LOCK_FILE = get_share_dir() / "update.lock" +UPDATE_LAST_SUCCESS_FILE = get_share_dir() / "update_last_success.json" + +_LOCK_STALE_AFTER_SECONDS = 2 * 60 * 60 +_LOCK_MALFORMED_GRACE_SECONDS = 60 +_SMOKE_CHECK_TIMEOUT_SECONDS = 10 + + +class UpdateJobState(StrEnum): + IDLE = "idle" + CHECKING = "checking" + AVAILABLE = "available" + RUNNING = "running" + UPDATED = "updated" + UP_TO_DATE = "up_to_date" + FAILED = "failed" + UNSUPPORTED = "unsupported" + BLOCKED = "blocked" + + +@dataclass(slots=True) +class UpdateJobStatus: + job_id: str + state: UpdateJobState + started_at: float | None + finished_at: float | None + current_version: str | None + target_version: str | None + result: str | None + message: str | None + log_path: str + pid: int | None + source: str = "unknown" + + +@dataclass(slots=True) +class UpdateLock: + path: Path + + def release(self) -> None: + with contextlib.suppress(OSError): + self.path.unlink() + + +def _ensure_parent(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _chmod_owner_only(path: Path) -> None: + with contextlib.suppress(OSError): + os.chmod(path, 0o600) + + +def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None: + _ensure_parent(path) + tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + fd: int | None = None + try: + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fd = None + json.dump(payload, fh, indent=2, sort_keys=True) + os.replace(tmp, path) + _chmod_owner_only(path) + except BaseException: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + with contextlib.suppress(OSError): + tmp.unlink() + raise + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + payload: object = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return cast(dict[str, Any], payload) if isinstance(payload, dict) else None + + +def _pid_exists(pid: int) -> bool: + if pid <= 0: + return False + if pid == os.getpid(): + return True + if os.name == "nt": + # Windows has no POSIX signal-0 probe; avoid os.kill() because it can + # deliver a real signal. Let lock age decide staleness on Windows. + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + except OSError: + # Be conservative on platforms where signal-0 probing is limited. + return True + return True + + +def _lock_payload_is_stale(payload: dict[str, Any] | None, now: float | None = None) -> bool: + now = time.time() if now is None else now + if payload is None: + return False + started_at = _optional_float(payload.get("started_at")) + age = now - started_at if started_at is not None else _LOCK_STALE_AFTER_SECONDS + 1 + if age > _LOCK_STALE_AFTER_SECONDS: + return True + parsed_pid = _optional_int(payload.get("pid")) + if parsed_pid is None: + return age > 60 + return not _pid_exists(parsed_pid) + + +def _lock_file_age_seconds(*, now: float | None = None) -> float | None: + now = time.time() if now is None else now + try: + return now - UPDATE_LOCK_FILE.stat().st_mtime + except OSError: + return None + + +def clear_stale_update_lock() -> bool: + if not UPDATE_LOCK_FILE.exists(): + return False + payload = _read_json(UPDATE_LOCK_FILE) + if payload is None: + age = _lock_file_age_seconds() + if age is None or age <= _LOCK_MALFORMED_GRACE_SECONDS: + return False + elif not _lock_payload_is_stale(payload): + return False + with contextlib.suppress(OSError): + UPDATE_LOCK_FILE.unlink() + return True + return False + + +def acquire_update_lock(*, source: str = "unknown") -> UpdateLock | None: + _ensure_parent(UPDATE_LOCK_FILE) + payload = { + "pid": os.getpid(), + "source": source, + "started_at": time.time(), + } + encoded = json.dumps(payload, sort_keys=True).encode("utf-8") + for _ in range(2): + try: + fd = os.open(UPDATE_LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + except FileExistsError: + if clear_stale_update_lock(): + continue + return None + try: + with os.fdopen(fd, "wb") as fh: + fh.write(encoded) + except OSError: + with contextlib.suppress(OSError): + os.close(fd) + UPDATE_LOCK_FILE.unlink() + raise + return UpdateLock(UPDATE_LOCK_FILE) + return None + + +def write_update_status(status: UpdateJobStatus) -> None: + payload = asdict(status) + payload["state"] = status.state.value + _atomic_write_json(UPDATE_STATUS_FILE, payload) + + +def read_update_status() -> UpdateJobStatus | None: + payload = _read_json(UPDATE_STATUS_FILE) + if payload is None: + return None + try: + state = UpdateJobState(str(payload.get("state") or UpdateJobState.IDLE.value)) + except ValueError: + state = UpdateJobState.FAILED + return UpdateJobStatus( + job_id=str(payload.get("job_id") or ""), + state=state, + started_at=_optional_float(payload.get("started_at")), + finished_at=_optional_float(payload.get("finished_at")), + current_version=_optional_str(payload.get("current_version")), + target_version=_optional_str(payload.get("target_version")), + result=_optional_str(payload.get("result")), + message=_optional_str(payload.get("message")), + log_path=str(payload.get("log_path") or UPDATE_LOG_FILE), + pid=_optional_int(payload.get("pid")), + source=str(payload.get("source") or "unknown"), + ) + + +def _optional_str(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _optional_float(value: object) -> float | None: + if not isinstance(value, int | float | str): + return None + try: + return float(value) + except ValueError: + return None + + +def _optional_int(value: object) -> int | None: + if not isinstance(value, int | float | str): + return None + try: + return int(value) + except ValueError: + return None + + +def append_update_log(message: str) -> None: + _ensure_parent(UPDATE_LOG_FILE) + try: + from pythinker_code.feedback import redact_text + + fd: int | None = os.open( + UPDATE_LOG_FILE, + os.O_WRONLY | os.O_CREAT | os.O_APPEND, + 0o600, + ) + try: + with os.fdopen(fd, "a", encoding="utf-8") as fh: + fd = None + fh.write(redact_text(message).rstrip("\n") + "\n") + finally: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + _chmod_owner_only(UPDATE_LOG_FILE) + except OSError: + logger.exception("Failed to append update log:") + + +def read_update_log_tail(max_lines: int = 80) -> list[str]: + if max_lines <= 0: + return [] + try: + lines = UPDATE_LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return [] + return lines[-max_lines:] + + +def _result_state(result: object) -> UpdateJobState: + from pythinker_code.ui.shell.update import UpdateResult + + if result is UpdateResult.UPDATE_AVAILABLE: + return UpdateJobState.AVAILABLE + if result is UpdateResult.UPDATED: + return UpdateJobState.UPDATED + if result is UpdateResult.UP_TO_DATE: + return UpdateJobState.UP_TO_DATE + if result is UpdateResult.UNSUPPORTED: + return UpdateJobState.UNSUPPORTED + return UpdateJobState.FAILED + + +def _read_target_version() -> str | None: + from pythinker_code.ui.shell.update import LATEST_VERSION_FILE + + try: + return LATEST_VERSION_FILE.read_text(encoding="utf-8").strip() or None + except OSError: + return None + + +def _new_status( + *, + job_id: str, + state: UpdateJobState, + source: str, + started_at: float | None, + finished_at: float | None = None, + result: str | None = None, + message: str | None = None, +) -> UpdateJobStatus: + return UpdateJobStatus( + job_id=job_id, + state=state, + started_at=started_at, + finished_at=finished_at, + current_version=CURRENT_VERSION, + target_version=_read_target_version(), + result=result, + message=message, + log_path=str(UPDATE_LOG_FILE), + pid=os.getpid(), + source=source, + ) + + +async def run_update_job( + *, + print_output: bool = True, + check_only: bool = False, + source: str = "cli", +) -> UpdateResult: + from pythinker_code.ui.shell.update import UpdateResult, do_update + + lock = acquire_update_lock(source=source) + if lock is None: + message = "Another Pythinker update is already running." + job_id = f"blocked-{uuid.uuid4().hex}" + append_update_log(f"{job_id}: {message}") + if print_output: + console.print(f"[{_get_tui_tokens().warning}]{message}[/]") + return UpdateResult.FAILED + + job_id = uuid.uuid4().hex + started_at = time.time() + state = UpdateJobState.CHECKING if check_only else UpdateJobState.RUNNING + append_update_log(f"\n=== pythinker update {job_id} started ({source}) ===") + write_update_status( + _new_status(job_id=job_id, state=state, source=source, started_at=started_at) + ) + + try: + try: + result = await do_update( + print_output=print_output, + check_only=check_only, + output_callback=append_update_log, + ) + except SystemExit: + message = "Update helper was launched; Pythinker is exiting to finish the update." + append_update_log(message) + write_update_status( + _new_status( + job_id=job_id, + state=UpdateJobState.RUNNING, + source=source, + started_at=started_at, + finished_at=None, + message=message, + ) + ) + raise + + reported_result = result + final_state = _result_state(result) + message = result.name.replace("_", " ").lower() + if result is UpdateResult.UPDATED and not check_only: + smoke_ok, smoke_message = run_post_install_smoke_check() + append_update_log(smoke_message) + if not smoke_ok: + reported_result = UpdateResult.FAILED + final_state = UpdateJobState.FAILED + message = f"Updated, but smoke check failed: {smoke_message}" + else: + message = smoke_message + _write_last_success(job_id=job_id, message=message) + + write_update_status( + _new_status( + job_id=job_id, + state=final_state, + source=source, + started_at=started_at, + finished_at=time.time(), + result=reported_result.name, + message=message, + ) + ) + return reported_result + except Exception as exc: + message = f"Update failed: {exc}" + append_update_log(message) + write_update_status( + _new_status( + job_id=job_id, + state=UpdateJobState.FAILED, + source=source, + started_at=started_at, + finished_at=time.time(), + result=UpdateResult.FAILED.name, + message=message, + ) + ) + raise + finally: + lock.release() + + +def _write_last_success(*, job_id: str, message: str) -> None: + try: + _atomic_write_json( + UPDATE_LAST_SUCCESS_FILE, + { + "job_id": job_id, + "finished_at": time.time(), + "message": message, + }, + ) + except OSError: + logger.exception("Failed to write last successful update marker:") + + +def _smoke_check_command() -> list[str]: + if is_native_build(): + return [sys.executable, "--version"] + return [sys.executable, "-m", "pythinker_code", "--version"] + + +def run_post_install_smoke_check() -> tuple[bool, str]: + command = _smoke_check_command() + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_SMOKE_CHECK_TIMEOUT_SECONDS, + env=get_clean_env(), + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Smoke check could not run: {exc}" + + output = ((result.stdout or "") + (result.stderr or "")).strip() + if result.returncode != 0: + detail = output.splitlines()[0] if output else f"exit code {result.returncode}" + return False, f"Smoke check failed: {detail}" + if not output or not any(ch.isdigit() for ch in output): + return False, "Smoke check did not report a version." + return True, f"Smoke check passed: {output.splitlines()[0]}" + + +async def prompt_pre_start_update_job() -> None: + from pythinker_code.ui.shell.update import prompt_pre_start_update + + async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult: + return await run_update_job( + print_output=print_output, check_only=check_only, source="startup" + ) + + await prompt_pre_start_update(update_runner=_runner) diff --git a/src/pythinker_code/ui/shell/visualize/_activity_tree.py b/src/pythinker_code/ui/shell/visualize/_activity_tree.py index 39e7ad7a..72d0744c 100644 --- a/src/pythinker_code/ui/shell/visualize/_activity_tree.py +++ b/src/pythinker_code/ui/shell/visualize/_activity_tree.py @@ -11,7 +11,7 @@ from pythinker_code.ui.shell.components.render_utils import cell_width, truncate_to_width from pythinker_code.ui.shell.design_system import ShellTone, shell_style, status_icon -from pythinker_code.ui.shell.motion import shimmer_text +from pythinker_code.ui.shell.motion import reduced_motion_enabled, shimmer_text ActivityState = Literal["running", "completed", "failed", "waiting", "denied", "interrupted"] @@ -24,9 +24,19 @@ class ActivityRow: identity: str | None = None +def _row_marker(state: ActivityState, now: float) -> Text: + """Status marker for an activity row, pulsing the running dot in step with + the rest of the shell (`loading_marker`/`_worklog`) on a 0.8s cadence.""" + icon = status_icon(state) + if state == "running" and not reduced_motion_enabled() and int(now / 0.8) % 2 != 0: + return Text(" ", style=icon.style) + return icon + + def render_activity_tree( - rows: list[ActivityRow], *, width: int, max_rows: int = 4 + rows: list[ActivityRow], *, width: int, max_rows: int = 4, now: float | None = None ) -> RenderableType: + clock = time.monotonic() if now is None else now rendered: list[RenderableType] = [] visible = rows[-max_rows:] hidden = max(0, len(rows) - len(visible)) @@ -36,12 +46,12 @@ def render_activity_tree( prefix = f"{branch} {label} " available = max(1, width - cell_width(prefix) - 4) text = Text() - text.append_text(status_icon(row.state)) + text.append_text(_row_marker(row.state, clock)) text.append(" ") text.append(prefix, style=shell_style(ShellTone.MUTED)) detail = truncate_to_width(row.detail, available) if row.state == "running": - text.append_text(shimmer_text(detail, time.monotonic())) + text.append_text(shimmer_text(detail, clock)) else: text.append(detail, style=shell_style(ShellTone.MUTED)) rendered.append(text) diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 3fac8375..6aaf3744 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -10,7 +10,7 @@ import json import random import time -from collections import deque +from collections import Counter, deque from typing import Any, NamedTuple, cast import streamingjson # type: ignore[reportMissingTypeStubs] @@ -20,6 +20,7 @@ from pythinker_code.soul import format_context_status, format_token_count from pythinker_code.tools import extract_key_argument +from pythinker_code.tools.display import DiffDisplayBlock from pythinker_code.ui.shell.components import ToolExecutionComponent from pythinker_code.ui.shell.components.markdown import ( PythinkerMarkdown as Markdown, @@ -54,6 +55,8 @@ from pythinker_code.utils.datetime import format_elapsed from pythinker_code.utils.rich.columns import BulletColumns from pythinker_code.wire.types import ( + HookResolved, + HookTriggered, MCPStatusSnapshot, Notification, ProgressNote, @@ -71,12 +74,24 @@ MAX_SUBAGENT_TOOL_CALLS_TO_SHOW = 4 _MAX_RUNNING_ROWS = 2 _MAX_SUB_OUTPUT_CHARS = 200 +_MAX_SUBAGENT_ROLLUP_TOOLS = 6 +_MAX_SUBAGENT_CHANGED_FILES = 5 # Background-agent statuses that mean "still running" — the tool call result # has arrived but the spawned agent has not yet finished. Blocks with this # status must stay in the Live area so their spinner keeps animating. _AGENT_ACTIVE_STATUSES = frozenset({"created", "starting", "running", "awaiting_approval"}) _TODO_TOOL_NAMES = frozenset({"SetTodoList", "TodoWrite"}) +_MUTATING_TOOL_NAMES = frozenset( + { + "applypatch", + "edit", + "replace", + "strreplacefile", + "write", + "writefile", + } +) def _is_active_background_agent(tool_name: str, result_text: str) -> bool: @@ -371,6 +386,9 @@ def __init__(self, tool_call: ToolCall): self._ongoing_subagent_tool_calls: dict[str, ToolCall] = {} self._last_subagent_tool_call: ToolCall | None = None self._n_finished_subagent_tool_calls = 0 + self._finished_subagent_tool_counts: Counter[str] = Counter() + self._subagent_changed_files: list[str] = [] + self._subagent_changed_file_set: set[str] = set() self._finished_subagent_tool_calls = deque[_ToolCallBlock.FinishedSubCall]( maxlen=MAX_SUBAGENT_TOOL_CALLS_TO_SHOW ) @@ -499,6 +517,7 @@ def finish_sub_tool_call(self, tool_result: ToolResult): self._subagent_output_parts.pop(tool_result.tool_call_id, None) self._subagent_output_had_stderr.pop(tool_result.tool_call_id, None) self._subagent_execution_started.discard(tool_result.tool_call_id) + self._record_finished_subagent_call(sub_tool_call, tool_result.return_value) self._finished_subagent_tool_calls.append( _ToolCallBlock.FinishedSubCall( @@ -540,16 +559,88 @@ def append_sub_output_part( self._subagent_output_parts[tool_call_id] = [combined[-_MAX_SUB_OUTPUT_CHARS:]] self._renderable = self._compose() - def _subagent_activity_children(self, style_label: str) -> list[RenderableType]: + def _record_finished_subagent_call( + self, sub_tool_call: ToolCall, result: ToolReturnValue + ) -> None: + self._finished_subagent_tool_counts[sub_tool_call.function.name] += 1 + for path in self._changed_paths_from_sub_call(sub_tool_call, result): + if path in self._subagent_changed_file_set: + continue + self._subagent_changed_file_set.add(path) + self._subagent_changed_files.append(path) + + def _changed_paths_from_sub_call( + self, sub_tool_call: ToolCall, result: ToolReturnValue + ) -> list[str]: + paths: list[str] = [] + for block in getattr(result, "display", []) or []: + if isinstance(block, DiffDisplayBlock): + paths.append(block.path) + if paths: + return paths + + if sub_tool_call.function.name.lower() not in _MUTATING_TOOL_NAMES: + return [] + try: + args = json.loads(sub_tool_call.function.arguments or "{}", strict=False) + except json.JSONDecodeError: + return [] + if not isinstance(args, dict): + return [] + parsed_args = cast(dict[str, Any], args) + raw_path = parsed_args.get("path") or parsed_args.get("file_path") + return [str(raw_path)] if raw_path else [] + + def _subagent_rollup_children(self) -> list[RenderableType]: + children: list[RenderableType] = [] + if self._finished_subagent_tool_counts: + parts: list[str] = [] + for tool_name, count in self._finished_subagent_tool_counts.most_common( + _MAX_SUBAGENT_ROLLUP_TOOLS + ): + label = tool_style(tool_name).label + parts.append(f"{label} ×{count}" if count > 1 else label) + hidden = len(self._finished_subagent_tool_counts) - len(parts) + if hidden > 0: + parts.append(f"+{hidden} more") + children.append( + BulletColumns( + Text("tools: " + ", ".join(parts), style=tui_rich_style("muted")), + bullet_style=tui_rich_style("muted"), + ) + ) + if self._subagent_changed_files: + shown = self._subagent_changed_files[:_MAX_SUBAGENT_CHANGED_FILES] + suffix = "" + hidden = len(self._subagent_changed_files) - len(shown) + if hidden > 0: + suffix = f", +{hidden} more" + children.append( + BulletColumns( + Text( + "changed: " + ", ".join(shown) + suffix, + style=tui_rich_style("muted"), + ), + bullet_style=tui_rich_style("muted"), + ) + ) + return children + + def _subagent_activity_children( + self, style_label: str, *, include_completed_subagent: bool = False + ) -> list[RenderableType]: children: list[RenderableType] = [] - if not (style_label == "Subagent" and self._result is not None): + should_show_activity = include_completed_subagent or not ( + style_label == "Subagent" and self._result is not None + ) + if should_show_activity: # Finished sub-tool call rows rows: list[ActivityRow] = [] for sub_call, sub_result in self._finished_subagent_tool_calls: argument = extract_key_argument( sub_call.function.arguments or "", sub_call.function.name ) - detail = sub_call.function.name + detail = tool_style(sub_call.function.name).label if argument: detail = f"{detail} {argument}" rows.append( @@ -567,7 +658,7 @@ def _subagent_activity_children(self, style_label: str) -> list[RenderableType]: running_rows: list[ActivityRow] = [] for call in visible_running: argument = extract_key_argument(call.function.arguments or "", call.function.name) - detail = call.function.name + detail = tool_style(call.function.name).label if argument: detail = f"{detail} {argument}" state = "running" if call.id in self._subagent_execution_started else "waiting" @@ -642,6 +733,7 @@ def _compose(self) -> RenderableType: style=tui_rich_style("muted"), ) children.append(BulletColumns(summary, bullet_style=tui_rich_style("muted"))) + children.extend(self._subagent_rollup_children()) elif self._n_finished_subagent_tool_calls > MAX_SUBAGENT_TOOL_CALLS_TO_SHOW: n_hidden = self._n_finished_subagent_tool_calls - MAX_SUBAGENT_TOOL_CALLS_TO_SHOW children.append( @@ -653,7 +745,12 @@ def _compose(self) -> RenderableType: bullet_style=tui_rich_style("muted"), ) ) - children.extend(self._subagent_activity_children(style.label)) + children.extend( + self._subagent_activity_children( + style.label, + include_completed_subagent=style.label == "Subagent" and self._result is not None, + ) + ) if self._result is None: streamed_output = self._streamed_output_text() @@ -750,7 +847,16 @@ def _compose_card(self) -> RenderableType | None: is_partial=True, ) card_rendered = self._tui_card.render() - activity_children = self._subagent_activity_children(tool_style(self._tool_name).label) + style_label = tool_style(self._tool_name).label + activity_children: list[RenderableType] = [] + if style_label == "Subagent" and self._result is not None: + activity_children.extend(self._subagent_rollup_children()) + activity_children.extend( + self._subagent_activity_children( + style_label, + include_completed_subagent=style_label == "Subagent" and self._result is not None, + ) + ) if activity_children: return Group(card_rendered, *activity_children) return card_rendered @@ -861,6 +967,48 @@ def compose(self) -> RenderableType: return BulletColumns(Group(*lines), bullet_style=style) +class _HookBlock: + """Compact lifecycle row for configured hooks around prompts and tools.""" + + def __init__(self, triggered: HookTriggered) -> None: + self.event = triggered.event + self.target = triggered.target + self.hook_count = triggered.hook_count + self.resolved: HookResolved | None = None + + def resolve(self, resolved: HookResolved) -> None: + self.resolved = resolved + self.event = resolved.event + self.target = resolved.target + + @property + def finished(self) -> bool: + return self.resolved is not None + + def compose(self) -> RenderableType: + target = self.event if not self.target else f"{self.event} {self.target}" + detail_parts: list[str] = [] + if self.hook_count > 1: + detail_parts.append(f"{self.hook_count} hooks") + if self.resolved is None: + state = WorkLogState.RUNNING + elif self.resolved.action == "block": + state = WorkLogState.FAILED + reason = " ".join(self.resolved.reason.split()) + if reason: + detail_parts.append(reason[:120] + ("…" if len(reason) > 120 else "")) + else: + state = WorkLogState.COMPLETED + if self.resolved is not None and self.resolved.duration_ms: + detail_parts.append(f"{self.resolved.duration_ms}ms") + return render_worklog_entry( + label="Hook", + target=target, + state=state, + detail=" · ".join(detail_parts) if detail_parts else None, + ) + + class _QuestionAnsweredBlock: """Compact transcript row for answers returned from AskUserQuestion.""" diff --git a/src/pythinker_code/ui/shell/visualize/_btw_panel.py b/src/pythinker_code/ui/shell/visualize/_btw_panel.py index 1fbdf2d7..6115a942 100644 --- a/src/pythinker_code/ui/shell/visualize/_btw_panel.py +++ b/src/pythinker_code/ui/shell/visualize/_btw_panel.py @@ -24,6 +24,7 @@ from rich.text import Text from pythinker_code.ui.shell.console import render_to_ansi +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.shell.spacing import DIALOG_PANEL_PADDING, blank_row from pythinker_code.ui.shell.visualize._blocks import Markdown @@ -101,7 +102,11 @@ def set_result(self, response: str | None, error: str | None) -> None: # -- Title --------------------------------------------------------------- def _loading_marker(self) -> Text: - glyph = "●" if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 else " " + glyph = ( + TRANSCRIPT_ACTIVE_MARKER + if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 + else " " + ) return Text(glyph, style=tui_rich_style("muted")) def _build_title(self) -> str: diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index 58856aa8..623a0cf1 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -30,7 +30,7 @@ from pythinker_code.ui.shell.components.render_utils import cell_width, truncate_to_width from pythinker_code.ui.shell.console import console, current_console_width from pythinker_code.ui.shell.echo import render_user_echo -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_TOOL_GUTTER +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER, TRANSCRIPT_TOOL_GUTTER from pythinker_code.ui.shell.keyboard import KeyboardListener, KeyEvent from pythinker_code.ui.shell.mcp_status import render_mcp_startup_text from pythinker_code.ui.shell.motion import ( @@ -50,6 +50,7 @@ Markdown, _CompactionBlock, _ContentBlock, + _HookBlock, _NotificationBlock, _ProgressNoteBlock, _QuestionAnsweredBlock, @@ -75,6 +76,8 @@ CompactionBegin, CompactionEnd, ContentPart, + HookResolved, + HookTriggered, MCPLoadingBegin, MCPLoadingEnd, Notification, @@ -211,6 +214,7 @@ def __init__( self._current_question_panel: QuestionRequestPanel | None = None self._notification_blocks = deque[_NotificationBlock]() self._live_notification_blocks = deque[_NotificationBlock](maxlen=MAX_LIVE_NOTIFICATIONS) + self._hook_blocks: dict[tuple[str, str], _HookBlock] = {} self._status_block = _StatusBlock(initial_status) self._need_recompose = False @@ -517,6 +521,8 @@ def compose_agent_output( # it too, so a still-running agent is separated from a finished # one already committed to scrollback. _append_action_block(blocks, tool_call.compose(), leading=True) + for hook_block in self._hook_blocks.values(): + _append_action_block(blocks, hook_block.compose(), leading=True) if include_working_indicator and self._active_turn_depth > 0: # Keep a stable activity indicator visible even while content or # tool cards are already on-screen. This makes long-running @@ -764,7 +770,7 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: if self._latest_mcp_status is not None: self._mcp_loading_spinner = render_mcp_startup_text(self._latest_mcp_status) else: - line = Text("● ", style=tui_rich_style("muted")) + line = Text(f"{TRANSCRIPT_ACTIVE_MARKER} ", style=tui_rich_style("muted")) line.append("Starting MCP servers", style=tui_rich_style("muted")) self._mcp_loading_spinner = line self.refresh_soon() @@ -775,7 +781,9 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: truncated = (question[:40] + "...") if len(question) > 40 else question self._btw_question = question glyph = ( - "●" if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 else " " + TRANSCRIPT_ACTIVE_MARKER + if reduced_motion_enabled() or int(time.monotonic() / 0.8) % 2 == 0 + else " " ) line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append(f"Side question... {truncated}", style=tui_rich_style("muted")) @@ -823,6 +831,10 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: self.display_question_answered(msg) case ProgressNote(): self.display_progress_note(msg) + case HookTriggered(): + self.append_hook_triggered(msg) + case HookResolved(): + self.append_hook_resolved(msg) case ContentPart(): self.append_content(msg) case ToolCall(): @@ -1004,6 +1016,7 @@ def cleanup(self, is_interrupt: bool) -> None: self._compaction_block = None self._mcp_loading_spinner = None self._btw_spinner = None + self._hook_blocks.clear() self._current_step_retry = None if is_interrupt: @@ -1162,6 +1175,26 @@ def append_notification(self, notification: Notification) -> None: self._live_notification_blocks.append(block) self.refresh_soon() + def append_hook_triggered(self, event: HookTriggered) -> None: + self._hook_blocks[(event.event, event.target)] = _HookBlock(event) + self.refresh_soon() + + def append_hook_resolved(self, event: HookResolved) -> None: + key = (event.event, event.target) + block = self._hook_blocks.pop(key, None) + if block is None: + block = _HookBlock( + HookTriggered( + event=event.event, + target=event.target, + hook_count=1, + ) + ) + block.resolve(event) + console.print() + console.print(block.compose()) + self.refresh_soon() + def display_question_answered(self, event: QuestionAnswered) -> None: self.flush_content() block = _QuestionAnsweredBlock(event) diff --git a/src/pythinker_code/ui/shell/visualize/_worklog.py b/src/pythinker_code/ui/shell/visualize/_worklog.py index 8314e4df..55b0a59f 100644 --- a/src/pythinker_code/ui/shell/visualize/_worklog.py +++ b/src/pythinker_code/ui/shell/visualize/_worklog.py @@ -18,6 +18,7 @@ ) from pythinker_code.ui.shell.components.markdown import PythinkerMarkdown as Markdown from pythinker_code.ui.shell.design_system import ShellTone, StatusName, shell_style, status_icon +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.shell.spacing import WORKLOG_PANEL_PADDING from pythinker_code.ui.theme import get_tui_tokens, tui_rich_style @@ -51,18 +52,26 @@ class ToolStyle: "Glob": ToolStyle("Find", "*", "info"), "Edit": ToolStyle("Edit", "<-", "accent"), "Replace": ToolStyle("Edit", "<-", "accent"), + "StrReplaceFile": ToolStyle("Edit", "<-", "accent"), "Write": ToolStyle("Write", "<-", "accent"), "WriteFile": ToolStyle("Write", "<-", "accent"), "ApplyPatch": ToolStyle("Patch", "◆", "accent"), "Bash": ToolStyle("Shell", "$", "success"), "Shell": ToolStyle("Shell", "$", "success"), + "SetTodoList": ToolStyle("Todo", "☑", "warning"), "TodoWrite": ToolStyle("Todo", "☑", "warning"), - "Agent": ToolStyle("Subagent", "●", "muted"), - "Task": ToolStyle("Subagent", "●", "muted"), + "Agent": ToolStyle("Subagent", TRANSCRIPT_ACTIVE_MARKER, "muted"), + "RunAgents": ToolStyle("Subagents", TRANSCRIPT_ACTIVE_MARKER, "muted"), + "Task": ToolStyle("Subagent", TRANSCRIPT_ACTIVE_MARKER, "muted"), "AskUser": ToolStyle("Ask", "?", "warning"), + "AskUserQuestion": ToolStyle("Ask", "?", "warning"), "FetchURL": ToolStyle("Fetch", "%", "info"), "WebFetch": ToolStyle("Fetch", "%", "info"), "WebSearch": ToolStyle("Search", "◈", "info"), + "SearchWeb": ToolStyle("Search", "◈", "info"), + "TaskList": ToolStyle("Tasks", "☷", "info"), + "TaskOutput": ToolStyle("TaskOutput", "☷", "info"), + "TaskStop": ToolStyle("TaskStop", "■", "warning"), "Skill": ToolStyle("Skill", "◇", "info"), } diff --git a/src/pythinker_code/ui/terminal_capabilities.py b/src/pythinker_code/ui/terminal_capabilities.py new file mode 100644 index 00000000..8169f1ba --- /dev/null +++ b/src/pythinker_code/ui/terminal_capabilities.py @@ -0,0 +1,90 @@ +"""Terminal capability helpers for portable shell UI rendering. + +The shell UI has to work in modern terminals, PowerShell, CI logs, SSH panes, +and deliberately minimal terminals. Keep environment-based decisions here so +color, glyph, and motion fallbacks stay consistent across Rich and +prompt_toolkit renderers. +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Mapping +from typing import TextIO + +_TRUE_VALUES = frozenset({"1", "true", "yes", "on", "always"}) +_FALSE_VALUES = frozenset({"0", "false", "no", "off", "never"}) + + +def _env(environ: Mapping[str, str] | None = None) -> Mapping[str, str]: + return os.environ if environ is None else environ + + +def _clean(value: str | None) -> str: + return "" if value is None else value.strip().lower() + + +def env_flag(name: str, *, environ: Mapping[str, str] | None = None) -> bool: + """Return true when *name* is set to a conventional truthy value.""" + return _clean(_env(environ).get(name)) in _TRUE_VALUES + + +def colors_disabled(environ: Mapping[str, str] | None = None) -> bool: + """Return whether terminal color should be suppressed. + + Follows the widely-used CLI conventions from clig.dev and no-color.org: + ``NO_COLOR`` wins when non-empty, ``TERM=dumb`` means no formatting, and + ``CLICOLOR=0`` disables color for tools that support the BSD convention. + ``PYTHINKER_NO_COLOR`` is the app-specific override for users who want one. + """ + env = _env(environ) + if env.get("NO_COLOR"): + return True + if env_flag("PYTHINKER_NO_COLOR", environ=env): + return True + if _clean(env.get("TERM")) == "dumb": + return True + return _clean(env.get("CLICOLOR")) == "0" + + +def ascii_glyphs_enabled( + environ: Mapping[str, str] | None = None, stdout: TextIO | None = None +) -> bool: + """Return whether the TUI should use ASCII-only glyphs. + + Modern Windows Terminal and PowerShell are Unicode-capable, so Windows alone + is not a reason to degrade. We fall back for explicit user requests, + ``TERM=dumb``, or non-UTF stdout encodings such as legacy Windows code pages. + """ + env = _env(environ) + stdout = sys.stdout if stdout is None else stdout + mode = _clean(env.get("PYTHINKER_TUI_GLYPHS")) + if mode in {"ascii", "safe"}: + return True + if mode in {"unicode", "rich"}: + return False + if env_flag("PYTHINKER_ASCII_UI", environ=env) or env_flag( + "PYTHINKER_SAFE_GLYPHS", environ=env + ): + return True + if _clean(env.get("TERM")) == "dumb": + return True + + encoding = _clean(getattr(stdout, "encoding", None)) + return bool(encoding and "utf" not in encoding and "65001" not in encoding) + + +def motion_disabled(environ: Mapping[str, str] | None = None) -> bool: + """Return whether animated terminal affordances should collapse to static.""" + env = _env(environ) + if _clean(env.get("TERM")) == "dumb": + return True + return any( + env_flag(name, environ=env) + for name in ( + "PYTHINKER_REDUCED_MOTION", + "PYTHINKER_NO_ANIMATION", + "PYTHINKER_STATIC_OUTPUT", + ) + ) diff --git a/src/pythinker_code/ui/theme.py b/src/pythinker_code/ui/theme.py index 9d459894..29256fd2 100644 --- a/src/pythinker_code/ui/theme.py +++ b/src/pythinker_code/ui/theme.py @@ -6,15 +6,46 @@ from __future__ import annotations -from dataclasses import dataclass, fields -from typing import Literal +import re +from dataclasses import dataclass, fields, replace +from typing import Any, Literal, cast from prompt_toolkit.styles import Style as PTKStyle from rich.style import Style as RichStyle +from pythinker_code.ui.terminal_capabilities import colors_disabled + type ThemeName = Literal["dark", "light"] +# Intentionally strips only hex prompt_toolkit color tokens (for example +# ``#RRGGBB``, ``fg:#RRGGBB``, and ``bg:#RRGGBB``). Named/ANSI tokens such as +# ``fg:red`` or ``bg:ansired`` are preserved; if those need no-color support, +# extend this regex and keep ``_strip_ptk_colors`` in sync. +_PTK_COLOR_TOKEN_RE = re.compile(r"^(?:fg:|bg:)?#[0-9A-Fa-f]{6}$") + + +def _strip_ptk_colors(style: str) -> str: + """Remove prompt_toolkit color directives while preserving weight/style.""" + if not style: + return style + return " ".join(part for part in style.split() if not _PTK_COLOR_TOKEN_RE.match(part)) + + +def _strip_ptk_style_map(values: dict[str, str]) -> dict[str, str]: + return {key: _strip_ptk_colors(value) for key, value in values.items()} + + +def _strip_color_dataclass[T](value: T) -> T: + updates: dict[str, Any] = {} + for field in fields(cast(Any, value)): + current = getattr(value, field.name) + if isinstance(current, str): + updates[field.name] = _strip_ptk_colors(current) + result: T = replace(cast(Any, value), **updates) + return result + + # --------------------------------------------------------------------------- # Diff colors (used by utils/rich/diff_render.py) # --------------------------------------------------------------------------- @@ -42,6 +73,13 @@ class DiffColors: del_hl=RichStyle(bgcolor="#ffc1c0"), ) +_DIFF_PLAIN = DiffColors( + add_bg=RichStyle(), + del_bg=RichStyle(), + add_hl=RichStyle(), + del_hl=RichStyle(), +) + # --------------------------------------------------------------------------- # Task browser colors (used by ui/shell/task_browser.py) @@ -49,51 +87,53 @@ class DiffColors: def _task_browser_style_dark() -> PTKStyle: - return PTKStyle.from_dict( - { - "header": "bg:#1f2937 #e5e7eb", - "header.title": "bg:#1f2937 #F4F4F5 bold", - "header.meta": "bg:#1f2937 #A3A3A3", - "status.running": "bg:#1f2937 #7BC97F bold", - "status.success": "bg:#1f2937 #7BC97F", - "status.warning": "bg:#1f2937 #E6B450", - "status.error": "bg:#1f2937 #EF5E62", - "status.info": "bg:#1f2937 #AFE3F1", - "task-list": "bg:#111827 #d1d5db", - "task-list.checked": "bg:#164e63 #ecfeff bold", - "frame.border": "#3A506D", - "frame.label": "bg:#17182a #F4F4F5 bold", - "footer": "bg:#17182a #A3A3A3", - "footer.key": "bg:#17182a #AFE3F1 bold", - "footer.text": "bg:#17182a #A3A3A3", - "footer.warning": "bg:#4a3315 #E6B450 bold", - "footer.meta": "bg:#17182a #5F6B7E", - } - ) + styles = { + "header": "bg:#1f2937 #e5e7eb", + "header.title": "bg:#1f2937 #F4F4F5 bold", + "header.meta": "bg:#1f2937 #A3A3A3", + "status.running": "bg:#1f2937 #7BC97F bold", + "status.success": "bg:#1f2937 #7BC97F", + "status.warning": "bg:#1f2937 #E6B450", + "status.error": "bg:#1f2937 #EF5E62", + "status.info": "bg:#1f2937 #AFE3F1", + "task-list": "bg:#111827 #d1d5db", + "task-list.checked": "bg:#164e63 #ecfeff bold", + "frame.border": "#3A506D", + "frame.label": "bg:#17182a #F4F4F5 bold", + "footer": "bg:#17182a #A3A3A3", + "footer.key": "bg:#17182a #AFE3F1 bold", + "footer.text": "bg:#17182a #A3A3A3", + "footer.warning": "bg:#4a3315 #E6B450 bold", + "footer.meta": "bg:#17182a #5F6B7E", + } + if colors_disabled(): + styles = _strip_ptk_style_map(styles) + return PTKStyle.from_dict(styles) def _task_browser_style_light() -> PTKStyle: - return PTKStyle.from_dict( - { - "header": "bg:#e5e7eb #1f2937", - "header.title": "bg:#e5e7eb #213853 bold", - "header.meta": "bg:#e5e7eb #666666", - "status.running": "bg:#e5e7eb #2C7A39 bold", - "status.success": "bg:#e5e7eb #2C7A39", - "status.warning": "bg:#e5e7eb #9A6B18", - "status.error": "bg:#e5e7eb #C0392B", - "status.info": "bg:#e5e7eb #176B7E", - "task-list": "bg:#f9fafb #374151", - "task-list.checked": "bg:#cffafe #164e63 bold", - "frame.border": "#495F7C", - "frame.label": "bg:#f1f5f9 #213853 bold", - "footer": "bg:#f1f5f9 #475569", - "footer.key": "bg:#f1f5f9 #176B7E bold", - "footer.text": "bg:#f1f5f9 #475569", - "footer.warning": "bg:#fee2e2 #C0392B bold", - "footer.meta": "bg:#f1f5f9 #64748b", - } - ) + styles = { + "header": "bg:#e5e7eb #1f2937", + "header.title": "bg:#e5e7eb #213853 bold", + "header.meta": "bg:#e5e7eb #666666", + "status.running": "bg:#e5e7eb #2C7A39 bold", + "status.success": "bg:#e5e7eb #2C7A39", + "status.warning": "bg:#e5e7eb #9A6B18", + "status.error": "bg:#e5e7eb #C0392B", + "status.info": "bg:#e5e7eb #176B7E", + "task-list": "bg:#f9fafb #374151", + "task-list.checked": "bg:#cffafe #164e63 bold", + "frame.border": "#495F7C", + "frame.label": "bg:#f1f5f9 #213853 bold", + "footer": "bg:#f1f5f9 #475569", + "footer.key": "bg:#f1f5f9 #176B7E bold", + "footer.text": "bg:#f1f5f9 #475569", + "footer.warning": "bg:#fee2e2 #C0392B bold", + "footer.meta": "bg:#f1f5f9 #64748b", + } + if colors_disabled(): + styles = _strip_ptk_style_map(styles) + return PTKStyle.from_dict(styles) # --------------------------------------------------------------------------- @@ -262,8 +302,11 @@ def markdown_rich_style(token: str, *, theme: ThemeName | None = None) -> RichSt """Resolve a MarkdownColors field name to a Rich Style. Background tokens (suffix ``_bg``) produce a style with ``bgcolor``; - everything else produces a style with ``color``. + everything else produces a style with ``color``. Color is suppressed when + the terminal environment requests plain output. """ + if colors_disabled(): + return RichStyle() colors = get_markdown_colors(theme) value = getattr(colors, token) if not value: @@ -324,6 +367,8 @@ def get_active_theme() -> ThemeName: def get_diff_colors() -> DiffColors: + if colors_disabled(): + return _DIFF_PLAIN return _DIFF_LIGHT if _active_theme == "light" else _DIFF_DARK @@ -333,15 +378,19 @@ def get_task_browser_style() -> PTKStyle: def get_prompt_style() -> PTKStyle: d = _PROMPT_STYLE_LIGHT if _active_theme == "light" else _PROMPT_STYLE_DARK + if colors_disabled(): + d = _strip_ptk_style_map(d) return PTKStyle.from_dict(d) def get_toolbar_colors() -> ToolbarColors: - return _TOOLBAR_LIGHT if _active_theme == "light" else _TOOLBAR_DARK + colors = _TOOLBAR_LIGHT if _active_theme == "light" else _TOOLBAR_DARK + return _strip_color_dataclass(colors) if colors_disabled() else colors def get_mcp_prompt_colors() -> MCPPromptColors: - return _MCP_PROMPT_LIGHT if _active_theme == "light" else _MCP_PROMPT_DARK + colors = _MCP_PROMPT_LIGHT if _active_theme == "light" else _MCP_PROMPT_DARK + return _strip_color_dataclass(colors) if colors_disabled() else colors # --------------------------------------------------------------------------- @@ -483,6 +532,7 @@ def tui_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: Background tokens (suffix ``_bg``) produce a style with ``bgcolor``; everything else produces a style with ``color``. Empty hex values (``""``) yield an empty style — Rich falls back to terminal defaults. + Color is suppressed when the terminal environment requests plain output. Raises: ValueError: If *token* is not a known TuiTokens field. @@ -490,6 +540,8 @@ def tui_rich_style(token: str, *, theme: ThemeName | None = None) -> RichStyle: if token not in TUI_TOKEN_NAMES: known = ", ".join(sorted(TUI_TOKEN_NAMES)) raise ValueError(f"Unknown TUI token {token!r}. Known tokens: {known}") + if colors_disabled(): + return RichStyle() tokens = get_tui_tokens(theme) value = getattr(tokens, token) if not value: diff --git a/tests/auth/test_openai_auth.py b/tests/auth/test_openai_auth.py index 467d8005..393b5fd0 100644 --- a/tests/auth/test_openai_auth.py +++ b/tests/auth/test_openai_auth.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import base64 +import json from urllib.parse import parse_qs, urlsplit import aiohttp @@ -18,6 +20,7 @@ OPENAI_BROWSER_PORT, OPENAI_BROWSER_REDIRECT_PATH, OPENAI_CHATGPT_BASE_URL, + OPENAI_CHATGPT_MODELS_URL, OPENAI_CHATGPT_OAUTH_KEY, OPENAI_CLIENT_ID, OPENAI_DEVICE_REDIRECT_URI, @@ -25,10 +28,12 @@ PkceCodes, _apply_openai_config, _build_authorize_url, - _discover_chatgpt_models, + _callback_html, _exchange_id_token_for_api_key, _select_default_openai_model, + _token_from_openai_response, _wait_for_browser_code, + discover_chatgpt_models, login_openai_api_key, login_openai_browser, ) @@ -52,6 +57,20 @@ def _model(model_id: str, *, reasoning: bool = False, image: bool = False) -> Mo ) +def _jwt_with_chatgpt_account(account_id: str) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"none"}').decode("utf-8").rstrip("=") + payload = ( + base64.urlsafe_b64encode( + json.dumps({"https://api.openai.com/auth": {"chatgpt_account_id": account_id}}).encode( + "utf-8" + ) + ) + .decode("utf-8") + .rstrip("=") + ) + return f"{header}.{payload}.signature" + + def _request_info(url: str) -> aiohttp.RequestInfo: return aiohttp.RequestInfo( url=URL(url), @@ -72,6 +91,23 @@ def test_openai_auth_constants_match_codex_compatible_values(): assert OPENAI_CHATGPT_OAUTH_KEY == "oauth/openai-chatgpt" +def test_openai_callback_html_uses_pythinker_branding(): + page = _callback_html(ok=True, message=None) + + assert "Pythinker logged in" in page + assert "You're logged in to Pythinker" in page + assert "OpenAI login complete" not in page + assert "data:image/svg+xml" in page + + +def test_openai_callback_html_escapes_error_message(): + page = _callback_html(ok=False, message='') + + assert "Pythinker login failed" in page + assert "<script>alert("x")</script>" in page + assert '' not in page + + def test_build_authorize_url_uses_codex_parameters(): url = _build_authorize_url( redirect_uri="http://localhost:1455/auth/callback", @@ -130,6 +166,20 @@ def post(self, url, *, data): assert "requested_token_type" not in captured["data"] +def test_token_from_openai_response_extracts_chatgpt_account_id_from_access_token(): + token = _token_from_openai_response( + { + "access_token": _jwt_with_chatgpt_account("acc_access"), + "refresh_token": "refresh-token", + "expires_in": 3600, + "token_type": "Bearer", + "scope": "openid profile email offline_access", + } + ) + + assert token.account_id == "acc_access" + + @pytest.mark.asyncio async def test_wait_for_browser_code_accepts_localhost_callback(monkeypatch): monkeypatch.setattr( @@ -349,38 +399,148 @@ async def fake_list_models(platform, api_key): @pytest.mark.asyncio async def test_discover_chatgpt_models_reraises_401(monkeypatch): - async def fake_list_models(platform, api_key): - assert platform.id == OPENAI_CHATGPT_PLATFORM_ID - assert api_key == "bad-token" - request_info = _request_info("https://chatgpt.com/backend-api/codex/models") - raise aiohttp.ClientResponseError(request_info, (), status=401, message="Unauthorized") + class FakeSession: + async def __aenter__(self): + return self - monkeypatch.setattr("pythinker_code.auth.openai.list_models", fake_list_models) + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, url, *, headers, raise_for_status): + assert url == OPENAI_CHATGPT_MODELS_URL + assert headers["Authorization"] == "Bearer bad-token" + request_info = _request_info(url) + raise aiohttp.ClientResponseError(request_info, (), status=401, message="Unauthorized") + + monkeypatch.setattr("pythinker_code.auth.openai.new_client_session", FakeSession) with pytest.raises(aiohttp.ClientResponseError) as exc_info: - await _discover_chatgpt_models("bad-token") + await discover_chatgpt_models("bad-token") assert exc_info.value.status == 401 @pytest.mark.asyncio -async def test_discover_chatgpt_models_fallback_uses_current_supported_models(monkeypatch): - async def fake_list_models(platform, api_key): - raise RuntimeError("models endpoint unavailable") +async def test_discover_chatgpt_models_uses_account_scoped_codex_catalog(monkeypatch): + captured = {} + payload = { + "models": [ + { + "slug": "gpt-5.3-codex", + "display_name": "GPT-5.3 Codex", + "context_window": 272_000, + "priority": 20, + "supported_in_api": False, + }, + { + "slug": "hidden-model", + "visibility": "hidden", + "context_window": 272_000, + "priority": 1, + }, + { + "slug": "gpt-5.3-codex-spark", + "name": "GPT-5.3 Codex Spark", + "context_window": 128_000, + "priority": 10, + "supported_in_api": False, + }, + ] + } - monkeypatch.setattr("pythinker_code.auth.openai.list_models", fake_list_models) + class FakeResponse: + async def __aenter__(self): + return self - models = await _discover_chatgpt_models("access-token") + async def __aexit__(self, exc_type, exc, tb): + return None - model_ids = [model.id for model in models] - assert model_ids[:6] == [ - "gpt-5.5", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2", - ] - assert "gpt-5.1-codex" not in model_ids + async def json(self, content_type=None): + return payload + + class FakeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, url, *, headers, raise_for_status): + captured["url"] = url + captured["headers"] = headers + captured["raise_for_status"] = raise_for_status + return FakeResponse() + + monkeypatch.setattr("pythinker_code.auth.openai.new_client_session", FakeSession) + + models = await discover_chatgpt_models("access-token", account_id="acc_123") + + assert captured["url"] == OPENAI_CHATGPT_MODELS_URL + assert captured["headers"]["Authorization"] == "Bearer access-token" + assert captured["headers"]["ChatGPT-Account-ID"] == "acc_123" + assert captured["headers"]["originator"] == "codex_cli_rs" + assert captured["raise_for_status"] is True + assert [model.id for model in models] == ["gpt-5.3-codex-spark", "gpt-5.3-codex"] + assert models[0].context_length == 128_000 + assert models[0].supports_image_in is False + assert models[1].display_name == "GPT-5.3 Codex" + + +@pytest.mark.asyncio +async def test_discover_chatgpt_models_uses_custom_base_url(monkeypatch): + captured = {} + payload = {"models": [{"slug": "gpt-5.3-codex", "supported_in_api": False}]} + + class FakeResponse: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def json(self, content_type=None): + return payload + + class FakeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, url, *, headers, raise_for_status): + captured["url"] = url + return FakeResponse() + + monkeypatch.setattr("pythinker_code.auth.openai.new_client_session", FakeSession) + + models = await discover_chatgpt_models( + "access-token", + base_url="https://proxy.example/backend-api/codex/", + ) + + assert ( + captured["url"] + == "https://proxy.example/backend-api/codex/models?client_version=1.0.0" + ) + assert [model.id for model in models] == ["gpt-5.3-codex"] + + +@pytest.mark.asyncio +async def test_discover_chatgpt_models_does_not_fallback_when_catalog_unavailable(monkeypatch): + class FakeSession: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + def get(self, *args, **kwargs): + raise RuntimeError("models endpoint unavailable") + + monkeypatch.setattr("pythinker_code.auth.openai.new_client_session", FakeSession) + + with pytest.raises(RuntimeError, match="models endpoint unavailable"): + await discover_chatgpt_models("access-token") @pytest.mark.asyncio @@ -409,16 +569,17 @@ async def fake_list_models(platform, api_key): @pytest.mark.asyncio -async def test_refresh_managed_models_replaces_stale_chatgpt_codex_model_with_fallback( +async def test_refresh_managed_models_replaces_stale_chatgpt_codex_model_with_live_catalog( monkeypatch, tmp_path ): monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) provider_key = managed_provider_key(OPENAI_CHATGPT_PLATFORM_ID) stale_model_key = managed_model_key(OPENAI_CHATGPT_PLATFORM_ID, "gpt-5.1-codex") config = Config(is_from_default_location=True) + chatgpt_base_url = "https://proxy.example/backend-api/codex" config.providers[provider_key] = LLMProvider( type="openai_codex", - base_url=OPENAI_CHATGPT_BASE_URL, + base_url=chatgpt_base_url, api_key=SecretStr("access-token"), oauth=OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY), ) @@ -430,17 +591,22 @@ async def test_refresh_managed_models_replaces_stale_chatgpt_codex_model_with_fa ) config.default_model = stale_model_key - async def fake_list_models(platform, api_key): - raise RuntimeError("models endpoint unavailable") + async def fake_discover_chatgpt_models(api_key, *, account_id=None, base_url=None): + assert api_key == "access-token" + assert account_id is None + assert base_url == chatgpt_base_url + return [_model("gpt-5.5", reasoning=True)] - monkeypatch.setattr("pythinker_code.auth.platforms.list_models", fake_list_models) + monkeypatch.setattr( + "pythinker_code.auth.openai.discover_chatgpt_models", fake_discover_chatgpt_models + ) changed = await refresh_managed_models(config) assert changed is True assert config.default_model == managed_model_key(OPENAI_CHATGPT_PLATFORM_ID, "gpt-5.5") assert stale_model_key not in config.models - assert managed_model_key(OPENAI_CHATGPT_PLATFORM_ID, "gpt-5.3-codex") in config.models + assert managed_model_key(OPENAI_CHATGPT_PLATFORM_ID, "gpt-5.5") in config.models @pytest.mark.asyncio @@ -475,8 +641,9 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri): async def fake_exchange_id_token_for_api_key(id_token): return "" - async def fake_discover_chatgpt_models(api_key): + async def fake_discover_chatgpt_models(api_key, *, account_id=None): assert api_key == "access-token" + assert account_id is None return [_model("gpt-5.1-codex", reasoning=True)] monkeypatch.setattr("pythinker_code.auth.openai._request_device_code", fake_request_device_code) @@ -489,7 +656,7 @@ async def fake_discover_chatgpt_models(api_key): fake_exchange_id_token_for_api_key, ) monkeypatch.setattr( - "pythinker_code.auth.openai._discover_chatgpt_models", + "pythinker_code.auth.openai.discover_chatgpt_models", fake_discover_chatgpt_models, ) @@ -528,7 +695,8 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri): "scope": "openid profile email offline_access", } - async def fake_discover_chatgpt_models(api_key): + async def fake_discover_chatgpt_models(api_key, *, account_id=None): + assert account_id is None return [_model("gpt-5.1-codex", reasoning=True)] monkeypatch.setattr( @@ -538,7 +706,7 @@ async def fake_discover_chatgpt_models(api_key): "pythinker_code.auth.openai._exchange_code_for_tokens", fake_exchange_code_for_tokens ) monkeypatch.setattr( - "pythinker_code.auth.openai._discover_chatgpt_models", fake_discover_chatgpt_models + "pythinker_code.auth.openai.discover_chatgpt_models", fake_discover_chatgpt_models ) events = [event async for event in login_openai_browser(config, open_browser=False)] diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 1aeb7c84..c37b8c65 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -96,6 +96,14 @@ def test_load_config_text_json(): assert config == get_default_config() +def test_load_config_migrates_legacy_feedback_repo_default(): + config = load_config_from_string( + '[feedback]\ngithub_repo = "mohamed-elkholy95/Pythinker-Code"\n' + ) + + assert config.feedback.github_repo == "TechMatrix-labs/pythinker-code" + + def test_agent_execution_profile_autonomous_sets_autonomy_defaults(): config = load_config_from_string('agent_execution_profile = "autonomous_coding"') diff --git a/tests/core/test_openai_provider.py b/tests/core/test_openai_provider.py index 275d5853..85738f9e 100644 --- a/tests/core/test_openai_provider.py +++ b/tests/core/test_openai_provider.py @@ -54,6 +54,19 @@ def with_thinking(self, effort): "pythinker_core.contrib.chat_provider.openai_responses.OpenAIResponses", FakeOpenAIResponses ) config = _openai_chatgpt_config() + ref = OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY) + save_tokens( + ref, + OAuthToken( + access_token="access-token", + refresh_token="refresh-token", + expires_at=time.time() + 3600, + scope="openid", + token_type="Bearer", + expires_in=3600, + account_id="acc_test", + ), + ) provider = next(iter(config.providers.values())) model = next(iter(config.models.values())) @@ -62,8 +75,10 @@ def with_thinking(self, effort): assert llm is not None assert captured["model"] == "gpt-5.1-codex" assert captured["base_url"] == "https://chatgpt.com/backend-api/codex" - assert captured["api_key"] == "" + assert captured["api_key"] == "access-token" assert captured["system_prompt_as_instructions"] is True + assert captured["default_headers"]["ChatGPT-Account-ID"] == "acc_test" + assert captured["default_headers"]["originator"] == "codex_cli_rs" assert captured["thinking"] == "high" diff --git a/tests/core/test_shell_mcp_status.py b/tests/core/test_shell_mcp_status.py index 4273cba9..84fecac4 100644 --- a/tests/core/test_shell_mcp_status.py +++ b/tests/core/test_shell_mcp_status.py @@ -42,12 +42,27 @@ def test_render_mcp_servers_shows_pythinker_x_loading_design() -> None: prompt_text = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.0)) prompt_text_off = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.9)) assert prompt_text != prompt_text_off - assert prompt_text.startswith("● ") + assert prompt_text.startswith("• ") assert "Starting MCP servers (0/2): chrome-devtools, context7" in prompt_text assert "resolve-library-id" not in prompt_text startup_text = render_mcp_startup_text(snapshot, now=0.0).plain - assert startup_text == "● Starting MCP servers (0/2): chrome-devtools, context7" + assert startup_text == "• Starting MCP servers (0/2): chrome-devtools, context7" + + +def test_render_mcp_prompt_respects_reduced_motion(monkeypatch) -> None: + snapshot = MCPStatusSnapshot( + loading=True, + connected=0, + total=1, + tools=0, + servers=(MCPServerSnapshot(name="slow-test", status="pending", tools=()),), + ) + monkeypatch.setattr("pythinker_code.ui.shell.mcp_status.reduced_motion_enabled", lambda: True) + + prompt_text = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.9)) + + assert prompt_text.startswith("• ") def test_render_mcp_startup_header_for_single_server() -> None: diff --git a/tests/ui_and_conv/test_activity_tree.py b/tests/ui_and_conv/test_activity_tree.py index 15f7ccae..ec1b7e5d 100644 --- a/tests/ui_and_conv/test_activity_tree.py +++ b/tests/ui_and_conv/test_activity_tree.py @@ -2,8 +2,11 @@ from rich.console import Console +from pythinker_code.ui.shell.design_system import status_icon from pythinker_code.ui.shell.visualize._activity_tree import ActivityRow, render_activity_tree +_RUNNING_GLYPH = status_icon("running").plain + def _plain(renderable, *, width: int = 80) -> str: console = Console(record=True, width=width, color_system=None) @@ -35,3 +38,35 @@ def test_activity_tree_truncates_long_detail(): width=40, ) assert all(len(row) <= 41 for row in output.splitlines() if row) + + +def test_running_row_marker_pulses_off_phase(monkeypatch): + for flag in ("PYTHINKER_REDUCED_MOTION", "PYTHINKER_NO_ANIMATION", "PYTHINKER_STATIC_OUTPUT"): + monkeypatch.delenv(flag, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + rows = [ActivityRow(label="explore", detail="Read _live_view.py", state="running")] + on_phase = _plain(render_activity_tree(rows, width=80, now=0.0)) + off_phase = _plain(render_activity_tree(rows, width=80, now=0.8)) + assert _RUNNING_GLYPH in on_phase + assert _RUNNING_GLYPH not in off_phase + + +def test_running_row_marker_static_under_reduced_motion(monkeypatch): + monkeypatch.setenv("PYTHINKER_REDUCED_MOTION", "1") + rows = [ActivityRow(label="explore", detail="Read _live_view.py", state="running")] + on_phase = _plain(render_activity_tree(rows, width=80, now=0.0)) + off_phase = _plain(render_activity_tree(rows, width=80, now=0.8)) + assert _RUNNING_GLYPH in on_phase + assert _RUNNING_GLYPH in off_phase + + +def test_non_running_row_marker_does_not_pulse(monkeypatch): + for flag in ("PYTHINKER_REDUCED_MOTION", "PYTHINKER_NO_ANIMATION", "PYTHINKER_STATIC_OUTPUT"): + monkeypatch.delenv(flag, raising=False) + monkeypatch.setenv("TERM", "xterm-256color") + completed_glyph = status_icon("completed").plain + rows = [ActivityRow(label="review", detail="Finished audit", state="completed")] + on_phase = _plain(render_activity_tree(rows, width=80, now=0.0)) + off_phase = _plain(render_activity_tree(rows, width=80, now=0.8)) + assert completed_glyph in on_phase + assert completed_glyph in off_phase diff --git a/tests/ui_and_conv/test_live_view_notifications.py b/tests/ui_and_conv/test_live_view_notifications.py index 75a3347e..1339114f 100644 --- a/tests/ui_and_conv/test_live_view_notifications.py +++ b/tests/ui_and_conv/test_live_view_notifications.py @@ -9,7 +9,13 @@ from pythinker_code.ui.shell.keyboard import KeyEvent from pythinker_code.ui.shell.visualize import _live_view as live_view_module from pythinker_code.ui.shell.visualize import _LiveView, _PromptLiveView -from pythinker_code.wire.types import Notification, StatusUpdate, TurnBegin +from pythinker_code.wire.types import ( + HookResolved, + HookTriggered, + Notification, + StatusUpdate, + TurnBegin, +) def _render(renderable) -> str: @@ -45,6 +51,42 @@ def test_live_view_renders_notification_block(): assert "..." in rendered +def test_live_view_renders_running_hook_block(): + view = _LiveView(StatusUpdate()) + + view.dispatch_wire_message(HookTriggered(event="PreToolUse", target="Shell", hook_count=1)) + + rendered = _render(view.compose_agent_output(include_working_indicator=False)) + assert "Hook" in rendered + assert "PreToolUse Shell" in rendered + assert "running" in rendered.lower() + + +def test_live_view_prints_resolved_blocking_hook(monkeypatch): + view = _LiveView(StatusUpdate()) + view.dispatch_wire_message(HookTriggered(event="PreToolUse", target="Shell", hook_count=1)) + printed = [] + monkeypatch.setattr(shell_console, "print", lambda *args, **kwargs: printed.extend(args)) + + view.dispatch_wire_message( + HookResolved( + event="PreToolUse", + target="Shell", + action="block", + reason="blocked by policy", + duration_ms=12, + ) + ) + + assert view._hook_blocks == {} + rendered = "\n".join(_render(item) for item in printed) + assert "Hook" in rendered + assert "PreToolUse Shell" in rendered + assert "failed" in rendered.lower() + assert "blocked by policy" in rendered + assert "12ms" in rendered + + def test_working_indicator_uses_turn_elapsed_time(monkeypatch): now = 1000.0 monkeypatch.setattr(live_view_module.time, "monotonic", lambda: now) diff --git a/tests/ui_and_conv/test_native_update_parity.py b/tests/ui_and_conv/test_native_update_parity.py index 2382fafb..a924abe2 100644 --- a/tests/ui_and_conv/test_native_update_parity.py +++ b/tests/ui_and_conv/test_native_update_parity.py @@ -203,8 +203,8 @@ async def test_run_update_prompt_reports_up_to_date(monkeypatch): monkeypatch.setattr(constant, "VERSION", "2.0.0") calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool): - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool): + calls.append((print_output, check_only)) return update.UpdateResult.UP_TO_DATE monkeypatch.setattr(update, "do_update", fake_do_update) @@ -216,8 +216,8 @@ async def fake_do_update(*, print: bool, check_only: bool): async def test_run_update_prompt_skip_returns_none(monkeypatch): monkeypatch.setattr(constant, "VERSION", "1.0.0") - async def fake_do_update(*, print: bool, check_only: bool): - assert (print, check_only) == (True, True) + async def fake_do_update(*, print_output: bool, check_only: bool): + assert (print_output, check_only) == (True, True) return update.UpdateResult.UPDATE_AVAILABLE async def fake_prompt(current, latest): diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index 50eea03d..c82db142 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -400,7 +400,7 @@ def test_background_task_summary_uses_single_task_command() -> None: def test_bottom_toolbar_does_not_render_mcp_startup_status(monkeypatch: Any) -> None: prompt_session = _make_toolbar_session(tips=[]) prompt_session._status_block_provider = lambda _columns: [ - ("", "● Booting MCP server: context7") + ("", "• Booting MCP server: context7") ] fragments = _render_toolbar_fragments(prompt_session, 80, monkeypatch) diff --git a/tests/ui_and_conv/test_render_matrix.py b/tests/ui_and_conv/test_render_matrix.py index d586f6bd..31509277 100644 --- a/tests/ui_and_conv/test_render_matrix.py +++ b/tests/ui_and_conv/test_render_matrix.py @@ -69,7 +69,7 @@ def test_markdown_code_fences_use_aligned_panel_frame(width: int) -> None: def test_activity_line_reduced_motion_uses_static_glyph(width: int) -> None: snap = ActivitySnapshot(label="Working", elapsed_s=3.0, reduced_motion=True) out = _render(activity_status_line(snap, width=width), width=width, no_color=True) - assert "●" in out # static dot, not an animated braille frame + assert "●" in out # static text bullet, not an animated braille frame assert "Working" in out diff --git a/tests/ui_and_conv/test_shell_design_system.py b/tests/ui_and_conv/test_shell_design_system.py index 5dbd6d48..7b8ef35a 100644 --- a/tests/ui_and_conv/test_shell_design_system.py +++ b/tests/ui_and_conv/test_shell_design_system.py @@ -33,7 +33,7 @@ def _color_hex(style: Style) -> str: def test_status_icon_names_are_stable(): assert status_icon("running").plain == "●" assert status_icon("completed").plain == "✓" - assert status_icon("failed").plain == "!" + assert status_icon("failed").plain == "✘" assert status_icon("denied").plain == "×" assert status_icon("interrupted").plain == "■" assert status_icon("waiting").plain == "○" @@ -41,6 +41,12 @@ def test_status_icon_names_are_stable(): assert status_icon("approval").plain == "?" +def test_running_and_failed_status_icons_use_expected_tones(): + assert status_icon("running").style == shell_style(ShellTone.MUTED) + assert status_icon("completed").style == shell_style(ShellTone.SUCCESS) + assert status_icon("failed").style == shell_style(ShellTone.ERROR) + + def test_keyboard_hint_uses_key_and_label(): output = _plain(keyboard_hint("esc", "interrupt")) assert "esc" in output diff --git a/tests/ui_and_conv/test_shell_feedback_slash.py b/tests/ui_and_conv/test_shell_feedback_slash.py index 681502c5..be3e1136 100644 --- a/tests/ui_and_conv/test_shell_feedback_slash.py +++ b/tests/ui_and_conv/test_shell_feedback_slash.py @@ -91,6 +91,50 @@ async def test_invalid_args_without_soul_still_offer_github_fallback(self, monke assert "Opening GitHub feedback" in output +class TestFeedbackDestination: + def test_builtin_endpoint_without_pythinker_auth_falls_back(self, monkeypatch) -> None: + from pythinker_code.config import Config + + monkeypatch.delenv("PYTHINKER_FEEDBACK_URL", raising=False) + soul = Mock() + soul.runtime.config = Config() + + assert shell_slash._feedback_destination(soul) is None + + def test_configured_endpoint_is_used_without_pythinker_auth(self, monkeypatch) -> None: + from pythinker_code.config import Config + + monkeypatch.delenv("PYTHINKER_FEEDBACK_URL", raising=False) + soul = Mock() + soul.runtime.config = Config() + soul.runtime.config.feedback.endpoint_url = "https://feedback.example/submit" + + assert shell_slash._feedback_destination(soul) == ("https://feedback.example/submit", {}) + + def test_builtin_endpoint_uses_pythinker_auth(self, monkeypatch) -> None: + from pydantic import SecretStr + + from pythinker_code.auth import PYTHINKER_CODE_PLATFORM_ID + from pythinker_code.auth.platforms import managed_provider_key + from pythinker_code.config import Config, LLMProvider + + monkeypatch.delenv("PYTHINKER_FEEDBACK_URL", raising=False) + soul = Mock() + soul.runtime.config = Config() + soul.runtime.config.providers[managed_provider_key(PYTHINKER_CODE_PLATFORM_ID)] = ( + LLMProvider( + type="pythinker", + base_url="https://api.pythinker.com/coding/v1", + api_key=SecretStr("token"), + ) + ) + + feedback_url, headers = shell_slash._feedback_destination(soul) or ("", {}) + + assert feedback_url.endswith("/feedback") + assert headers["Authorization"] == "Bearer token" + + class TestFeedbackSubmission: async def test_submits_structured_payload(self, tmp_path: Path, monkeypatch) -> None: from pythinker_code.soul.pythinkersoul import PythinkerSoul @@ -252,6 +296,14 @@ def post(self, *_args: object, **_kwargs: object) -> FakeResponse: assert submission.number is None assert submission.html_url is None + def test_feedback_issue_url_migrates_legacy_default_repo(self) -> None: + payload = {"type": "other", "content": "hi"} + + url = build_feedback_issue_url(payload, "mohamed-elkholy95/Pythinker-Code") + + assert "github.com/TechMatrix-labs/pythinker-code/issues/new" in url + assert "mohamed-elkholy95" not in url + def test_feedback_issue_url_uses_compact_body_for_large_payload(self) -> None: payload = { "type": "bug", @@ -309,6 +361,20 @@ def test_redacts_common_secrets_and_home_path(self) -> None: assert str(Path.home()) not in redacted assert " None: + text = ( + "PIP_INDEX_URL=https://user:pass@example.test/simple " + "HTTPS_PROXY=http://proxy-user:proxy-pass@proxy.test:8080 " + "Authorization: Basic dXNlcjpwYXNzd29yZA==" + ) + + redacted = redact_text(text) + + assert "user:pass" not in redacted + assert "proxy-user:proxy-pass" not in redacted + assert "dXNlcjpwYXNzd29yZA" not in redacted + assert redacted.count("= 3 + def test_redacts_common_bare_token_formats(self) -> None: text = " ".join( [ diff --git a/tests/ui_and_conv/test_shell_motion.py b/tests/ui_and_conv/test_shell_motion.py index 846cbbf5..18cddf4a 100644 --- a/tests/ui_and_conv/test_shell_motion.py +++ b/tests/ui_and_conv/test_shell_motion.py @@ -63,6 +63,14 @@ def test_spinner_frame_changes_with_time(): assert spinner_frame_at(0.0) != spinner_frame_at(0.2) +def test_active_glyphs_use_text_safe_solid_circle(): + from pythinker_code.ui.shell.glyphs import SHAPE_FRAMES, TRANSCRIPT_ASSISTANT_MARKER + + active_glyphs = {TRANSCRIPT_ASSISTANT_MARKER, SHAPE_FRAMES[0], REDUCED_MOTION_GLYPH} + assert active_glyphs == {"●"} + assert "⏺" not in active_glyphs + + def test_reduced_motion_uses_static_glyph(): assert spinner_frame_at(0.2, reduced_motion=True) == "●" diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index c016ac84..bae986e7 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -24,8 +24,8 @@ async def fake_prompt( assert allow_exit is True return update.UpdatePromptSelection.UPDATE_NOW - async def fake_do_update(*, print: bool) -> update.UpdateResult: - assert print is True + async def fake_do_update(*, print_output: bool) -> update.UpdateResult: + assert print_output is True calls.append("update") return update.UpdateResult.UPDATED @@ -113,7 +113,7 @@ async def fake_prompt( assert allow_exit is True return update.UpdatePromptSelection.EXIT - async def fail_do_update(*, print: bool) -> update.UpdateResult: + async def fail_do_update(*, print_output: bool) -> update.UpdateResult: raise AssertionError("exit must not run the update") monkeypatch.setattr(update.sys, "stdout", SimpleNamespace(isatty=lambda: True)) @@ -139,7 +139,7 @@ async def fake_prompt( assert allow_exit is True return update.UpdatePromptSelection.SKIP - async def fail_do_update(*, print: bool) -> update.UpdateResult: + async def fail_do_update(*, print_output: bool) -> update.UpdateResult: raise AssertionError("declining must not run the update") monkeypatch.setattr(update.sys, "stdout", SimpleNamespace(isatty=lambda: True)) @@ -165,7 +165,7 @@ async def fake_prompt( assert allow_exit is True return update.UpdatePromptSelection.DISMISS_VERSION - async def fail_do_update(*, print: bool) -> update.UpdateResult: + async def fail_do_update(*, print_output: bool) -> update.UpdateResult: raise AssertionError("dismissing must not run the update") monkeypatch.setattr(update.sys, "stdout", SimpleNamespace(isatty=lambda: True)) @@ -268,8 +268,8 @@ async def test_resolve_latest_version_fetches_when_due(monkeypatch, tmp_path): last_check_file = tmp_path / "last_update_check.txt" calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: + calls.append((print_output, check_only)) latest_file.write_text("2.0.0", encoding="utf-8") return update.UpdateResult.UPDATE_AVAILABLE @@ -291,8 +291,8 @@ async def test_resolve_latest_version_fetches_when_cache_missing(monkeypatch, tm last_check_file = tmp_path / "last_update_check.txt" calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: + calls.append((print_output, check_only)) latest_file.write_text("2.0.0", encoding="utf-8") return update.UpdateResult.UPDATE_AVAILABLE @@ -311,7 +311,7 @@ async def test_resolve_latest_version_uses_cache_when_not_due(monkeypatch, tmp_p latest_file = tmp_path / "latest.txt" latest_file.write_text("3.1.0", encoding="utf-8") - async def fail_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: + async def fail_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: raise AssertionError("must not hit the network when the throttle is not due") monkeypatch.setattr(update, "LATEST_VERSION_FILE", latest_file) @@ -328,8 +328,8 @@ async def test_resolve_latest_version_revalidates_stale_cache_when_not_due(monke last_check_file = tmp_path / "last_update_check.txt" calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: + calls.append((print_output, check_only)) latest_file.write_text("999.0.0", encoding="utf-8") return update.UpdateResult.UPDATE_AVAILABLE @@ -378,7 +378,7 @@ async def test_refresh_cache_does_not_throttle_on_failure(monkeypatch, tmp_path) latest_file = tmp_path / "latest.txt" last_check_file = tmp_path / "last_update_check.txt" - async def failing_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: + async def failing_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: return update.UpdateResult.FAILED monkeypatch.setattr(update, "LATEST_VERSION_FILE", latest_file) @@ -398,7 +398,7 @@ async def failing_do_update(*, print: bool, check_only: bool) -> update.UpdateRe async def test_refresh_cache_does_not_throttle_on_exception(monkeypatch, tmp_path): last_check_file = tmp_path / "last_update_check.txt" - async def raising_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: + async def raising_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: raise RuntimeError("boom") monkeypatch.setattr(update, "LAST_UPDATE_CHECK_FILE", last_check_file) @@ -554,7 +554,7 @@ async def fake_unavailable(session, latest_version: str, upgrade_command: list[s monkeypatch.setattr(update, "_update_candidate_unavailable_reason", fake_unavailable) monkeypatch.setattr(update, "new_client_session", lambda timeout: _FakeSessionContext(object())) - result = await update.do_update(print=False, check_only=True) + result = await update.do_update(print_output=False, check_only=True) assert result is update.UpdateResult.FAILED assert not latest_file.exists() @@ -645,8 +645,8 @@ async def test_resolve_latest_version_can_force_refresh(monkeypatch, tmp_path): last_check_file = tmp_path / "last_update_check.txt" calls: list[tuple[bool, bool]] = [] - async def fake_do_update(*, print: bool, check_only: bool) -> update.UpdateResult: - calls.append((print, check_only)) + async def fake_do_update(*, print_output: bool, check_only: bool) -> update.UpdateResult: + calls.append((print_output, check_only)) latest_file.write_text("3.2.0", encoding="utf-8") return update.UpdateResult.UPDATE_AVAILABLE @@ -692,7 +692,7 @@ def fake_run(*args, **kwargs): monkeypatch.setattr(update.subprocess, "run", fake_run) with pytest.raises(SystemExit) as excinfo: - await update.do_update(print=False, check_only=False) + await update.do_update(print_output=False, check_only=False) assert excinfo.value.code == 0 assert spawned and "pythinker-code" in spawned[0] @@ -722,7 +722,9 @@ async def fake_unavailable(session, latest_version: str, upgrade_command: list[s monkeypatch.setattr(update, "_maybe_run_native_update", fake_native_update) monkeypatch.setattr(update.subprocess, "run", fake_run) - assert await update.do_update(print=False, check_only=False) is update.UpdateResult.UPDATED + assert ( + await update.do_update(print_output=False, check_only=False) is update.UpdateResult.UPDATED + ) assert native_versions == ["999.0.0"] diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index 078d9c5e..60169b26 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -110,9 +110,31 @@ def test_welcome_strapline_and_help_on_separate_lines(monkeypatch): shell_module._print_welcome_info("Pythinker Code", []) out = console.export_text() - assert "then Create." in out - assert "Send /help for help." in out + assert "Build with confidence." in out + assert "Type /help for commands." in out # The strapline and the help line must not share one rendered line. - assert not any( - "then Create." in ln and "Send /help" in ln for ln in out.splitlines() - ) + assert not any("Build with confidence." in ln and "Type /help" in ln for ln in out.splitlines()) + + +def test_welcome_auto_save_path_is_middle_truncated_not_wrapped(monkeypatch): + from pythinker_code.ui.shell import WelcomeInfoItem + + console = Console(record=True, width=120, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + + items = [ + WelcomeInfoItem( + name="Auto-save", + value=( + "~/.pythinker/sessions/91ce869d5afa3e6547c32cb5b58fa943/" + "6b76c556-cae9-47e2-8233-38ecf986624e/context.json" + ), + ) + ] + shell_module._print_welcome_info("Pythinker Code", items) + + lines = [ln for ln in console.export_text().splitlines() if "Auto-save" in ln] + assert len(lines) == 1 + assert "…" in lines[0] + assert "context.json" in lines[0] diff --git a/tests/ui_and_conv/test_terminal_capabilities.py b/tests/ui_and_conv/test_terminal_capabilities.py new file mode 100644 index 00000000..5794e81c --- /dev/null +++ b/tests/ui_and_conv/test_terminal_capabilities.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import importlib +from types import SimpleNamespace + +from pythinker_code.ui.terminal_capabilities import ( + ascii_glyphs_enabled, + colors_disabled, + motion_disabled, +) + + +def test_color_capability_honors_standard_env_vars() -> None: + assert colors_disabled({"NO_COLOR": "1"}) + assert colors_disabled({"TERM": "dumb"}) + assert colors_disabled({"CLICOLOR": "0"}) + assert colors_disabled({"PYTHINKER_NO_COLOR": "true"}) + assert not colors_disabled({"TERM": "xterm-256color"}) + + +def test_ascii_glyphs_are_opt_in_or_minimal_terminal() -> None: + assert ascii_glyphs_enabled({"PYTHINKER_TUI_GLYPHS": "ascii"}) + assert ascii_glyphs_enabled({"PYTHINKER_ASCII_UI": "1"}) + assert ascii_glyphs_enabled({"TERM": "dumb"}) + assert ascii_glyphs_enabled({"TERM": "xterm"}, stdout=SimpleNamespace(encoding="cp1252")) + assert not ascii_glyphs_enabled({"PYTHINKER_TUI_GLYPHS": "unicode", "TERM": "dumb"}) + + +def test_motion_capability_honors_static_output_env_vars() -> None: + assert motion_disabled({"TERM": "dumb"}) + assert motion_disabled({"PYTHINKER_REDUCED_MOTION": "1"}) + assert motion_disabled({"PYTHINKER_NO_ANIMATION": "true"}) + assert motion_disabled({"PYTHINKER_STATIC_OUTPUT": "yes"}) + assert not motion_disabled({"TERM": "xterm-256color"}) + + +def test_theme_resolvers_strip_colors_when_no_color_is_set(monkeypatch) -> None: + from pythinker_code.ui.theme import ( + _strip_ptk_colors, + get_diff_colors, + get_toolbar_colors, + markdown_rich_style, + tui_rich_style, + ) + + monkeypatch.setenv("NO_COLOR", "1") + + assert tui_rich_style("accent").color is None + assert tui_rich_style("tool_error_bg").bgcolor is None + assert markdown_rich_style("link").color is None + assert get_diff_colors().add_bg.bgcolor is None + assert get_toolbar_colors().tip_key == "bold" + assert _strip_ptk_colors("bg:#112233 fg:#abcdef bold italic") == "bold italic" + + +def test_ascii_glyph_mode_uses_plain_fallbacks(monkeypatch) -> None: + import pythinker_code.ui.shell.glyphs as glyphs + + monkeypatch.setenv("PYTHINKER_TUI_GLYPHS", "ascii") + ascii_glyphs = importlib.reload(glyphs) + try: + assert ascii_glyphs.SPINNER_FRAMES == ("-", "\\", "|", "/") + assert ascii_glyphs.REDUCED_MOTION_GLYPH == "*" + assert ascii_glyphs.TRANSCRIPT_PROMPT_MARKER == ">" + assert ascii_glyphs.TRANSCRIPT_TOOL_GUTTER == "|" + finally: + monkeypatch.delenv("PYTHINKER_TUI_GLYPHS", raising=False) + importlib.reload(glyphs) diff --git a/tests/ui_and_conv/test_tool_call_block.py b/tests/ui_and_conv/test_tool_call_block.py index 47ce29e8..e9d95e68 100644 --- a/tests/ui_and_conv/test_tool_call_block.py +++ b/tests/ui_and_conv/test_tool_call_block.py @@ -73,7 +73,7 @@ def test_tool_call_block_renders_running_worklog_entry(): assert "running" in output.lower() -def test_tool_call_block_renders_running_subagent_with_solid_circle(monkeypatch): +def test_tool_call_block_renders_running_subagent_with_text_safe_solid_circle(monkeypatch): monkeypatch.setattr(_worklog.time, "monotonic", lambda: 0.0) block = _ToolCallBlock(_tool_call("Agent", '{"description":"Audit UI"}')) output = _plain(block.compose()) @@ -168,9 +168,34 @@ def test_completed_subagent_renders_compact_summary(): assert "Subagent" in output assert "completed" in output.lower() assert "7 tool calls" in output + assert "tools: Read ×7" in output assert output.count("ReadFile") <= 4 +def test_completed_subagent_summarizes_changed_files_and_tool_counts(): + block = _ToolCallBlock(_tool_call("Agent", '{"description":"Implement UI"}')) + calls = [ + _tool_call_with_id("read-1", "ReadFile", json.dumps({"path": "src/app.py"})), + _tool_call_with_id("read-2", "ReadFile", json.dumps({"path": "src/ui.py"})), + _tool_call_with_id("write-1", "WriteFile", json.dumps({"path": "src/new.py"})), + _tool_call_with_id("edit-1", "StrReplaceFile", json.dumps({"path": "src/existing.py"})), + _tool_call_with_id("shell-1", "Shell", json.dumps({"command": "pytest"})), + ] + for call in calls: + block.append_sub_tool_call(call) + block.finish_sub_tool_call(ToolResult(tool_call_id=call.id, return_value=ToolOk(output=""))) + + block.finish(ToolOk(output="done")) + output = _plain(block.compose()) + + assert "tools:" in output + assert "Read ×2" in output + assert "Write" in output + assert "Edit" in output + assert "Shell" in output + assert "changed: src/new.py, src/existing.py" in output + + def test_append_sub_output_part_accumulates_text(): block = _ToolCallBlock(_tool_call("Agent", '{"description":"scan"}')) call = _tool_call_with_id("sub-1", "Bash", '{"command":"ls"}') diff --git a/tests/ui_and_conv/test_tui_blocks_integration.py b/tests/ui_and_conv/test_tui_blocks_integration.py index dda8d629..38bf4766 100644 --- a/tests/ui_and_conv/test_tui_blocks_integration.py +++ b/tests/ui_and_conv/test_tui_blocks_integration.py @@ -185,7 +185,7 @@ def test_card_style_running_subagent_uses_solid_circle(_force_card_style, monkey assert "Agent(" in rendered assert "Audit UI" in rendered - assert "⏺" in rendered + assert "●" in rendered assert not any(frame in rendered for frame in spinner_frames) block.finish(_ok_result("done")) @@ -210,7 +210,7 @@ def test_card_style_finished_subagent_shows_compact_result(_force_card_style, mo block.finish(_ok_result("done")) rendered = render_plain(block.compose(), width=80) - assert "⏺ Agent(coder · Audit UI)" in rendered + assert "● Agent(coder · Audit UI)" in rendered assert "⎿ done" in rendered assert "Agent finished" not in rendered @@ -234,7 +234,7 @@ def test_card_style_running_task_output_uses_solid_circle(_force_card_style, mon assert "TaskOutput(" in rendered assert "agent-123" in rendered - assert "⏺" in rendered + assert "●" in rendered assert not any(frame in rendered for frame in spinner_frames) @@ -258,9 +258,9 @@ def test_card_style_running_subagent_marker_pulses(_force_card_style, monkeypatc assert first != second assert "Agent(" in first - assert "⏺" in first + assert "●" in first assert "Agent(" in second - assert "⏺" not in second + assert "●" not in second def test_card_style_background_subagent_result_keeps_solid_circle(_force_card_style, monkeypatch): @@ -291,7 +291,7 @@ def test_card_style_background_subagent_result_keeps_solid_circle(_force_card_st assert "background subagent working" in rendered assert "background audit" in rendered assert "status: running" in rendered - assert "⏺" in rendered + assert "●" in rendered assert not any(frame in rendered for frame in spinner_frames) @@ -326,7 +326,7 @@ def test_card_style_background_subagent_marker_pulses(_force_card_style, monkeyp assert first != second assert "background subagent working" in first - assert "⎿ ⏺ background subagent working" in first + assert "⎿ ● background subagent working" in first assert "background subagent working" in second assert "⎿ background subagent working" in second diff --git a/tests/ui_and_conv/test_tui_card_tool_renderers.py b/tests/ui_and_conv/test_tui_card_tool_renderers.py index 7d098077..1a56427b 100644 --- a/tests/ui_and_conv/test_tui_card_tool_renderers.py +++ b/tests/ui_and_conv/test_tui_card_tool_renderers.py @@ -99,11 +99,11 @@ def test_loading_marker_pulses_muted_transcript_dot_then_finishes_green(): hidden = loading_marker(now=0.9) done = loading_marker(done=True) - assert visible.plain == "⏺ " + assert visible.plain == "● " assert visible.style == tui_rich_style("muted") assert hidden.plain == " " assert hidden.style == tui_rich_style("muted") - assert done.plain == "⏺ " + assert done.plain == "● " assert done.style == tui_rich_style("success") @@ -118,7 +118,7 @@ def test_read_renders_path_and_range(): {"path": "/repo/src/foo.py", "line_offset": 10, "n_lines": 30}, output="line1\nline2", ) - assert "⏺ Read(" in rendered + assert "● Read(" in rendered assert "src/foo.py" in rendered assert ":10-39" in rendered assert "Read 1 file (ctrl+o to expand)" in rendered @@ -206,7 +206,7 @@ def test_write_shows_path_and_content_preview(): {"path": "/repo/new.py", "content": "def f():\n return 1\n"}, output="Successfully wrote", ) - assert "⏺ Write(new.py)" in rendered + assert "● Write(new.py)" in rendered assert "Wrote 2 lines to new.py" in rendered assert "1 def f():" in rendered @@ -317,7 +317,7 @@ def test_grep_renders_pattern_and_path(): {"pattern": "def\\s+", "path": "/repo/src", "glob": "*.py"}, output="src/foo.py:10: def hello():", ) - assert "⏺ Search(" in rendered + assert "● Search(" in rendered assert "/def\\s+/" in rendered assert "src" in rendered assert "*.py" in rendered @@ -343,7 +343,7 @@ def test_invalid_empty_grep_call_names_missing_pattern(): ), is_error=True, ) - assert "⏺ Search( in .)" in rendered + assert "✘ Search( in .)" in rendered assert "Error searching files" in rendered assert "Search(... in .)" not in rendered @@ -359,7 +359,7 @@ def test_glob_renders_pattern_and_directory(): {"pattern": "**/*.py", "directory": "/repo/src"}, output="src/a.py\nsrc/b.py", ) - assert "⏺ Find(" in rendered + assert "● Find(" in rendered assert "**/*.py" in rendered assert "Found 2 files" in rendered @@ -371,7 +371,7 @@ def test_glob_renders_pattern_and_directory(): def test_shell_renders_command_and_output_under_response_gutter(): rendered = _render("Shell", {"command": "ls -la", "timeout": 60}, output="total 0") - assert "⏺ Bash(ls -la)" in rendered + assert "● Bash(ls -la)" in rendered assert "total 0" in rendered assert "⎿" in rendered @@ -459,7 +459,7 @@ def test_shell_error_uses_structured_exit_code_when_available(): def test_shell_uses_comment_label_for_long_script(): command = "# build assets\n" + "\n".join(f"echo {i}" for i in range(5)) rendered = _render("Shell", {"command": command, "timeout": 60}, output="ok") - assert "⏺ Bash(build assets)" in rendered + assert "● Bash(build assets)" in rendered assert "echo 0" not in rendered @@ -517,8 +517,7 @@ def test_running_tool_headers_do_not_duplicate_status_bullets(): for tool, args, label in cases: rendered = _render_running(tool, args, width=64) assert label in rendered - assert "⏺ ⏺" not in rendered - assert "⏺ ⏺" not in rendered + assert "● ●" not in rendered def test_streaming_missing_args_use_preparing_rows_not_tool_ellipsis_placeholders(): @@ -555,7 +554,7 @@ def test_invalid_empty_shell_call_names_missing_command(): ), is_error=True, ) - assert "⏺ Bash()" in rendered + assert "✘ Bash()" in rendered assert "$ ..." not in rendered @@ -671,7 +670,7 @@ def test_agent_renders_type_and_description_without_prompt_preview(): }, output="Plan ready", ) - assert "⏺ Agent(" in rendered + assert "● Agent(" in rendered assert "code-architect" in rendered assert "design auth flow" in rendered assert "Prompt: Design the OAuth flow with PKCE" not in rendered @@ -751,7 +750,7 @@ def test_run_agents_renders_compact_professional_summary(): ), width=120, ) - assert "⏺ RunAgents(" in rendered + assert "● RunAgents(" in rendered assert "2 agents" in rendered assert "foreground" in rendered assert "code_scan" in rendered @@ -784,7 +783,7 @@ def test_ask_user_renders_question_and_options(): ] }, ) - assert "⏺ Ask(1 question)" in rendered + assert "● Ask(1 question)" in rendered assert "Which auth method?" in rendered assert "OAuth" in rendered assert "API key" in rendered @@ -811,7 +810,7 @@ def test_ask_user_renders_single_question_object_without_invalid_badge(): }, ) - assert "⏺ Ask(1 question)" in rendered + assert "● Ask(1 question)" in rendered assert "" not in rendered assert "How should independent analyses run?" in rendered assert "Run concurrently (Recommended)" in rendered @@ -824,7 +823,7 @@ def test_ask_user_renders_single_question_object_without_invalid_badge(): def test_think_renders_thought_body(): rendered = _render("Think", {"thought": "First, check the file layout.\nThen draft a fix."}) - assert "⏺ Think" in rendered + assert "● Think" in rendered assert "First, check the file layout." in rendered @@ -877,7 +876,7 @@ def test_todo_infers_nested_items_from_leading_spaces(): def test_fetch_renders_url(): rendered = _render("FetchURL", {"url": "https://example.com/page"}, output="...") - assert "⏺ Fetch(" in rendered + assert "● Fetch(" in rendered assert "example.com" in rendered assert "Received 9 bytes" in rendered @@ -888,7 +887,7 @@ def test_search_renders_query_and_extras(): {"query": "python typing", "limit": 10, "include_content": True}, output="result 1", ) - assert "⏺ WebSearch(" in rendered + assert "● WebSearch(" in rendered assert "python typing" in rendered assert "limit 10" in rendered assert "with content" in rendered @@ -952,7 +951,7 @@ def test_search_all_results_filtered_reports_zero(): def test_task_list_renders_active_flag(): rendered = _render("TaskList", {"active_only": True}, output="task-1: running") - assert "⏺ Tasks(active)" in rendered + assert "● Tasks(active)" in rendered def test_task_output_renders_id_and_block_flag(): @@ -961,14 +960,14 @@ def test_task_output_renders_id_and_block_flag(): {"task_id": "abc-123", "block": True, "timeout": 60}, output="logs...", ) - assert "⏺ TaskOutput(" in rendered + assert "● TaskOutput(" in rendered assert "abc-123" in rendered assert "block" in rendered def test_task_stop_renders_id(): rendered = _render("TaskStop", {"task_id": "abc-123", "reason": "user requested"}) - assert "⏺ TaskStop(" in rendered + assert "● TaskStop(" in rendered assert "abc-123" in rendered @@ -979,7 +978,7 @@ def test_task_stop_renders_id(): def test_enter_plan_mode_renders(): rendered = _render("EnterPlanMode", {}) - assert "⏺ Plan(entering)" in rendered + assert "● Plan(entering)" in rendered def test_exit_plan_mode_renders_options(): @@ -992,7 +991,7 @@ def test_exit_plan_mode_renders_options(): ] }, ) - assert "⏺ Plan(exiting)" in rendered + assert "● Plan(exiting)" in rendered assert "Refactor first" in rendered assert "Add tests first" in rendered @@ -1006,7 +1005,7 @@ def test_card_renders_compact_without_outer_padding(): """Compact tool cards should start at the title and avoid extra outer padding.""" rendered = _render("Glob", {"pattern": "*.py", "directory": "/repo"}, output="foo.py") lines = [line.strip() for line in rendered.splitlines()] - assert lines[0] == "⏺ Find(*.py in /repo)" + assert lines[0] == "● Find(*.py in /repo)" assert lines[-1] == "⎿ Found 1 file ctrl+o expand" diff --git a/tests/ui_and_conv/test_tui_components.py b/tests/ui_and_conv/test_tui_components.py index a68cb4ec..15353082 100644 --- a/tests/ui_and_conv/test_tui_components.py +++ b/tests/ui_and_conv/test_tui_components.py @@ -251,7 +251,7 @@ def test_bash_execution_uses_codex_style_compact_layout(): width=80, ) - assert "⏺ Ran $ printf hello" in out + assert "● Ran $ printf hello" in out assert "⎿ hello" in out assert " world" in out assert "─" not in out @@ -279,8 +279,8 @@ def test_bash_execution_ignores_shebang_when_extracting_comment_label(): width=80, ) - assert "⏺ Ran $ usr/bin/env bash" not in shebang_only - assert "⏺ Ran $ Build docs" in with_label + assert "● Ran $ usr/bin/env bash" not in shebang_only + assert "● Ran $ Build docs" in with_label assert "echo ok" not in with_label @@ -358,5 +358,5 @@ def test_bash_execution_running_marker_pulses(monkeypatch): ) assert first != second - assert "⏺ Running $ sleep 1" in first + assert "● Running $ sleep 1" in first assert "Running $ sleep 1" in second diff --git a/tests/ui_and_conv/test_update_orchestrator.py b/tests/ui_and_conv/test_update_orchestrator.py new file mode 100644 index 00000000..78e4bf51 --- /dev/null +++ b/tests/ui_and_conv/test_update_orchestrator.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import json +import os +import time +from types import SimpleNamespace + +import pytest + +from pythinker_code.ui.shell import update +from pythinker_code.ui.shell import update_orchestrator as orchestrator + + +def _isolate_update_files(monkeypatch, tmp_path): + monkeypatch.setattr(orchestrator, "UPDATE_STATUS_FILE", tmp_path / "update_status.json") + monkeypatch.setattr(orchestrator, "UPDATE_LOG_FILE", tmp_path / "update.log") + monkeypatch.setattr(orchestrator, "UPDATE_LOCK_FILE", tmp_path / "update.lock") + monkeypatch.setattr( + orchestrator, + "UPDATE_LAST_SUCCESS_FILE", + tmp_path / "update_last_success.json", + ) + + +@pytest.mark.asyncio +async def test_update_job_records_status_and_log(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + + async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): + assert print_output is False + assert check_only is True + assert output_callback is not None + output_callback("checked release channel") + return update.UpdateResult.UP_TO_DATE + + monkeypatch.setattr(update, "do_update", fake_do_update) + monkeypatch.setattr(orchestrator, "_read_target_version", lambda: "1.2.3") + + result = await orchestrator.run_update_job(print_output=False, check_only=True, source="test") + + assert result is update.UpdateResult.UP_TO_DATE + assert not orchestrator.UPDATE_LOCK_FILE.exists() + status = orchestrator.read_update_status() + assert status is not None + assert status.state is orchestrator.UpdateJobState.UP_TO_DATE + assert status.result == "UP_TO_DATE" + assert status.target_version == "1.2.3" + assert "checked release channel" in "\n".join(orchestrator.read_update_log_tail()) + + +@pytest.mark.asyncio +async def test_update_job_blocks_when_another_process_holds_lock(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.UPDATE_LOCK_FILE.write_text( + json.dumps({"pid": os.getpid(), "started_at": time.time()}), + encoding="utf-8", + ) + + async def fail_do_update(**_kwargs): + raise AssertionError("locked update must not call do_update") + + monkeypatch.setattr(update, "do_update", fail_do_update) + + orchestrator.write_update_status( + orchestrator.UpdateJobStatus( + job_id="running-job", + state=orchestrator.UpdateJobState.RUNNING, + started_at=time.time(), + finished_at=None, + current_version="1.0.0", + target_version=None, + result=None, + message="still running", + log_path=str(orchestrator.UPDATE_LOG_FILE), + pid=os.getpid(), + ) + ) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.FAILED + status = orchestrator.read_update_status() + assert status is not None + assert status.job_id == "running-job" + assert status.state is orchestrator.UpdateJobState.RUNNING + assert "already running" in "\n".join(orchestrator.read_update_log_tail()) + + +@pytest.mark.asyncio +async def test_update_job_blocks_on_fresh_malformed_lock(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.UPDATE_LOCK_FILE.write_text("", encoding="utf-8") + + async def fail_do_update(**_kwargs): + raise AssertionError("fresh malformed lock must not call do_update") + + monkeypatch.setattr(update, "do_update", fail_do_update) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.FAILED + assert orchestrator.UPDATE_LOCK_FILE.exists() + assert orchestrator.read_update_status() is None + assert "already running" in "\n".join(orchestrator.read_update_log_tail()) + + +@pytest.mark.asyncio +async def test_update_job_replaces_old_malformed_lock(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.UPDATE_LOCK_FILE.write_text("", encoding="utf-8") + old_time = time.time() - orchestrator._LOCK_MALFORMED_GRACE_SECONDS - 1 + os.utime(orchestrator.UPDATE_LOCK_FILE, (old_time, old_time)) + + async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): + return update.UpdateResult.UP_TO_DATE + + monkeypatch.setattr(update, "do_update", fake_do_update) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.UP_TO_DATE + assert not orchestrator.UPDATE_LOCK_FILE.exists() + + +@pytest.mark.asyncio +async def test_update_job_replaces_stale_lock(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.UPDATE_LOCK_FILE.write_text( + json.dumps({"pid": 123456789, "started_at": time.time()}), + encoding="utf-8", + ) + monkeypatch.setattr(orchestrator, "_pid_exists", lambda _pid: False) + + async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): + return update.UpdateResult.UP_TO_DATE + + monkeypatch.setattr(update, "do_update", fake_do_update) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.UP_TO_DATE + assert not orchestrator.UPDATE_LOCK_FILE.exists() + status = orchestrator.read_update_status() + assert status is not None + assert status.state is orchestrator.UpdateJobState.UP_TO_DATE + + +@pytest.mark.asyncio +async def test_update_job_marks_failed_when_smoke_check_fails(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + + async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): + return update.UpdateResult.UPDATED + + monkeypatch.setattr(update, "do_update", fake_do_update) + monkeypatch.setattr( + orchestrator, + "run_post_install_smoke_check", + lambda: (False, "Smoke check failed: broken"), + ) + + result = await orchestrator.run_update_job(print_output=False, source="test") + + assert result is update.UpdateResult.FAILED + status = orchestrator.read_update_status() + assert status is not None + assert status.state is orchestrator.UpdateJobState.FAILED + assert status.result == "FAILED" + assert "smoke check failed" in (status.message or "").lower() + assert not orchestrator.UPDATE_LAST_SUCCESS_FILE.exists() + + +@pytest.mark.asyncio +async def test_run_update_prompt_routes_check_through_runner(monkeypatch): + calls: list[bool] = [] + + async def fail_do_update(**_kwargs): + raise AssertionError("orchestrated /update check must not call do_update directly") + + async def fake_runner(*, print_output: bool, check_only: bool): + assert print_output is True + calls.append(check_only) + return update.UpdateResult.UP_TO_DATE + + monkeypatch.setattr(update, "do_update", fail_do_update) + + result = await update.run_update_prompt(update_runner=fake_runner) + + assert result is update.UpdateResult.UP_TO_DATE + assert calls == [True] + + +def test_update_log_tail_returns_recent_lines(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + + for idx in range(5): + orchestrator.append_update_log(f"line {idx}") + + assert orchestrator.read_update_log_tail(2) == ["line 3", "line 4"] + + +def test_update_log_redacts_sensitive_output_and_is_owner_only(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + + orchestrator.append_update_log( + "Authorization: Bearer secret-token-12345 " + "PIP_INDEX_URL=https://user:pass@example.test/simple" + ) + + log_text = orchestrator.UPDATE_LOG_FILE.read_text(encoding="utf-8") + assert "secret-token-12345" not in log_text + assert "user:pass" not in log_text + assert " None: + raise AssertionError("Windows PID checks must not call os.kill") + + monkeypatch.setattr(orchestrator.os, "kill", fail_kill) + + assert orchestrator._pid_exists(os.getpid() + 1) is True + + +@pytest.mark.asyncio +async def test_do_update_mirrors_messages_to_output_callback(monkeypatch, tmp_path): + messages: list[str] = [] + + async def fake_get_latest(session): + return "999.0.0" + + async def fake_unavailable(session, latest_version: str, upgrade_command: list[str]): + return None + + async def fake_native_update(latest_version: str) -> update.UpdateResult: + return update.UpdateResult.UPDATED + + monkeypatch.setattr(update, "LATEST_VERSION_FILE", tmp_path / "latest.txt") + monkeypatch.setattr(update, "_get_latest_version", fake_get_latest) + monkeypatch.setattr(update, "_update_candidate_unavailable_reason", fake_unavailable) + monkeypatch.setattr(update, "_detect_upgrade_command", lambda: [update.NATIVE_INSTALLER_MARKER]) + monkeypatch.setattr(update, "_maybe_run_native_update", fake_native_update) + + result = await update.do_update(print_output=False, output_callback=messages.append) + + assert result is update.UpdateResult.UPDATED + assert any("Checking for updates" in message for message in messages) + assert any("Updating pythinker-code" in message for message in messages) + + +def test_smoke_check_reports_success(monkeypatch): + monkeypatch.setattr(orchestrator, "_smoke_check_command", lambda: ["pythinker", "--version"]) + + def fake_run(command, **kwargs): + assert command == ["pythinker", "--version"] + assert kwargs["timeout"] == orchestrator._SMOKE_CHECK_TIMEOUT_SECONDS + return SimpleNamespace(returncode=0, stdout="pythinker, version 1.2.3\n", stderr="") + + monkeypatch.setattr(orchestrator.subprocess, "run", fake_run) + + ok, message = orchestrator.run_post_install_smoke_check() + + assert ok is True + assert "1.2.3" in message + + +def test_smoke_check_reports_failure(monkeypatch): + monkeypatch.setattr(orchestrator, "_smoke_check_command", lambda: ["pythinker", "--version"]) + + def fake_run(command, **kwargs): + return SimpleNamespace(returncode=1, stdout="", stderr="broken\n") + + monkeypatch.setattr(orchestrator.subprocess, "run", fake_run) + + ok, message = orchestrator.run_post_install_smoke_check() + + assert ok is False + assert "broken" in message diff --git a/tests/ui_and_conv/test_visualize_running_prompt.py b/tests/ui_and_conv/test_visualize_running_prompt.py index 7d32e3fc..77b23e31 100644 --- a/tests/ui_and_conv/test_visualize_running_prompt.py +++ b/tests/ui_and_conv/test_visualize_running_prompt.py @@ -224,7 +224,7 @@ def test_prompt_status_block_renders_above_agent_input_preamble() -> None: from prompt_toolkit.formatted_text import FormattedText def _status_block(_columns: int) -> FormattedText: - return FormattedText([("", "● Booting MCP server: context7")]) + return FormattedText([("", "• Booting MCP server: context7")]) session = object.__new__(CustomPromptSession) session._running_prompt_delegate = None @@ -234,7 +234,7 @@ def _status_block(_columns: int) -> FormattedText: rendered = CustomPromptSession._render_agent_status(session, 80) text = "".join(item[1] for item in rendered) - assert text.startswith("● Booting MCP server: context7") + assert text.startswith("• Booting MCP server: context7") def test_background_status_splits_verb_and_count_styles(monkeypatch) -> None: diff --git a/tests/ui_and_conv/test_worklog_render.py b/tests/ui_and_conv/test_worklog_render.py index 2c75e2ea..a07cd070 100644 --- a/tests/ui_and_conv/test_worklog_render.py +++ b/tests/ui_and_conv/test_worklog_render.py @@ -28,10 +28,13 @@ def test_tool_style_maps_common_tools_to_professional_labels(): assert tool_style("ReadFile").label == "Read" assert tool_style("Grep").label == "Search" assert tool_style("Edit").label == "Edit" + assert tool_style("StrReplaceFile").label == "Edit" assert tool_style("ApplyPatch").label == "Patch" assert tool_style("Bash").label == "Shell" + assert tool_style("SetTodoList").label == "Todo" assert tool_style("TodoWrite").label == "Todo" assert tool_style("Agent").label == "Subagent" + assert tool_style("RunAgents").label == "Subagents" assert tool_style("Agent").icon == "●" from pythinker_code.ui.theme import get_tui_tokens, set_active_theme diff --git a/web/src/components/ai-elements/subagent-steps.tsx b/web/src/components/ai-elements/subagent-steps.tsx index 18b66999..6446c29c 100644 --- a/web/src/components/ai-elements/subagent-steps.tsx +++ b/web/src/components/ai-elements/subagent-steps.tsx @@ -10,12 +10,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Shimmer } from "./shimmer"; -import { - CheckIcon, - ChevronRightIcon, - Loader2Icon, - XIcon, -} from "lucide-react"; +import { ChevronRightIcon, XIcon } from "lucide-react"; // --------------------------------------------------------------------------- // SubagentActivity — top-level wrapper rendered inside Tool's ToolContent area @@ -56,16 +51,20 @@ export const SubagentActivity = memo( {...props} > - + {hasError && !isRunning ? ( + + ) : ( + )} - /> + {isRunning ? ( <> @@ -78,6 +77,12 @@ export const SubagentActivity = memo( ... + ) : hasError ? ( + toolCallCount > 0 ? ( + `${agentLabel} failed · ${toolCallCount} tool call${toolCallCount !== 1 ? "s" : ""}` + ) : ( + `${agentLabel} failed` + ) ) : toolCallCount > 0 ? ( `${agentLabel} completed · ${toolCallCount} tool call${toolCallCount !== 1 ? "s" : ""}` ) : ( @@ -157,11 +162,21 @@ const getPrimaryParam = (input: unknown): string | null => { const getSubToolStatusIcon = (status: string) => { switch (status) { case "success": - return ; + return ( + + + + ); case "error": return ; default: - return ; + // Running: pulse a grey solid circle; keep the footprint aligned with + // the finished green dot and failed red X states. + return ( + + + + ); } }; From cde37402bf8f653fe5918caa95f0c555f447be29 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 16:19:04 -0400 Subject: [PATCH 09/16] test(shell): follow-up tweaks to update, feedback, and TUI tests Adjust update_orchestrator, mcp_status, feedback_repo, and live_view alongside their tests; add update CLI test coverage. --- src/pythinker_code/feedback_repo.py | 5 +- src/pythinker_code/ui/shell/mcp_status.py | 8 +-- .../ui/shell/update_orchestrator.py | 29 ++++++--- .../ui/shell/visualize/_live_view.py | 2 +- tests/auth/test_openai_auth.py | 21 ++++-- tests/cli/test_update_cli.py | 59 +++++++++++++++++ tests/core/test_config.py | 5 +- .../ui_and_conv/test_shell_feedback_slash.py | 5 +- tests/ui_and_conv/test_shell_update.py | 65 +++++++++++++++++++ tests/ui_and_conv/test_shell_welcome_info.py | 42 ++++++++++++ .../ui_and_conv/test_terminal_capabilities.py | 51 ++++++++++++++- tests/ui_and_conv/test_update_orchestrator.py | 36 ++++++++-- 12 files changed, 295 insertions(+), 33 deletions(-) create mode 100644 tests/cli/test_update_cli.py diff --git a/src/pythinker_code/feedback_repo.py b/src/pythinker_code/feedback_repo.py index 0edf0abf..24696af1 100644 --- a/src/pythinker_code/feedback_repo.py +++ b/src/pythinker_code/feedback_repo.py @@ -1,10 +1,11 @@ from __future__ import annotations DEFAULT_FEEDBACK_GITHUB_REPO = "TechMatrix-labs/pythinker-code" +_LEGACY_FEEDBACK_GITHUB_OWNER = "mohamed-elkholy95" _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS = { - "mohamed-elkholy95/Pythinker-Code", - "mohamed-elkholy95/pythinker-code", + _LEGACY_FEEDBACK_GITHUB_OWNER + "/Pythinker-Code", + _LEGACY_FEEDBACK_GITHUB_OWNER + "/pythinker-code", } _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS_LOWER = { repo.lower() for repo in _LEGACY_DEFAULT_FEEDBACK_GITHUB_REPOS diff --git a/src/pythinker_code/ui/shell/mcp_status.py b/src/pythinker_code/ui/shell/mcp_status.py index c66e21dc..1e0ddc02 100644 --- a/src/pythinker_code/ui/shell/mcp_status.py +++ b/src/pythinker_code/ui/shell/mcp_status.py @@ -8,12 +8,12 @@ from rich.text import Text from pythinker_code.ui.shell.components.render_utils import sanitize_ansi -from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import get_mcp_prompt_colors, tui_rich_style from pythinker_code.wire.types import MCPServerSnapshot, MCPStatusSnapshot _STARTING_STATUSES = frozenset({"pending", "connecting"}) +_MCP_STARTUP_GLYPH = "•" def _safe_text(text: str) -> str: @@ -56,7 +56,7 @@ def mcp_startup_header(snapshot: MCPStatusSnapshot) -> str | None: def render_mcp_startup_text(snapshot: MCPStatusSnapshot, *, now: float | None = None) -> Text: """Render the animated MCP startup status used by live prompt/status areas.""" t = time.monotonic() if now is None else now - glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append( mcp_startup_header(snapshot) or "Starting MCP servers", @@ -97,7 +97,7 @@ def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType: def render_mcp_inventory_loading(*, now: float | None = None) -> RenderableType: t = time.monotonic() if now is None else now - glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append("Loading MCP inventory", style=tui_rich_style("tool_title") + Style(bold=True)) line.append("…", style=tui_rich_style("muted")) @@ -135,7 +135,7 @@ def render_mcp_prompt(snapshot: MCPStatusSnapshot, *, now: float | None = None) colors = get_mcp_prompt_colors() t = time.monotonic() if now is None else now - glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " prefix = f"{glyph} " return FormattedText([(colors.text, f"{prefix}{header}"), ("", "\n")]) diff --git a/src/pythinker_code/ui/shell/update_orchestrator.py b/src/pythinker_code/ui/shell/update_orchestrator.py index 45e01dc4..903010c4 100644 --- a/src/pythinker_code/ui/shell/update_orchestrator.py +++ b/src/pythinker_code/ui/shell/update_orchestrator.py @@ -377,13 +377,11 @@ async def run_update_job( if result is UpdateResult.UPDATED and not check_only: smoke_ok, smoke_message = run_post_install_smoke_check() append_update_log(smoke_message) - if not smoke_ok: - reported_result = UpdateResult.FAILED - final_state = UpdateJobState.FAILED - message = f"Updated, but smoke check failed: {smoke_message}" - else: + if smoke_ok: message = smoke_message - _write_last_success(job_id=job_id, message=message) + else: + message = f"Updated, but smoke check did not pass: {smoke_message}" + _write_last_success(job_id=job_id, message=message) write_update_status( _new_status( @@ -433,7 +431,21 @@ def _write_last_success(*, job_id: str, message: str) -> None: def _smoke_check_command() -> list[str]: if is_native_build(): return [sys.executable, "--version"] - return [sys.executable, "-m", "pythinker_code", "--version"] + return [sys.executable, "-P", "-m", "pythinker_code", "--version"] + + +def _smoke_check_cwd() -> Path: + try: + return Path(sys.executable).resolve().parent + except OSError: + return Path.home() + + +def _smoke_check_env() -> dict[str, str]: + env = get_clean_env() + env["PYTHONSAFEPATH"] = "1" + env.pop("PYTHONPATH", None) + return env def run_post_install_smoke_check() -> tuple[bool, str]: @@ -446,7 +458,8 @@ def run_post_install_smoke_check() -> tuple[bool, str]: encoding="utf-8", errors="replace", timeout=_SMOKE_CHECK_TIMEOUT_SECONDS, - env=get_clean_env(), + env=_smoke_check_env(), + cwd=_smoke_check_cwd(), check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: diff --git a/src/pythinker_code/ui/shell/visualize/_live_view.py b/src/pythinker_code/ui/shell/visualize/_live_view.py index 623a0cf1..2300204b 100644 --- a/src/pythinker_code/ui/shell/visualize/_live_view.py +++ b/src/pythinker_code/ui/shell/visualize/_live_view.py @@ -521,7 +521,7 @@ def compose_agent_output( # it too, so a still-running agent is separated from a finished # one already committed to scrollback. _append_action_block(blocks, tool_call.compose(), leading=True) - for hook_block in self._hook_blocks.values(): + for hook_block in getattr(self, "_hook_blocks", {}).values(): _append_action_block(blocks, hook_block.compose(), leading=True) if include_working_indicator and self._active_turn_depth > 0: # Keep a stable activity indicator visible even while content or diff --git a/tests/auth/test_openai_auth.py b/tests/auth/test_openai_auth.py index 393b5fd0..858933d7 100644 --- a/tests/auth/test_openai_auth.py +++ b/tests/auth/test_openai_auth.py @@ -518,10 +518,7 @@ def get(self, url, *, headers, raise_for_status): base_url="https://proxy.example/backend-api/codex/", ) - assert ( - captured["url"] - == "https://proxy.example/backend-api/codex/models?client_version=1.0.0" - ) + assert captured["url"] == "https://proxy.example/backend-api/codex/models?client_version=1.0.0" assert [model.id for model in models] == ["gpt-5.3-codex"] @@ -632,6 +629,7 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri): assert redirect_uri == "https://auth.openai.com/deviceauth/callback" return { "access_token": "access-token", + "id_token": _jwt_with_chatgpt_account("acc_headless"), "refresh_token": "refresh-token", "expires_in": 3600, "token_type": "Bearer", @@ -639,11 +637,12 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri): } async def fake_exchange_id_token_for_api_key(id_token): + assert id_token == _jwt_with_chatgpt_account("acc_headless") return "" async def fake_discover_chatgpt_models(api_key, *, account_id=None): assert api_key == "access-token" - assert account_id is None + assert account_id == "acc_headless" return [_model("gpt-5.1-codex", reasoning=True)] monkeypatch.setattr("pythinker_code.auth.openai._request_device_code", fake_request_device_code) @@ -668,6 +667,7 @@ async def fake_discover_chatgpt_models(api_key, *, account_id=None): token = load_tokens(OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY)) assert token is not None assert token.access_token == "access-token" + assert token.account_id == "acc_headless" provider = config.providers[managed_provider_key(OPENAI_CHATGPT_PLATFORM_ID)] assert provider.type == "openai_codex" assert provider.oauth == OAuthRef(storage="file", key=OPENAI_CHATGPT_OAUTH_KEY) @@ -689,14 +689,19 @@ async def fake_exchange_code_for_tokens(code, verifier, redirect_uri): assert redirect_uri.startswith("http://localhost:") return { "access_token": "access-token", + "id_token": _jwt_with_chatgpt_account("acc_browser"), "refresh_token": "refresh-token", "expires_in": 3600, "token_type": "Bearer", "scope": "openid profile email offline_access", } + async def fake_exchange_id_token_for_api_key(id_token): + assert id_token == _jwt_with_chatgpt_account("acc_browser") + return "" + async def fake_discover_chatgpt_models(api_key, *, account_id=None): - assert account_id is None + assert account_id == "acc_browser" return [_model("gpt-5.1-codex", reasoning=True)] monkeypatch.setattr( @@ -705,6 +710,10 @@ async def fake_discover_chatgpt_models(api_key, *, account_id=None): monkeypatch.setattr( "pythinker_code.auth.openai._exchange_code_for_tokens", fake_exchange_code_for_tokens ) + monkeypatch.setattr( + "pythinker_code.auth.openai._exchange_id_token_for_api_key", + fake_exchange_id_token_for_api_key, + ) monkeypatch.setattr( "pythinker_code.auth.openai.discover_chatgpt_models", fake_discover_chatgpt_models ) diff --git a/tests/cli/test_update_cli.py b/tests/cli/test_update_cli.py new file mode 100644 index 00000000..ce09f1fe --- /dev/null +++ b/tests/cli/test_update_cli.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import os + +from typer.testing import CliRunner + +from pythinker_code.cli import cli +from pythinker_code.ui.shell import update_orchestrator as orchestrator + + +def _isolate_update_files(monkeypatch, tmp_path): + monkeypatch.setattr(orchestrator, "UPDATE_STATUS_FILE", tmp_path / "update_status.json") + monkeypatch.setattr(orchestrator, "UPDATE_LOG_FILE", tmp_path / "update.log") + monkeypatch.setattr(orchestrator, "UPDATE_LOCK_FILE", tmp_path / "update.lock") + monkeypatch.setattr( + orchestrator, + "UPDATE_LAST_SUCCESS_FILE", + tmp_path / "update_last_success.json", + ) + + +def test_update_status_command_renders_recorded_status(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + orchestrator.write_update_status( + orchestrator.UpdateJobStatus( + job_id="job-1", + state=orchestrator.UpdateJobState.UPDATED, + started_at=1.0, + finished_at=2.0, + current_version="1.0.0", + target_version="1.1.0", + result="UPDATED", + message="Smoke check passed: pythinker, version 1.1.0", + log_path=str(orchestrator.UPDATE_LOG_FILE), + pid=os.getpid(), + source="test", + ) + ) + + result = CliRunner().invoke(cli, ["update", "status"]) + + assert result.exit_code == 0, result.output + assert "State: updated" in result.output + assert "Result: UPDATED" in result.output + assert "Current version: 1.0.0" in result.output + assert "Target version: 1.1.0" in result.output + assert "Message: Smoke check passed" in result.output + assert f"Log: {orchestrator.UPDATE_LOG_FILE}" in result.output + + +def test_update_log_command_respects_line_count(monkeypatch, tmp_path): + _isolate_update_files(monkeypatch, tmp_path) + for idx in range(4): + orchestrator.append_update_log(f"line {idx}") + + result = CliRunner().invoke(cli, ["update", "log", "--lines", "2"]) + + assert result.exit_code == 0, result.output + assert result.output.splitlines() == ["line 2", "line 3"] diff --git a/tests/core/test_config.py b/tests/core/test_config.py index c37b8c65..55d56cf4 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -97,9 +97,8 @@ def test_load_config_text_json(): def test_load_config_migrates_legacy_feedback_repo_default(): - config = load_config_from_string( - '[feedback]\ngithub_repo = "mohamed-elkholy95/Pythinker-Code"\n' - ) + old_owner = "mohamed-elkholy95" + config = load_config_from_string(f'[feedback]\ngithub_repo = "{old_owner}/Pythinker-Code"\n') assert config.feedback.github_repo == "TechMatrix-labs/pythinker-code" diff --git a/tests/ui_and_conv/test_shell_feedback_slash.py b/tests/ui_and_conv/test_shell_feedback_slash.py index be3e1136..1433a609 100644 --- a/tests/ui_and_conv/test_shell_feedback_slash.py +++ b/tests/ui_and_conv/test_shell_feedback_slash.py @@ -298,11 +298,12 @@ def post(self, *_args: object, **_kwargs: object) -> FakeResponse: def test_feedback_issue_url_migrates_legacy_default_repo(self) -> None: payload = {"type": "other", "content": "hi"} + old_owner = "mohamed-elkholy95" - url = build_feedback_issue_url(payload, "mohamed-elkholy95/Pythinker-Code") + url = build_feedback_issue_url(payload, old_owner + "/Pythinker-Code") assert "github.com/TechMatrix-labs/pythinker-code/issues/new" in url - assert "mohamed-elkholy95" not in url + assert old_owner not in url def test_feedback_issue_url_uses_compact_body_for_large_payload(self) -> None: payload = { diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index bae986e7..77ba083a 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -698,6 +698,71 @@ def fake_run(*args, **kwargs): assert spawned and "pythinker-code" in spawned[0] +def test_run_upgrade_command_streams_subprocess_output(monkeypatch): + messages: list[str] = [] + launched: list[list[str]] = [] + + class FakeProc: + stdout = ["first line\n", "second line\n"] + + def wait(self, *, timeout: float) -> int: + assert timeout == update.UPGRADE_COMMAND_TIMEOUT_SECONDS + return 0 + + def fake_popen(command, **kwargs): + launched.append(command) + assert kwargs["stdout"] is update.subprocess.PIPE + assert kwargs["stderr"] is update.subprocess.STDOUT + assert kwargs["text"] is True + return FakeProc() + + monkeypatch.setattr(update.subprocess, "Popen", fake_popen) + + returncode = update._run_upgrade_command( + ["uv", "tool", "upgrade", "pythinker-code"], + print_output=False, + output_callback=messages.append, + ) + + assert returncode == 0 + assert launched == [["uv", "tool", "upgrade", "pythinker-code"]] + assert messages == ["first line", "second line"] + + +@pytest.mark.asyncio +async def test_do_update_reports_non_native_upgrade_failure_to_callback(monkeypatch, tmp_path): + messages: list[str] = [] + + async def fake_get_latest(session): + return "999.0.0" + + async def fake_unavailable(session, latest_version: str, upgrade_command: list[str]): + return None + + def fake_run_upgrade_command(command, *, print_output: bool, output_callback): + assert command == ["uv", "tool", "upgrade", "pythinker-code"] + assert print_output is False + assert output_callback is not None + output_callback("installer said no") + return 2 + + monkeypatch.setattr(update, "LATEST_VERSION_FILE", tmp_path / "latest.txt") + monkeypatch.setattr(update, "_get_latest_version", fake_get_latest) + monkeypatch.setattr(update, "_update_candidate_unavailable_reason", fake_unavailable) + monkeypatch.setattr( + update, + "_detect_upgrade_command", + lambda: ["uv", "tool", "upgrade", "pythinker-code"], + ) + monkeypatch.setattr(update, "_run_upgrade_command", fake_run_upgrade_command) + + result = await update.do_update(print_output=False, output_callback=messages.append) + + assert result is update.UpdateResult.FAILED + assert "installer said no" in messages + assert any("Upgrade failed" in message for message in messages) + + @pytest.mark.asyncio async def test_do_update_uses_native_installer_marker(monkeypatch, tmp_path): native_versions: list[str] = [] diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index 60169b26..0cb1d529 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -116,6 +116,48 @@ def test_welcome_strapline_and_help_on_separate_lines(monkeypatch): assert not any("Build with confidence." in ln and "Type /help" in ln for ln in out.splitlines()) +def test_welcome_banner_layout_width_matrix(monkeypatch): + from pythinker_code.ui.shell import WelcomeInfoItem + from pythinker_code.ui.shell.components.render_utils import cell_width + + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + items = [ + WelcomeInfoItem(name="Directory", value="/home/ai/Projects/pythinker-code-main"), + WelcomeInfoItem(name="Model", value="gpt-5.1-codex"), + WelcomeInfoItem(name="Branch", value="feat/welcome-banner-redesign"), + WelcomeInfoItem( + name="Tip", + value="Use /update after release promotion completes and /help for commands.", + ), + ] + + for width in (60, 80, 120): + console = Console(record=True, width=width, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + + shell_module._print_welcome_info( + "Pythinker Code", + items, + banner=Text("↑ Update available — v9.9.10 · /update"), + ) + + output = console.export_text() + lines = [line.rstrip() for line in output.splitlines() if line.strip()] + max_panel_width = min(width, shell_module._WELCOME_MAX_WIDTH) + assert all(cell_width(line) <= max_panel_width for line in lines) + assert "Pythinker Code v9.9.9" in lines[0] + assert "Welcome to Pythinker" in output + assert "Directory" in output + assert "gpt-5.1-codex" in output + assert "Tips" in output + assert "/update" in lines[-1] + assert "/help" in output + if width == 60: + assert "▛" not in output + else: + assert "▛" in output + + def test_welcome_auto_save_path_is_middle_truncated_not_wrapped(monkeypatch): from pythinker_code.ui.shell import WelcomeInfoItem diff --git a/tests/ui_and_conv/test_terminal_capabilities.py b/tests/ui_and_conv/test_terminal_capabilities.py index 5794e81c..1e5ef9e1 100644 --- a/tests/ui_and_conv/test_terminal_capabilities.py +++ b/tests/ui_and_conv/test_terminal_capabilities.py @@ -2,6 +2,7 @@ import importlib from types import SimpleNamespace +from typing import TextIO, cast from pythinker_code.ui.terminal_capabilities import ( ascii_glyphs_enabled, @@ -22,7 +23,8 @@ def test_ascii_glyphs_are_opt_in_or_minimal_terminal() -> None: assert ascii_glyphs_enabled({"PYTHINKER_TUI_GLYPHS": "ascii"}) assert ascii_glyphs_enabled({"PYTHINKER_ASCII_UI": "1"}) assert ascii_glyphs_enabled({"TERM": "dumb"}) - assert ascii_glyphs_enabled({"TERM": "xterm"}, stdout=SimpleNamespace(encoding="cp1252")) + legacy_stdout = cast(TextIO, SimpleNamespace(encoding="cp1252")) + assert ascii_glyphs_enabled({"TERM": "xterm"}, stdout=legacy_stdout) assert not ascii_glyphs_enabled({"PYTHINKER_TUI_GLYPHS": "unicode", "TERM": "dumb"}) @@ -66,3 +68,50 @@ def test_ascii_glyph_mode_uses_plain_fallbacks(monkeypatch) -> None: finally: monkeypatch.delenv("PYTHINKER_TUI_GLYPHS", raising=False) importlib.reload(glyphs) + + +def test_term_dumb_glyph_mode_uses_plain_fallbacks(monkeypatch) -> None: + import pythinker_code.ui.shell.glyphs as glyphs + + monkeypatch.setenv("TERM", "dumb") + plain_glyphs = importlib.reload(glyphs) + try: + assert plain_glyphs.SPINNER_FRAMES == ("-", "\\", "|", "/") + assert plain_glyphs.TRANSCRIPT_PROMPT_MARKER == ">" + assert plain_glyphs.TRANSCRIPT_TOOL_GUTTER == "|" + finally: + monkeypatch.delenv("TERM", raising=False) + importlib.reload(glyphs) + + +def test_explicit_unicode_glyph_mode_overrides_term_dumb(monkeypatch) -> None: + import pythinker_code.ui.shell.glyphs as glyphs + + monkeypatch.setenv("TERM", "dumb") + monkeypatch.setenv("PYTHINKER_TUI_GLYPHS", "unicode") + unicode_glyphs = importlib.reload(glyphs) + try: + assert unicode_glyphs.SPINNER_FRAMES[0] == "⠋" + assert unicode_glyphs.REDUCED_MOTION_GLYPH == "●" + assert unicode_glyphs.TRANSCRIPT_PROMPT_MARKER == "❯" + assert unicode_glyphs.TRANSCRIPT_TOOL_GUTTER == "⎿" + finally: + monkeypatch.delenv("TERM", raising=False) + monkeypatch.delenv("PYTHINKER_TUI_GLYPHS", raising=False) + importlib.reload(glyphs) + + +def test_reduced_motion_env_pins_runtime_activity_marker(monkeypatch) -> None: + from pythinker_code.ui.shell.glyphs import REDUCED_MOTION_GLYPH + from pythinker_code.ui.shell.motion import ( + ActivitySnapshot, + active_marker_frame, + activity_status_line, + ) + + monkeypatch.setenv("PYTHINKER_REDUCED_MOTION", "1") + + assert active_marker_frame(0.0) == REDUCED_MOTION_GLYPH + assert active_marker_frame(10.0) == REDUCED_MOTION_GLYPH + line = activity_status_line(ActivitySnapshot(label="Thinking", elapsed_s=10.0)) + assert line.plain.startswith(f"{REDUCED_MOTION_GLYPH} Thinking") diff --git a/tests/ui_and_conv/test_update_orchestrator.py b/tests/ui_and_conv/test_update_orchestrator.py index 78e4bf51..2e2ee122 100644 --- a/tests/ui_and_conv/test_update_orchestrator.py +++ b/tests/ui_and_conv/test_update_orchestrator.py @@ -146,7 +146,7 @@ async def fake_do_update(*, print_output: bool, check_only: bool, output_callbac @pytest.mark.asyncio -async def test_update_job_marks_failed_when_smoke_check_fails(monkeypatch, tmp_path): +async def test_update_job_keeps_success_when_post_install_smoke_check_fails(monkeypatch, tmp_path): _isolate_update_files(monkeypatch, tmp_path) async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): @@ -161,13 +161,13 @@ async def fake_do_update(*, print_output: bool, check_only: bool, output_callbac result = await orchestrator.run_update_job(print_output=False, source="test") - assert result is update.UpdateResult.FAILED + assert result is update.UpdateResult.UPDATED status = orchestrator.read_update_status() assert status is not None - assert status.state is orchestrator.UpdateJobState.FAILED - assert status.result == "FAILED" - assert "smoke check failed" in (status.message or "").lower() - assert not orchestrator.UPDATE_LAST_SUCCESS_FILE.exists() + assert status.state is orchestrator.UpdateJobState.UPDATED + assert status.result == "UPDATED" + assert "smoke check did not pass" in (status.message or "").lower() + assert orchestrator.UPDATE_LAST_SUCCESS_FILE.exists() @pytest.mark.asyncio @@ -250,6 +250,26 @@ def fail_kill(_pid: int, _signal: int) -> None: assert orchestrator._pid_exists(os.getpid() + 1) is True +def test_python_smoke_check_uses_safe_import_path(monkeypatch): + monkeypatch.setattr(orchestrator, "is_native_build", lambda: False) + monkeypatch.setattr(orchestrator.sys, "executable", "/tmp/venv/bin/python") + + assert orchestrator._smoke_check_command() == [ + "/tmp/venv/bin/python", + "-P", + "-m", + "pythinker_code", + "--version", + ] + + +def test_native_smoke_check_does_not_use_python_module_import(monkeypatch): + monkeypatch.setattr(orchestrator, "is_native_build", lambda: True) + monkeypatch.setattr(orchestrator.sys, "executable", "/opt/pythinker/pythinker") + + assert orchestrator._smoke_check_command() == ["/opt/pythinker/pythinker", "--version"] + + @pytest.mark.asyncio async def test_do_update_mirrors_messages_to_output_callback(monkeypatch, tmp_path): messages: list[str] = [] @@ -278,10 +298,14 @@ async def fake_native_update(latest_version: str) -> update.UpdateResult: def test_smoke_check_reports_success(monkeypatch): monkeypatch.setattr(orchestrator, "_smoke_check_command", lambda: ["pythinker", "--version"]) + monkeypatch.setenv("PYTHONPATH", "/tmp/untrusted") def fake_run(command, **kwargs): assert command == ["pythinker", "--version"] assert kwargs["timeout"] == orchestrator._SMOKE_CHECK_TIMEOUT_SECONDS + assert kwargs["env"]["PYTHONSAFEPATH"] == "1" + assert "PYTHONPATH" not in kwargs["env"] + assert kwargs["cwd"] == orchestrator._smoke_check_cwd() return SimpleNamespace(returncode=0, stdout="pythinker, version 1.2.3\n", stderr="") monkeypatch.setattr(orchestrator.subprocess, "run", fake_run) From 548d96a3fea4e4aaf56a5d5640596d9b1969be2e Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 16:49:55 -0400 Subject: [PATCH 10/16] fix(ci): make UI tests terminal-agnostic, fix docs build, add changelog CI runs the suite with TERM=dumb and NO_COLOR=1 to keep logs clean. The new terminal_capabilities module correctly degrades the TUI to ASCII glyphs and no-color under those, but ~115 pre-existing UI-contract tests assert the rich (Unicode + color + motion) appearance and only ever ran in UTF-8 dev shells. - tests/conftest.py: pin a capable terminal (drop NO_COLOR/CLICOLOR/glyph/motion overrides, set TERM=xterm-256color) before any pythinker import freezes the glyph constants in ui.shell.glyphs. The degraded fallbacks stay covered by tests/ui_and_conv/test_terminal_capabilities.py. Verified: full tests/ run green under NO_COLOR=1 TERM=dumb PYTHONUTF8=1. - docs p0-quick-wins: wrap inline-code GitHub Actions ${{ ... }} in so VitePress's Vue SSR stops evaluating them as template interpolation. Fixes the docs build crash on PYTHINKER_HOME_REPO_DISPATCH_TOKEN. Verified with a local build over the committed doc set. - CHANGELOG.md (+ synced docs mirror): add Unreleased entries for the welcome banner redesign and terminal-aware rendering, satisfying the changelog gate. --- CHANGELOG.md | 2 ++ docs/en/release-notes/changelog.md | 4 +++ ...-31-release-orchestration-p0-quick-wins.md | 4 +-- tests/conftest.py | 26 +++++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb241a46..f9346141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged. +- **Terminal-aware rendering for minimal and CI terminals.** The shell UI adapts to the terminal — ASCII glyph fallbacks for `TERM=dumb` and legacy Windows code pages, reduced-motion mode (`PYTHINKER_REDUCED_MOTION`), and `NO_COLOR`/`CLICOLOR` support that strips color cleanly — so output stays readable in CI logs, SSH panes, and bare terminals. - **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured. ## 0.27.0 (2026-05-31) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 47ec674e..cef00ff4 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,6 +17,10 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Redesigned startup welcome banner.** The banner now uses a cleaner footer-chip layout: the "What's new / Update available" chip sits on the panel's bottom border, the headline/strapline/help lines align beside the robot logo, and the info grid drops its vertical separator. The robot art and palette are unchanged. +- **Terminal-aware rendering for minimal and CI terminals.** The shell UI adapts to the terminal — ASCII glyph fallbacks for `TERM=dumb` and legacy Windows code pages, reduced-motion mode (`PYTHINKER_REDUCED_MOTION`), and `NO_COLOR`/`CLICOLOR` support that strips color cleanly — so output stays readable in CI logs, SSH panes, and bare terminals. +- **Windows updates avoid encoded PowerShell.** Native updates now launch the signed Inno installer directly with Restart Manager flags instead of a `powershell.exe -EncodedCommand` helper, reducing antivirus command-line heuristic false positives. Windows bootstrap installs use visible `/SILENT` progress instead of fully suppressed setup, and the installer build signs bundled PE files plus Inno's setup/uninstaller/temp copies when signing credentials are configured. + ## 0.27.0 (2026-05-31) ### What changed in this release diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md index 117d886d..60003ee4 100644 --- a/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md @@ -108,7 +108,7 @@ This is CI-wiring: verify with `actionlint` locally, then `gh workflow run` post **Files:** Modify `.github/workflows/dispatch-pythinker-home-sync.yml:25-47`. Verify: `actionlint`. -- [ ] 2.1 Replace the `dispatch` job's `steps:` block (current lines 25-47, where the single `Trigger pythinker-home sync` step reads `DISPATCH_TOKEN: ${{ secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN }}` and silently `exit 0` on empty) with a job-level `env:` + a mint step + a dispatch step. The exact replacement for lines 25-47: +- [ ] 2.1 Replace the `dispatch` job's `steps:` block (current lines 25-47, where the single `Trigger pythinker-home sync` step reads DISPATCH_TOKEN: ${{ secrets.PYTHINKER_HOME_REPO_DISPATCH_TOKEN }} and silently `exit 0` on empty) with a job-level `env:` + a mint step + a dispatch step. The exact replacement for lines 25-47: ```yaml env: DISPATCH_OWNER: TechMatrix-labs @@ -508,7 +508,7 @@ Branch: `release-orch/p0-promote-reconcile`. Touches `promote-release.yml` and a echo "number=$number" >> "$GITHUB_OUTPUT" ``` Notes: `gh api --paginate ... | jq -r --arg t ...` — the exact-title filter is a **standalone `jq`** consuming the paginated `gh api` output. `gh api`'s own `--jq` takes only a jq-program string and rejects `--arg`, so the variable injection must be on the piped `jq`. `--paginate` ensures the match scans all open issues, not just page 1. -- [ ] 9.3 The stuck-failure issue comment is already emitted inside the readiness-wait step (Task 8.5, using the local `bottleneck` shell variable and `steps.readiness.outputs.number`). No separate step is needed; confirm the readiness-wait step's `env:` block carries `GH_TOKEN: ${{ github.token }}` and `REPO: ${{ github.repository }}` (it already does, current lines 60-63) so `gh issue comment` authenticates. +- [ ] 9.3 The stuck-failure issue comment is already emitted inside the readiness-wait step (Task 8.5, using the local `bottleneck` shell variable and `steps.readiness.outputs.number`). No separate step is needed; confirm the readiness-wait step's `env:` block carries GH_TOKEN: ${{ github.token }} and REPO: ${{ github.repository }} (it already does, current lines 60-63) so `gh issue comment` authenticates. - [ ] 9.4 On **success**, tick all rows and close the issue. Append to the `Promote release` step (after the PATCH at current line 163-164): ```bash all_ready_body=$'Tracking install-channel readiness for **'"$TAG"$'**.\n\n- [x] GitHub Release assets\n- [x] PyPI: pythinker-code\n- [x] PyPI: pinned sub-packages (core/host/review)\n- [ ] Homebrew tap (best-effort)\n- [ ] Site version.json (best-effort)' diff --git a/tests/conftest.py b/tests/conftest.py index 70a70560..ee7d2eb6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """Test configuration and fixtures.""" +# ruff: noqa: E402 -- terminal-capability env must be pinned before pythinker imports from __future__ import annotations @@ -9,6 +10,31 @@ from contextlib import contextmanager from pathlib import Path +# Pin a capable terminal for the whole test session BEFORE any pythinker import +# runs, because ``ui.shell.glyphs`` freezes its glyph constants from +# ``terminal_capabilities`` at module load. CI runs with ``NO_COLOR=1`` and +# ``TERM=dumb`` to keep build logs clean; left untouched those would degrade +# every renderer to ASCII glyphs and stripped color, breaking the UI-contract +# tests that assert the rich appearance. The degraded fallbacks themselves are +# covered explicitly by tests/ui_and_conv/test_terminal_capabilities.py, which +# sets these vars per-test and reloads. (Don't set PYTHINKER_TUI_GLYPHS=unicode +# here: that mode short-circuits the TERM=dumb check and would break the +# fallback tests; popping it and relying on TERM != dumb + UTF-8 stdout is +# enough.) +for _capability_var in ( + "NO_COLOR", + "PYTHINKER_NO_COLOR", + "CLICOLOR", + "PYTHINKER_TUI_GLYPHS", + "PYTHINKER_ASCII_UI", + "PYTHINKER_SAFE_GLYPHS", + "PYTHINKER_REDUCED_MOTION", + "PYTHINKER_NO_ANIMATION", + "PYTHINKER_STATIC_OUTPUT", +): + os.environ.pop(_capability_var, None) +os.environ["TERM"] = "xterm-256color" + import pytest from pydantic import SecretStr from pythinker_core.chat_provider.mock import MockChatProvider From 5b7b0c6b3308510d7a704b22a0458ce7157bb703 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 16:57:02 -0400 Subject: [PATCH 11/16] fix: address CodeRabbit review (null guard, ASCII glyphs, annotations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slash.py: guard provider.api_key (SecretStr | None) before get_secret_value() to avoid AttributeError when no API key is configured. - mcp_status.py + glyphs.py: add an ASCII-aware LIST_BULLET glyph and use it for the MCP startup/inventory bullets instead of a hardcoded "•", so they degrade with the rest of the TUI under TERM=dumb / legacy code pages. - _blocks.py: build changed-paths via a list comprehension (PERF401). - test_update_cli.py, test_update_orchestrator.py: add -> None on the _isolate_update_files helpers. - p0-quick-wins plan: scope the release-bot App secrets to --visibility selected --repos pythinker-code instead of org-wide. --- ...-31-release-orchestration-p0-quick-wins.md | 6 ++++-- src/pythinker_code/ui/shell/glyphs.py | 4 ++++ src/pythinker_code/ui/shell/mcp_status.py | 21 ++++++++++++------- src/pythinker_code/ui/shell/slash.py | 4 +++- .../ui/shell/visualize/_blocks.py | 9 ++++---- tests/cli/test_update_cli.py | 2 +- tests/ui_and_conv/test_update_orchestrator.py | 2 +- 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md index 60003ee4..916d7cbd 100644 --- a/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p0-quick-wins.md @@ -18,10 +18,12 @@ These touch admin/secrets/outward-facing services. They are **operator actions** - *Why an App and not the PAT:* `homebrew-pythinker` is public, `pythinker-home` is private; a dedicated org App contains a leak to one trust domain, survives member/org changes, and mints ~1h tokens per run (§4). - [ ] **OP-2 — Install the App on `pythinker-home` ONLY.** App page → **Install App** → TechMatrix-labs → **Only select repositories** → `pythinker-home` → Install. Verify it is NOT installed on any other repo. - [ ] **OP-3 — Set the org secrets** (run from a shell where `gh auth status` shows an org-admin token): + ```bash - gh secret set PYTHINKER_RELEASE_BOT_APP_ID --org TechMatrix-labs --visibility all --body "" - gh secret set PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY --org TechMatrix-labs --visibility all < /path/to/pythinker-release-bot.private-key.pem + gh secret set PYTHINKER_RELEASE_BOT_APP_ID --org TechMatrix-labs --visibility selected --repos pythinker-code --body "" + gh secret set PYTHINKER_RELEASE_BOT_APP_PRIVATE_KEY --org TechMatrix-labs --visibility selected --repos pythinker-code < /path/to/pythinker-release-bot.private-key.pem ``` + Expected: `✓ Set Organization secret PYTHINKER_RELEASE_BOT_APP_ID` (and `_PRIVATE_KEY`). Then `rm /path/to/pythinker-release-bot.private-key.pem` (the key lives only in the secret now). - [ ] **OP-4 — Confirm the live site host runs Dokploy build-from-source, not the Docker-Compose/Watchtower stack.** Required before Task 14 (deploy retirement). Check the Dokploy dashboard / server: the site is built from source via nixpacks (`bun run server.ts`), and there is no running `watchtower`/`traefik` compose stack for it. If you cannot confirm, **skip Task 14** and log it under "Out of scope / deferred" — it is reversible (`git rm`) and not on the release path. - [ ] **OP-5 — (DEFERRED, post-verification) Delete the retired PATs.** Do these only after the gated green cycle in "Phase verification": diff --git a/src/pythinker_code/ui/shell/glyphs.py b/src/pythinker_code/ui/shell/glyphs.py index 2a786ac8..781e1697 100644 --- a/src/pythinker_code/ui/shell/glyphs.py +++ b/src/pythinker_code/ui/shell/glyphs.py @@ -52,6 +52,9 @@ TRANSCRIPT_ACTIVE_MARKER: Final = REDUCED_MOTION_GLYPH #: Transcript gutter marker for tool results. TRANSCRIPT_TOOL_GUTTER: Final = "|" if _ASCII_GLYPHS else "⎿" +#: List/detail bullet (U+2022) used in status panels; falls back to an asterisk +#: under ASCII mode so legacy code pages and ``TERM=dumb`` stay clean. +LIST_BULLET: Final = "*" if _ASCII_GLYPHS else "•" __all__ = [ "SPINNER_FRAMES", @@ -68,4 +71,5 @@ "TRANSCRIPT_STATUS_MARKER", "TRANSCRIPT_ACTIVE_MARKER", "TRANSCRIPT_TOOL_GUTTER", + "LIST_BULLET", ] diff --git a/src/pythinker_code/ui/shell/mcp_status.py b/src/pythinker_code/ui/shell/mcp_status.py index 1e0ddc02..38e28e09 100644 --- a/src/pythinker_code/ui/shell/mcp_status.py +++ b/src/pythinker_code/ui/shell/mcp_status.py @@ -8,12 +8,13 @@ from rich.text import Text from pythinker_code.ui.shell.components.render_utils import sanitize_ansi +from pythinker_code.ui.shell.glyphs import LIST_BULLET from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import get_mcp_prompt_colors, tui_rich_style from pythinker_code.wire.types import MCPServerSnapshot, MCPStatusSnapshot _STARTING_STATUSES = frozenset({"pending", "connecting"}) -_MCP_STARTUP_GLYPH = "•" +_MCP_STARTUP_GLYPH = LIST_BULLET def _safe_text(text: str) -> str: @@ -78,14 +79,18 @@ def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType: servers = sorted(snapshot.servers, key=lambda server: server.name) if not servers: - renderables.append(Text(" • No MCP servers configured.", style=Style(italic=True))) + renderables.append( + Text(f" {LIST_BULLET} No MCP servers configured.", style=Style(italic=True)) + ) renderables.append( Text(" See the MCP docs to configure them.", style=tui_rich_style("muted")) ) return Group(*renderables) if snapshot.tools == 0: - renderables.append(Text(" • No MCP tools available.", style=Style(italic=True))) + renderables.append( + Text(f" {LIST_BULLET} No MCP tools available.", style=Style(italic=True)) + ) renderables.append(Text("")) for server in servers: @@ -108,22 +113,22 @@ def _server_inventory_lines(server: MCPServerSnapshot) -> list[RenderableType]: status = _safe_text(server.status) status_style = _status_color(server.status) server_name = _safe_text(server.name) - lines: list[RenderableType] = [Text.assemble(" • ", (server_name, status_style))] - lines.append(Text.assemble(" • Status: ", (status, status_style))) + lines: list[RenderableType] = [Text.assemble(f" {LIST_BULLET} ", (server_name, status_style))] + lines.append(Text.assemble(f" {LIST_BULLET} Status: ", (status, status_style))) if server.status == "unauthorized": lines.append( Text( - f" • Auth: Not authorized - run: pythinker mcp auth {server_name}", + f" {LIST_BULLET} Auth: Not authorized - run: pythinker mcp auth {server_name}", style=tui_rich_style("muted"), ) ) tool_names = sorted(_safe_text(tool_name) for tool_name in server.tools) if tool_names: - lines.append(Text.assemble(" • Tools: ", ", ".join(tool_names))) + lines.append(Text.assemble(f" {LIST_BULLET} Tools: ", ", ".join(tool_names))) else: - lines.append(Text(" • Tools: (none)")) + lines.append(Text(f" {LIST_BULLET} Tools: (none)")) return lines diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index 4d378811..e90b97ec 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -568,7 +568,9 @@ def _feedback_destination(soul: PythinkerSoul) -> tuple[str, dict[str, str]] | N return None if provider.custom_headers: headers.update(provider.custom_headers) - api_key = provider.api_key.get_secret_value() + api_key: str | None = None + if provider.api_key is not None: + api_key = provider.api_key.get_secret_value() if provider.oauth is not None: api_key = soul.runtime.oauth.resolve_api_key(provider.api_key, provider.oauth) if api_key: diff --git a/src/pythinker_code/ui/shell/visualize/_blocks.py b/src/pythinker_code/ui/shell/visualize/_blocks.py index 6aaf3744..314708eb 100644 --- a/src/pythinker_code/ui/shell/visualize/_blocks.py +++ b/src/pythinker_code/ui/shell/visualize/_blocks.py @@ -572,10 +572,11 @@ def _record_finished_subagent_call( def _changed_paths_from_sub_call( self, sub_tool_call: ToolCall, result: ToolReturnValue ) -> list[str]: - paths: list[str] = [] - for block in getattr(result, "display", []) or []: - if isinstance(block, DiffDisplayBlock): - paths.append(block.path) + paths: list[str] = [ + block.path + for block in getattr(result, "display", []) or [] + if isinstance(block, DiffDisplayBlock) + ] if paths: return paths diff --git a/tests/cli/test_update_cli.py b/tests/cli/test_update_cli.py index ce09f1fe..4a77905f 100644 --- a/tests/cli/test_update_cli.py +++ b/tests/cli/test_update_cli.py @@ -8,7 +8,7 @@ from pythinker_code.ui.shell import update_orchestrator as orchestrator -def _isolate_update_files(monkeypatch, tmp_path): +def _isolate_update_files(monkeypatch, tmp_path) -> None: monkeypatch.setattr(orchestrator, "UPDATE_STATUS_FILE", tmp_path / "update_status.json") monkeypatch.setattr(orchestrator, "UPDATE_LOG_FILE", tmp_path / "update.log") monkeypatch.setattr(orchestrator, "UPDATE_LOCK_FILE", tmp_path / "update.lock") diff --git a/tests/ui_and_conv/test_update_orchestrator.py b/tests/ui_and_conv/test_update_orchestrator.py index 2e2ee122..083fdbc5 100644 --- a/tests/ui_and_conv/test_update_orchestrator.py +++ b/tests/ui_and_conv/test_update_orchestrator.py @@ -11,7 +11,7 @@ from pythinker_code.ui.shell import update_orchestrator as orchestrator -def _isolate_update_files(monkeypatch, tmp_path): +def _isolate_update_files(monkeypatch, tmp_path) -> None: monkeypatch.setattr(orchestrator, "UPDATE_STATUS_FILE", tmp_path / "update_status.json") monkeypatch.setattr(orchestrator, "UPDATE_LOG_FILE", tmp_path / "update.log") monkeypatch.setattr(orchestrator, "UPDATE_LOCK_FILE", tmp_path / "update.lock") From 6fd3899d22c2cb0a24de8bab258a632f4cd5bff8 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 17:00:55 -0400 Subject: [PATCH 12/16] docs: use repo-relative paths in release-orchestration design spec --- .../2026-05-31-release-orchestration-design.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-05-31-release-orchestration-design.md b/docs/superpowers/specs/2026-05-31-release-orchestration-design.md index a40d1b94..414064f2 100644 --- a/docs/superpowers/specs/2026-05-31-release-orchestration-design.md +++ b/docs/superpowers/specs/2026-05-31-release-orchestration-design.md @@ -317,12 +317,12 @@ Add a **`.sha256` sidecar existence check** (right-sized: defense-in-depth for t C1–C5 confirmed complete: C1 (release.py opens `release/X.Y.Z` PR, human tags post-merge); C2 (existing merge-gate hook); C3 (release.py is CI tooling, shipped agent gains zero deps); C4 (version-less new-channel snippets + lockstep-enforced README strings, bump in same change set); C5 (`## Unreleased`→`## X.Y.Z (DATE)` preserves body, contributor footer addendum only). **Files referenced (absolute paths):** -- `/home/ai/Projects/pythinker-code-main/pyproject.toml` (SSOT `:3`; pins `:28` core, `:47` host, `:48` review; workspace `:89`; sources `:97`) -- `/home/ai/Projects/pythinker-code-main/scripts/check_pythinker_dependency_versions.py` (`:66-67` core+host only — extend for review) -- `/home/ai/Projects/pythinker-code-main/.github/workflows/promote-release.yml` (`:95` PyPI poll, `:126-129` homebrew gate, `:152` exit/prerelease, `:168-186` dispatch PAT, `:172-174` silent skip, `:188-200` Slack) -- `/home/ai/Projects/pythinker-code-main/.github/workflows/dispatch-pythinker-home-sync.yml` (`:16-17` watched paths, `:28` PAT, `:30` sends `github.sha`) -- `/home/ai/Projects/pythinker-code-main/.github/workflows/changelog-entry-required.yml` (`:54` title skip, `:57` branch skip) -- `/home/ai/Projects/pythinker-code-main/.github/workflows/release-pythinker-core.yml` (`:99-127` pdoc gh-pages step to retire) -- `/home/ai/Projects/pythinker-code-main/flake.nix` (`:129` `packages.default`; NO `apps` stanza) -- `/home/ai/Projects/pythinker-code-main/scripts/install.ps1` (`:144` per_page=20, `:152` prerelease skip, `:158` asset-pair guard) +- `pyproject.toml` (SSOT `:3`; pins `:28` core, `:47` host, `:48` review; workspace `:89`; sources `:97`) +- `scripts/check_pythinker_dependency_versions.py` (`:66-67` core+host only — extend for review) +- `.github/workflows/promote-release.yml` (`:95` PyPI poll, `:126-129` homebrew gate, `:152` exit/prerelease, `:168-186` dispatch PAT, `:172-174` silent skip, `:188-200` Slack) +- `.github/workflows/dispatch-pythinker-home-sync.yml` (`:16-17` watched paths, `:28` PAT, `:30` sends `github.sha`) +- `.github/workflows/changelog-entry-required.yml` (`:54` title skip, `:57` branch skip) +- `.github/workflows/release-pythinker-core.yml` (`:99-127` pdoc gh-pages step to retire) +- `flake.nix` (`:129` `packages.default`; NO `apps` stanza) +- `scripts/install.ps1` (`:144` per_page=20, `:152` prerelease skip, `:158` asset-pair guard) - `/home/ai/Projects/pythinker-site/site/scripts/sync-upstream-products.ts` (`:82-83`/`:98-99` per-product owner config, `:366` hardcoded `replaceAll` literal) From ea90cbe322bd66b9761632ead7f5f0af9ccae37d Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 17:02:55 -0400 Subject: [PATCH 13/16] docs: address CodeRabbit doc nits (relative paths, telemetry wording, fence langs, plan note) --- README.md | 2 +- .../plans/2026-05-31-welcome-banner-redesign.md | 9 +++++++++ .../specs/2026-05-31-release-orchestration-design.md | 4 ++-- .../specs/2026-05-31-welcome-banner-redesign-design.md | 4 ++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4c3f6f1a..c422953e 100644 --- a/README.md +++ b/README.md @@ -802,7 +802,7 @@ Distributed under the **Apache-2.0 License**. See [`LICENSE`](https://github.com [🔌 MCP](https://modelcontextprotocol.io/)

- Thanks for visiting ✨ Pythinker! This README does not collect user-level telemetry. + Thanks for visiting ✨ Pythinker! Viewing this README does not trigger telemetry.

diff --git a/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md b/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md index a621a84e..b0ef0f0f 100644 --- a/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md +++ b/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md @@ -125,6 +125,15 @@ git commit -m "test: lock welcome banner redesign (footer chip, no pipe, split s ## Task 2: Rewrite `_print_welcome_info` to the footer-chip layout +> **Note (post-implementation):** the shipped `_print_welcome_info` evolved +> beyond the skeleton below. The source is the source of truth; it adds +> responsive width handling (`_welcome_panel_width`, `_WELCOME_MAX_WIDTH`, +> `_WELCOME_PANEL_CHROME_WIDTH`), cell-aware truncation helpers +> (`_truncate_middle_to_width`, `_welcome_value`, `_welcome_tip_lines`), a +> `Table.grid(...)` build, and a `width >= 68` logo-beside-text vs stacked +> branch. Read the block below as the original footer-chip intent, not the +> literal final code. + **Files:** - Modify: `src/pythinker_code/ui/shell/__init__.py` (function `_print_welcome_info`, lines ~1934–2008; constant `_PYTHINKER_BORDER`, line 1864) diff --git a/docs/superpowers/specs/2026-05-31-release-orchestration-design.md b/docs/superpowers/specs/2026-05-31-release-orchestration-design.md index 414064f2..a58d8f47 100644 --- a/docs/superpowers/specs/2026-05-31-release-orchestration-design.md +++ b/docs/superpowers/specs/2026-05-31-release-orchestration-design.md @@ -316,7 +316,7 @@ Add a **`.sha256` sidecar existence check** (right-sized: defense-in-depth for t C1–C5 confirmed complete: C1 (release.py opens `release/X.Y.Z` PR, human tags post-merge); C2 (existing merge-gate hook); C3 (release.py is CI tooling, shipped agent gains zero deps); C4 (version-less new-channel snippets + lockstep-enforced README strings, bump in same change set); C5 (`## Unreleased`→`## X.Y.Z (DATE)` preserves body, contributor footer addendum only). -**Files referenced (absolute paths):** +**Files referenced (repo-relative paths):** - `pyproject.toml` (SSOT `:3`; pins `:28` core, `:47` host, `:48` review; workspace `:89`; sources `:97`) - `scripts/check_pythinker_dependency_versions.py` (`:66-67` core+host only — extend for review) - `.github/workflows/promote-release.yml` (`:95` PyPI poll, `:126-129` homebrew gate, `:152` exit/prerelease, `:168-186` dispatch PAT, `:172-174` silent skip, `:188-200` Slack) @@ -325,4 +325,4 @@ C1–C5 confirmed complete: C1 (release.py opens `release/X.Y.Z` PR, human tags - `.github/workflows/release-pythinker-core.yml` (`:99-127` pdoc gh-pages step to retire) - `flake.nix` (`:129` `packages.default`; NO `apps` stanza) - `scripts/install.ps1` (`:144` per_page=20, `:152` prerelease skip, `:158` asset-pair guard) -- `/home/ai/Projects/pythinker-site/site/scripts/sync-upstream-products.ts` (`:82-83`/`:98-99` per-product owner config, `:366` hardcoded `replaceAll` literal) +- `../pythinker-site/site/scripts/sync-upstream-products.ts` (`:82-83`/`:98-99` per-product owner config, `:366` hardcoded `replaceAll` literal) diff --git a/docs/superpowers/specs/2026-05-31-welcome-banner-redesign-design.md b/docs/superpowers/specs/2026-05-31-welcome-banner-redesign-design.md index 1f61db4e..d485269d 100644 --- a/docs/superpowers/specs/2026-05-31-welcome-banner-redesign-design.md +++ b/docs/superpowers/specs/2026-05-31-welcome-banner-redesign-design.md @@ -25,7 +25,7 @@ Note: the terminal-art mockups below are illustrative, not pixel-perfect contrac ### Current (v0.27.0) -``` +```text ╭─ Pythinker Code v0.27.0 ───────────────────────────────────────────╮ │ ● ✦ What's new in v0.27.0 · /changelog │ │ │ │ @@ -47,7 +47,7 @@ adds visual noise to the info grid. ### Proposed ("Footer chip") -``` +```text ╭─ Pythinker Code v0.27.0 ─────────────────────────────────────────╮ │ │ │ ● │ From 0564e1f5b9b164adeb55d57261ef67ccfaeea8e0 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 17:36:08 -0400 Subject: [PATCH 14/16] fix(ci): resolve PR 37 review failures --- .github/workflows/homebrew-tap.yml | 2 +- .github/workflows/linux-installer.yml | 2 +- .github/workflows/promote-release.yml | 2 +- .github/workflows/release-pythinker-cli.yml | 2 +- .github/workflows/windows-installer.yml | 2 +- ...1-release-orchestration-p1-release-tool.md | 27 +++++++------------ ...-orchestration-p2-distribution-channels.md | 4 +-- .../2026-05-31-welcome-banner-redesign.md | 17 +++++++++--- ...2026-05-31-release-orchestration-design.md | 3 +++ src/pythinker_code/ui/shell/mcp_status.py | 9 +++---- src/pythinker_code/ui/shell/slash.py | 9 ++++--- .../ui/shell/update_orchestrator.py | 2 +- tests/cli/test_review_wrapper.py | 23 +++++++++++++++- tests/cli/test_secscan_wrapper.py | 10 +++++++ tests/core/test_export_cli.py | 2 +- tests/core/test_shell_mcp_status.py | 9 ++++--- tests/e2e/test_cli_error_output.py | 2 ++ tests/ui_and_conv/test_update_orchestrator.py | 6 +++-- 18 files changed, 88 insertions(+), 45 deletions(-) diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index 2e772ece..fa35c8c1 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -3,7 +3,7 @@ name: Update Homebrew tap on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: inputs: version: diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml index d6a62b44..cf8cb345 100644 --- a/.github/workflows/linux-installer.yml +++ b/.github/workflows/linux-installer.yml @@ -4,7 +4,7 @@ on: push: tags: # Same scheme as release-pythinker-cli.yml and windows-installer.yml. - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: inputs: version: diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index b2f72904..81693648 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -16,7 +16,7 @@ name: Promote release on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: inputs: tag: diff --git a/.github/workflows/release-pythinker-cli.yml b/.github/workflows/release-pythinker-cli.yml index 4dfe7b22..f5c061dc 100644 --- a/.github/workflows/release-pythinker-cli.yml +++ b/.github/workflows/release-pythinker-cli.yml @@ -3,7 +3,7 @@ name: Release (pythinker-code) on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]*.[0-9]*.[0-9]*" permissions: contents: write diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 1a407f96..3bfe71d8 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -4,7 +4,7 @@ on: push: tags: # Match the existing PyPI release workflow's tag scheme: v... - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: inputs: version: diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md index 827b8a08..d2c2a445 100644 --- a/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md @@ -37,7 +37,7 @@ There are no admin/secret/App actions in P1. - `tests/ui_and_conv/test_shell_update.py` — add the brew-unchanged + `PYTHINKER_MANAGED` regression tests (this is the file that actually imports `update`; `tests/test_release_update_pipeline.py` is workflow-text only and does NOT import `update`). - `tests/test_release_update_pipeline.py` — add a test asserting `changelog-entry-required.yml` skips on both the `chore(release)*` title (line 54) and the `release/*` head branch (line 57) — the skip-contract that `release.py.open_pr()` depends on. (Workflow-text file, the correct home for this assertion.) - `docs/en/release-notes/breaking-changes.md` — add a `## Unreleased` anchor (currently absent) so `release.py`'s heading promotion is uniform across all three changelog files. -- `.agents/skills/release/SKILL.md` — repoint the `update_files` (lines 22-25) and `uv_sync` (line 35) nodes at `python scripts/release.py`. +- `.agents/skills/release/SKILL.md` — repoint the `update_files` (lines 22-25) and `uv_sync` (line 35) nodes at `uv run python scripts/release.py`. --- @@ -140,24 +140,17 @@ There are no admin/secret/App actions in P1. ): ``` 6. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q` → `2 passed`. -7. - [ ] Update the CI caller. In `.github/workflows/ci-pythinker-cli.yml`, change the block at lines 253-256 from: +7. - [ ] Update the CI caller. In `.github/workflows/ci-pythinker-cli.yml`, change the existing dependency-check block to use the project-managed launcher and include review: ```yaml - python scripts/check_pythinker_dependency_versions.py \ - --root-pyproject pyproject.toml \ - --pythinker-core-pyproject packages/pythinker-core/pyproject.toml \ - --pythinker-host-pyproject packages/pythinker-host/pyproject.toml - ``` - to (append the review line; mind the trailing `\` on the host line): - ```yaml - python scripts/check_pythinker_dependency_versions.py \ + uv run python scripts/check_pythinker_dependency_versions.py \ --root-pyproject pyproject.toml \ --pythinker-core-pyproject packages/pythinker-core/pyproject.toml \ --pythinker-host-pyproject packages/pythinker-host/pyproject.toml \ --pythinker-review-pyproject packages/pythinker-review/pyproject.toml ``` 8. - [ ] Update the release caller. In `.github/workflows/release-pythinker-cli.yml`, apply the identical change to the block at lines 57-60 (same trailing-`\` addition on the host line + the new review line). -9. - [ ] Sanity-check the real workspace passes. `python scripts/check_pythinker_dependency_versions.py --root-pyproject pyproject.toml --pythinker-core-pyproject packages/pythinker-core/pyproject.toml --pythinker-host-pyproject packages/pythinker-host/pyproject.toml --pythinker-review-pyproject packages/pythinker-review/pyproject.toml` → `ok: pythinker-code dependencies match workspace package versions`. -10. - [ ] Lint the workflows. `uvx actionlint .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml` (if `actionlint` is unavailable, fall back to `python -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml`) → no output / exit 0. +9. - [ ] Sanity-check the real workspace passes. `uv run python scripts/check_pythinker_dependency_versions.py --root-pyproject pyproject.toml --pythinker-core-pyproject packages/pythinker-core/pyproject.toml --pythinker-host-pyproject packages/pythinker-host/pyproject.toml --pythinker-review-pyproject packages/pythinker-review/pyproject.toml` → `ok: pythinker-code dependencies match workspace package versions`. +10. - [ ] Lint the workflows. `uvx actionlint .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml` (if `actionlint` is unavailable, fall back to `uv run python -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml`) → no output / exit 0. 11. - [ ] Commit. `git add scripts/check_pythinker_dependency_versions.py tests/test_release_py.py .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml && git commit -m "feat(release): enforce pythinker-review pin in dependency check"` --- @@ -699,7 +692,7 @@ The git/gh/uv orchestration is genuine I/O and is verified by `--dry-run` + a re (No dead `text = ROOT_PYPROJECT` line — the anchor check is the `CHANGELOG_FILES` loop inside `validate()`.) 2. - [ ] Lint the module. `uv run ruff check scripts/release.py && uv run ruff format --check scripts/release.py` → exit 0 (run `uv run ruff format scripts/release.py` first if formatting fails). There should be zero F841/unused-variable findings. 3. - [ ] Confirm the unit tests still pass. `uv run pytest tests/test_release_py.py -q` → all pure-function tests pass (orchestration is not under pytest). -4. - [ ] Dry-run verification (no writes). On a clean tree synced to origin/main: `python scripts/release.py --set-version 0.28.0 --dry-run`. Expected: prints the `warning` only if Unreleased body is empty, then `[dry-run] would rewrite SSOT -> 0.28.0`, `[dry-run] git switch -c release/0.28.0`, ... `[dry-run] gh pr create ...`, and the tag-order block ending `git tag v0.28.0 && git push origin v0.28.0`. Confirm `git status --porcelain` is still empty afterward (dry-run wrote nothing). +4. - [ ] Dry-run verification (no writes). On a clean tree synced to origin/main: `uv run python scripts/release.py --set-version 0.28.0 --dry-run`. Expected: prints the `warning` only if Unreleased body is empty, then `[dry-run] would rewrite SSOT -> 0.28.0`, `[dry-run] git switch -c release/0.28.0`, ... `[dry-run] gh pr create ...`, and the tag-order block ending `git tag v0.28.0 && git push origin v0.28.0`. Confirm `git status --porcelain` is still empty afterward (dry-run wrote nothing). 5. - [ ] Commit. `git add scripts/release.py && git commit -m "feat(release): add 4-phase orchestration with uv lock + frozen-sync gate"` --- @@ -985,7 +978,7 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( to: ``` update_files: |md - Run `python scripts/release.py --set-version X.Y.Z [--bump-core A.B.C --bump-host A.B.C]`. + Run `uv run python scripts/release.py --set-version X.Y.Z [--bump-core A.B.C --bump-host A.B.C]`. It rewrites pyproject.toml:3, the sub-package pins, uv.lock, all three changelog files (preserving the authored Unreleased body), and the README/asset names from the single source of truth, then runs the local gates and opens the `release/X.Y.Z` PR. @@ -1012,7 +1005,7 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( Because every task committed to the single `p1/release-tool` branch (Task 1 onward), the required dep-check arg and both workflow-caller edits are atomic in one PR — there is no cherry-pick or stacked-PR reconciliation to do. 1. - [ ] Confirm the full local gate set is green before pushing. `uv run pytest tests/test_release_py.py tests/test_version_lockstep.py tests/ui_and_conv/test_shell_update.py tests/test_release_update_pipeline.py -q` → all pass; `uv run ruff check scripts/release.py tests/test_release_py.py tests/test_version_lockstep.py && uv run ruff format --check scripts/release.py tests/test_release_py.py tests/test_version_lockstep.py` → exit 0; `uv run pyright src/pythinker_code/ui/shell/update.py` → 0 errors. -2. - [ ] Confirm the workspace version checks pass exactly as CI will run them: `python scripts/check_pythinker_dependency_versions.py --root-pyproject pyproject.toml --pythinker-core-pyproject packages/pythinker-core/pyproject.toml --pythinker-host-pyproject packages/pythinker-host/pyproject.toml --pythinker-review-pyproject packages/pythinker-review/pyproject.toml` → `ok: pythinker-code dependencies match workspace package versions`. +2. - [ ] Confirm the workspace version checks pass exactly as CI will run them: `uv run python scripts/check_pythinker_dependency_versions.py --root-pyproject pyproject.toml --pythinker-core-pyproject packages/pythinker-core/pyproject.toml --pythinker-host-pyproject packages/pythinker-host/pyproject.toml --pythinker-review-pyproject packages/pythinker-review/pyproject.toml` → `ok: pythinker-code dependencies match workspace package versions`. 3. - [ ] Confirm the branch history is one coherent stack. `git log --oneline -8 p1/release-tool` shows the dep-check, release.py (validation/rewrites/promotion/asset/orchestration), lockstep, skip-contract, updater, and SKILL commits all on `p1/release-tool`. Push: `git push -u origin p1/release-tool`. 4. - [ ] Open the PR. `gh pr create --base main --head p1/release-tool --title "feat(release): release.py + version lockstep SSOT (P1)" --body "Adds scripts/release.py (4-phase SSOT release orchestrator), tests/test_version_lockstep.py (every-PR version guard), the pythinker-review dependency-check tuple (with both CI callers updated atomically), the changelog-workflow skip-contract assertion, and the PYTHINKER_MANAGED updater hook with a brew-unchanged regression test and a managed-channel rendered hint. No new agent runtime deps (C3)."` 5. - [ ] Wait for CI and CodeRabbit. Confirm required checks (`check`, `test`, `changelog`, `release-validate` as applicable) pass and the `CodeRabbit` commit status on the PR head SHA is `success` (C2) before merging. Read CodeRabbit's "Actionable comments" and resolve or surface them — do not merge past unresolved findings. Per the project CLAUDE.md / MEMORY note, reject a CodeRabbit camelCase-for-Python finding if one appears (false positive; codebase is snake_case). @@ -1026,12 +1019,12 @@ Because every task committed to the single `p1/release-tool` branch (Task 1 onwa **End-to-end rehearsal (the proof, no tag pushed):** -1. - [ ] On a clean tree synced to `origin/main`, run a real (non-dry-run) rehearsal to a throwaway version: `python scripts/release.py --set-version 0.28.0`. Expected: Phase 1 validates all three changelog anchors; Phase 2 rewrites the files + runs `uv lock`; Phase 3 runs all four gates (`check_version_tag`, the extended `check_pythinker_dependency_versions`, `uv sync --frozen --all-extras --all-packages`, `pytest tests/test_version_lockstep.py`) plus the `grep -qF` checks — all green; Phase 4 creates branch `release/0.28.0`, commits `chore(release): prepare 0.28.0`, pushes, and opens a PR, then prints `git tag v0.28.0 && git push origin v0.28.0`. +1. - [ ] On a clean tree synced to `origin/main`, run a real (non-dry-run) rehearsal to a throwaway version: `uv run python scripts/release.py --set-version 0.28.0`. Expected: Phase 1 validates all three changelog anchors; Phase 2 rewrites the files + runs `uv lock`; Phase 3 runs all four gates (`check_version_tag`, the extended `check_pythinker_dependency_versions`, `uv sync --frozen --all-extras --all-packages`, `pytest tests/test_version_lockstep.py`) plus the `grep -qF` checks — all green; Phase 4 creates branch `release/0.28.0`, commits `chore(release): prepare 0.28.0`, pushes, and opens a PR, then prints `git tag v0.28.0 && git push origin v0.28.0`. 2. - [ ] Prove the stress-test catch is covered: confirm `uv.lock` changed in the rehearsal commit (`git show --stat release/0.28.0 | grep uv.lock`) and that `uv sync --frozen --all-extras --all-packages` ran clean inside Phase 3 (no "lockfile out of date" error). This is the exact failure that would otherwise turn the release PR's own CI red. 3. - [ ] Confirm the documented exception held: `grep -n "version 0.28.0" docs/en/guides/getting-started.md` returns nothing — the `--version 0.27.0` flag example is unchanged (still `--version 0.27.0`), while `What's New in 0.28.0`, `pythinker-code==0.28.0`, and `PythinkerSetup-0.28.0.exe` are all present in their respective files. 4. - [ ] Confirm the skip-contract makes the rehearsal PR pass the changelog gate: the PR head branch is `release/0.28.0` and the title is `chore(release): prepare 0.28.0`, so `changelog-entry-required.yml` skips (matching the guards Task 9 asserts) and does not block on the now-empty `## Unreleased`. 5. - [ ] Tear down the rehearsal (no tag was pushed): `gh pr close release/0.28.0 --delete-branch` and `git switch main && git branch -D release/0.28.0` and `git push origin --delete release/0.28.0`. Verify `git log --oneline -1 origin/main` is untouched (C1: nothing reached main, no tag was created). -6. - [ ] Sub-package rehearsal (optional, validates `--bump-core`): `python scripts/release.py --set-version 0.28.0 --bump-core 1.2.0 --dry-run` → prints the ordered tag sequence (`pythinker-core-1.2.0` first, wait-for-PyPI note, then `v0.28.0`) and the intended pin rewrite `pythinker-core[contrib]==1.2.0`, writing nothing. +6. - [ ] Sub-package rehearsal (optional, validates `--bump-core`): `uv run python scripts/release.py --set-version 0.28.0 --bump-core 1.2.0 --dry-run` → prints the ordered tag sequence (`pythinker-core-1.2.0` first, wait-for-PyPI note, then `v0.28.0`) and the intended pin rewrite `pythinker-core[contrib]==1.2.0`, writing nothing. **Files relevant to this phase (absolute paths):** - `/home/ai/Projects/pythinker-code-main/scripts/release.py` diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md index 0f009349..7e9ad2c7 100644 --- a/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md @@ -191,7 +191,7 @@ name: Docker (GHCR) on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: inputs: version: @@ -830,7 +830,7 @@ name: Update Scoop bucket on: push: tags: - - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: inputs: version: diff --git a/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md b/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md index b0ef0f0f..aeae50a8 100644 --- a/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md +++ b/docs/superpowers/plans/2026-05-31-welcome-banner-redesign.md @@ -59,7 +59,6 @@ def test_welcome_chip_renders_in_footer_not_header(monkeypatch): assert "changelog" in lines[-1] assert all("changelog" not in ln for ln in lines[:3]) - def test_welcome_info_grid_has_no_pipe_separator(monkeypatch): from pythinker_code.ui.shell import WelcomeInfoItem @@ -76,7 +75,6 @@ def test_welcome_info_grid_has_no_pipe_separator(monkeypatch): assert dir_line.count("│") == 2 assert "/tmp/proj" in dir_line - def test_welcome_strapline_and_help_on_separate_lines(monkeypatch): console = Console(record=True, width=120, color_system=None) monkeypatch.setattr(shell_module, "console", console) @@ -96,6 +94,7 @@ def test_welcome_strapline_and_help_on_separate_lines(monkeypatch): - [ ] **Step 2: Run the new tests and confirm they fail against the current banner** Run: + ```bash uv run pytest tests/ui_and_conv/test_shell_welcome_info.py -q -k "footer or pipe_separator or separate_lines" ``` @@ -108,6 +107,7 @@ Expected: all three FAIL. - [ ] **Step 3: Confirm the 5 existing tests still pass (no regression introduced by the new tests)** Run: + ```bash uv run pytest tests/ui_and_conv/test_shell_welcome_info.py -q -k "not (footer or pipe_separator or separate_lines)" ``` @@ -131,8 +131,11 @@ git commit -m "test: lock welcome banner redesign (footer chip, no pipe, split s > `_WELCOME_PANEL_CHROME_WIDTH`), cell-aware truncation helpers > (`_truncate_middle_to_width`, `_welcome_value`, `_welcome_tip_lines`), a > `Table.grid(...)` build, and a `width >= 68` logo-beside-text vs stacked -> branch. Read the block below as the original footer-chip intent, not the -> literal final code. +> branch. For the final implementation, open +> `src/pythinker_code/ui/shell/__init__.py` and search for +> `_print_welcome_info` to inspect the responsive/truncation logic and +> logo layout. Read the block below as the original footer-chip intent, +> not the literal final code. **Files:** - Modify: `src/pythinker_code/ui/shell/__init__.py` (function `_print_welcome_info`, lines ~1934–2008; constant `_PYTHINKER_BORDER`, line 1864) @@ -236,6 +239,7 @@ Leave the surrounding `_LOGO_*` color constants and `_LOGO` exactly as they are. - [ ] **Step 3: Confirm `_PYTHINKER_BORDER` has no remaining references** Run: + ```bash grep -rn "_PYTHINKER_BORDER" src/ tests/ tests_e2e/ ``` @@ -245,6 +249,7 @@ Expected: no output (zero matches). If any match remains, you removed the consta - [ ] **Step 4: Run the full banner test file** Run: + ```bash uv run pytest tests/ui_and_conv/test_shell_welcome_info.py -q ``` @@ -299,6 +304,7 @@ for width in (80, 100, 120): ``` Run: + ```bash uv run python /tmp/verify_banner.py ``` @@ -308,6 +314,7 @@ Expected: the antenna (`●`/`│`) floats above the headline; headline/straplin - [ ] **Step 2: Lint the changed file** Run: + ```bash uv run ruff check src/pythinker_code/ui/shell/__init__.py ``` @@ -317,6 +324,7 @@ Expected: no errors. (Line-length limit is 100; the code blocks above are within - [ ] **Step 3: Type-check the changed file (project type gate)** Run: + ```bash uv run pyright src/pythinker_code/ui/shell/__init__.py ``` @@ -326,6 +334,7 @@ Expected: no new errors introduced by this change. If `pyright` is not the confi - [ ] **Step 4: Run the broader UI test directory to catch unexpected fallout** Run: + ```bash uv run pytest tests/ui_and_conv/ -q ``` diff --git a/docs/superpowers/specs/2026-05-31-release-orchestration-design.md b/docs/superpowers/specs/2026-05-31-release-orchestration-design.md index a58d8f47..bdd5f996 100644 --- a/docs/superpowers/specs/2026-05-31-release-orchestration-design.md +++ b/docs/superpowers/specs/2026-05-31-release-orchestration-design.md @@ -285,15 +285,18 @@ Add a **`.sha256` sidecar existence check** (right-sized: defense-in-depth for t ## 9. Phased Migration Roadmap ### P0 — Quick wins (low risk, high value; fixes F2 + the F1 sub-package race + the real 0.24.0 class) + **Scope:** (a) Create org App `pythinker-release-bot` + org secrets; migrate **both** dispatch files to App token **and** fail-loud-on-empty (auth fix + fail-loud fix are the same edit). (b) Receiver `if:` source-repo gate in `sync-upstream-products.yml`. (c) Remove dead core-pages gh-pages step + delete `PYTHINKER_CORE_PAGES_TOKEN`. (d) promote-release: **add the sub-package PyPI-existence blocking check (core+host+review)**, drop Homebrew from gate, per-channel bottleneck detail in the existing Slack job, `issues:write` + `release-readiness` issue, dispatch as separate `needs:promote` job, asset URLs from API `tag_name` only. (e) **Mode-B fix**: `public/version.json` emit (pythinker-home) + lockstep assertion over the existing per-product config + fix the sync-TS:366 literal + pin raw fetch to dispatched ref (tolerate tag OR sha); `git rm` the 3 dead **site** install.ps1 mirrors (keep code-repo source scripts). (f) `release-readiness-reconcile.yml` (drift + alert + issue close) — **lands before/with the Homebrew-gate removal in (d)**. (g) Installer backoff + `install.ps1` pagination fix. **Risk:** Low (CI/site only; no agent runtime change; no branch-protection change). **Reversibility:** High (keep PAT until both files green + one cycle; revert is a workflow revert). **Effort:** ~**3 days** across two repos + 3 PRs (code, code, home) — revised up from the optimistic ~1.5–2 day estimate, given the org App + 2 workflow rewrites + the new blocking check + receiver gate + `version.json` + reconcile workflow + per-product lockstep + 2 installer fixes. *Note:* P0 fixes BOTH 0.24.0 modes and the `pip install` race only because (d)'s sub-pkg check and (e)'s `version.json`/literal fix are included — App migration alone (a) covers neither Mode B nor the publish race. ### P1 — Structural (release tool + single-source; finishes F3) + **Scope:** `scripts/release.py` (stdlib + git/gh/uv) with all 4 phases incl. `uv lock` regen + `uv sync --frozen` local gate + tomlkit + tomllib parse-back; `tests/test_version_lockstep.py` covering **core+host+review pins**; extend `check_pythinker_dependency_versions.py` with the review tuple; getting-started.md:47 into column-C + test; ensure `## Unreleased` anchors; repoint release SKILL; document the `release/*` + `chore(release)` contract with `changelog-entry-required.yml`. Add `PYTHINKER_MANAGED` env to `update.py` + brew-regression test (prep for P2 channels). **Risk:** Low-medium (tool runs locally before any push; gates mirror CI; no workflow topology change). **Reversibility:** High (additive; manual bump still works if tool unused). **Effort:** ~2–3 days incl. tests + a dry-run release rehearsal. ### P2 — Breadth (new channels; broaden distribution) + **Scope, in order:** (1) Docker/GHCR (`Dockerfile` + `docker.yml`, ancestor-check, `GITHUB_TOKEN`); (2) Scoop (`scoop-pythinker` **org repo** + `pythinker-scoop-publisher` **org App** + `generate-manifest.py` polling the windows zip + `scoop-bucket.yml`); (3) Nix (`apps.default` net-new + `nix build .#default`/`nix run` CI + monthly `update-flake-lock` PR); (4) WinGet (manual `workflow_dispatch`, isolated PAT) — last; AUR deferred. Each new channel: version-less README snippet (C4), best-effort (never gates promote); Docker/Scoop/Nix set `PYTHINKER_MANAGED`, while WinGet's no-marker limitation is documented. **Risk:** Medium (new repos/Apps/secrets; all best-effort so they can't worsen F1). **Reversibility:** High (each channel is an independent additive workflow; delete to remove). **Effort:** Docker ~1 day, Scoop ~1.5 days, Nix ~0.5 day, WinGet ~0.5 day — adopt incrementally, one PR each. diff --git a/src/pythinker_code/ui/shell/mcp_status.py b/src/pythinker_code/ui/shell/mcp_status.py index 38e28e09..636d0f52 100644 --- a/src/pythinker_code/ui/shell/mcp_status.py +++ b/src/pythinker_code/ui/shell/mcp_status.py @@ -8,13 +8,12 @@ from rich.text import Text from pythinker_code.ui.shell.components.render_utils import sanitize_ansi -from pythinker_code.ui.shell.glyphs import LIST_BULLET +from pythinker_code.ui.shell.glyphs import LIST_BULLET, TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.motion import reduced_motion_enabled from pythinker_code.ui.theme import get_mcp_prompt_colors, tui_rich_style from pythinker_code.wire.types import MCPServerSnapshot, MCPStatusSnapshot _STARTING_STATUSES = frozenset({"pending", "connecting"}) -_MCP_STARTUP_GLYPH = LIST_BULLET def _safe_text(text: str) -> str: @@ -57,7 +56,7 @@ def mcp_startup_header(snapshot: MCPStatusSnapshot) -> str | None: def render_mcp_startup_text(snapshot: MCPStatusSnapshot, *, now: float | None = None) -> Text: """Render the animated MCP startup status used by live prompt/status areas.""" t = time.monotonic() if now is None else now - glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append( mcp_startup_header(snapshot) or "Starting MCP servers", @@ -102,7 +101,7 @@ def render_mcp_console(snapshot: MCPStatusSnapshot) -> RenderableType: def render_mcp_inventory_loading(*, now: float | None = None) -> RenderableType: t = time.monotonic() if now is None else now - glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " line = Text(f"{glyph} ", style=tui_rich_style("muted")) line.append("Loading MCP inventory", style=tui_rich_style("tool_title") + Style(bold=True)) line.append("…", style=tui_rich_style("muted")) @@ -140,7 +139,7 @@ def render_mcp_prompt(snapshot: MCPStatusSnapshot, *, now: float | None = None) colors = get_mcp_prompt_colors() t = time.monotonic() if now is None else now - glyph = _MCP_STARTUP_GLYPH if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " + glyph = TRANSCRIPT_ACTIVE_MARKER if reduced_motion_enabled() or int(t / 0.8) % 2 == 0 else " " prefix = f"{glyph} " return FormattedText([(colors.text, f"{prefix}{header}"), ("", "\n")]) diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index e90b97ec..f06e0a00 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -568,11 +568,12 @@ def _feedback_destination(soul: PythinkerSoul) -> tuple[str, dict[str, str]] | N return None if provider.custom_headers: headers.update(provider.custom_headers) - api_key: str | None = None - if provider.api_key is not None: - api_key = provider.api_key.get_secret_value() + provider_api_key = getattr(provider, "api_key", None) + if provider_api_key is None: + return None + api_key = provider_api_key.get_secret_value() if provider.oauth is not None: - api_key = soul.runtime.oauth.resolve_api_key(provider.api_key, provider.oauth) + api_key = soul.runtime.oauth.resolve_api_key(provider_api_key, provider.oauth) if api_key: headers["Authorization"] = f"Bearer {api_key}" if not any(key.lower() == "authorization" and value for key, value in headers.items()): diff --git a/src/pythinker_code/ui/shell/update_orchestrator.py b/src/pythinker_code/ui/shell/update_orchestrator.py index 903010c4..cbf928fc 100644 --- a/src/pythinker_code/ui/shell/update_orchestrator.py +++ b/src/pythinker_code/ui/shell/update_orchestrator.py @@ -379,9 +379,9 @@ async def run_update_job( append_update_log(smoke_message) if smoke_ok: message = smoke_message + _write_last_success(job_id=job_id, message=message) else: message = f"Updated, but smoke check did not pass: {smoke_message}" - _write_last_success(job_id=job_id, message=message) write_update_status( _new_status( diff --git a/tests/cli/test_review_wrapper.py b/tests/cli/test_review_wrapper.py index cb6e2fa4..325bb033 100644 --- a/tests/cli/test_review_wrapper.py +++ b/tests/cli/test_review_wrapper.py @@ -1,12 +1,25 @@ +import os import subprocess import pytest +def _cli_env() -> dict[str, str]: + env = os.environ.copy() + env["NO_COLOR"] = "1" + env["TERM"] = "dumb" + env["COLUMNS"] = "120" + return env + + @pytest.mark.parametrize("cmd", ["review", "secscan", "security-scan", "debug"]) def test_top_level_help_lists_command(cmd: str) -> None: proc = subprocess.run( - ["uv", "run", "pythinker", "--help"], check=True, capture_output=True, text=True + ["uv", "run", "pythinker", "--help"], + check=True, + capture_output=True, + text=True, + env=_cli_env(), ) assert cmd in proc.stdout @@ -17,6 +30,7 @@ def test_review_diff_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--with-security" in proc.stdout assert "--mode" in proc.stdout @@ -43,6 +57,7 @@ def test_review_artifact_help_works(command: str) -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--budget-chars" in proc.stdout assert "--timeout-s" in proc.stdout @@ -62,6 +77,7 @@ def test_review_local_parity_help_works(command: str, expected: str) -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert expected in proc.stdout @@ -72,6 +88,7 @@ def test_review_help_docs_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--docs-path" in proc.stdout assert "--root-readme" in proc.stdout @@ -83,6 +100,7 @@ def test_review_compliance_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--checklist" in proc.stdout assert "--ticket-file" in proc.stdout @@ -94,6 +112,7 @@ def test_security_scan_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "scan" in proc.stdout assert "process" in proc.stdout @@ -112,6 +131,7 @@ def test_standalone_security_scan_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "scan" in proc.stdout assert "process" in proc.stdout @@ -123,5 +143,6 @@ def test_debug_failure_help_works() -> None: check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--command" in proc.stdout diff --git a/tests/cli/test_secscan_wrapper.py b/tests/cli/test_secscan_wrapper.py index eda444cf..9cfa0675 100644 --- a/tests/cli/test_secscan_wrapper.py +++ b/tests/cli/test_secscan_wrapper.py @@ -1,11 +1,21 @@ +import os import subprocess +def _cli_env() -> dict[str, str]: + env = os.environ.copy() + env["NO_COLOR"] = "1" + env["TERM"] = "dumb" + env["COLUMNS"] = "120" + return env + + def test_secscan_diff_help_works() -> None: proc = subprocess.run( ["uv", "run", "pythinker", "secscan", "diff", "--help"], check=True, capture_output=True, text=True, + env=_cli_env(), ) assert "--fail-on" in proc.stdout diff --git a/tests/core/test_export_cli.py b/tests/core/test_export_cli.py index 1d2f3495..4dd23fc8 100644 --- a/tests/core/test_export_cli.py +++ b/tests/core/test_export_cli.py @@ -199,7 +199,7 @@ def test_export_previous_session_errors_when_missing( def test_export_help_is_leaf_command() -> None: - result = CliRunner().invoke(cli, ["export", "--help"]) + result = CliRunner().invoke(cli, ["export", "--help"], color=False) assert result.exit_code == 0, result.output assert "Usage: root export [OPTIONS] [SESSION_ID]" in result.output diff --git a/tests/core/test_shell_mcp_status.py b/tests/core/test_shell_mcp_status.py index 84fecac4..2a101628 100644 --- a/tests/core/test_shell_mcp_status.py +++ b/tests/core/test_shell_mcp_status.py @@ -2,6 +2,7 @@ from rich.console import Console +from pythinker_code.ui.shell.glyphs import TRANSCRIPT_ACTIVE_MARKER from pythinker_code.ui.shell.mcp_status import ( mcp_startup_header, render_mcp_console, @@ -42,12 +43,14 @@ def test_render_mcp_servers_shows_pythinker_x_loading_design() -> None: prompt_text = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.0)) prompt_text_off = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.9)) assert prompt_text != prompt_text_off - assert prompt_text.startswith("• ") + assert prompt_text.startswith(f"{TRANSCRIPT_ACTIVE_MARKER} ") assert "Starting MCP servers (0/2): chrome-devtools, context7" in prompt_text assert "resolve-library-id" not in prompt_text startup_text = render_mcp_startup_text(snapshot, now=0.0).plain - assert startup_text == "• Starting MCP servers (0/2): chrome-devtools, context7" + assert startup_text == ( + f"{TRANSCRIPT_ACTIVE_MARKER} Starting MCP servers (0/2): chrome-devtools, context7" + ) def test_render_mcp_prompt_respects_reduced_motion(monkeypatch) -> None: @@ -62,7 +65,7 @@ def test_render_mcp_prompt_respects_reduced_motion(monkeypatch) -> None: prompt_text = "".join(fragment[1] for fragment in render_mcp_prompt(snapshot, now=0.9)) - assert prompt_text.startswith("• ") + assert prompt_text.startswith(f"{TRANSCRIPT_ACTIVE_MARKER} ") def test_render_mcp_startup_header_for_single_server() -> None: diff --git a/tests/e2e/test_cli_error_output.py b/tests/e2e/test_cli_error_output.py index e6e79c22..d0efd85d 100644 --- a/tests/e2e/test_cli_error_output.py +++ b/tests/e2e/test_cli_error_output.py @@ -18,6 +18,8 @@ def _run_pythinker(args: list[str], *, share_dir: Path) -> subprocess.CompletedP env = os.environ.copy() env["PYTHINKER_SHARE_DIR"] = str(share_dir) # Stabilize rich/Click formatting across environments for snapshot tests. + env["NO_COLOR"] = "1" + env["TERM"] = "dumb" env["COLUMNS"] = "120" env["LINES"] = "40" # Run via `python -m` to avoid `uv run pythinker` build/progress output interfering with snapshots. diff --git a/tests/ui_and_conv/test_update_orchestrator.py b/tests/ui_and_conv/test_update_orchestrator.py index 083fdbc5..5ea3da7f 100644 --- a/tests/ui_and_conv/test_update_orchestrator.py +++ b/tests/ui_and_conv/test_update_orchestrator.py @@ -146,7 +146,9 @@ async def fake_do_update(*, print_output: bool, check_only: bool, output_callbac @pytest.mark.asyncio -async def test_update_job_keeps_success_when_post_install_smoke_check_fails(monkeypatch, tmp_path): +async def test_update_job_skips_success_marker_when_post_install_smoke_check_fails( + monkeypatch, tmp_path +): _isolate_update_files(monkeypatch, tmp_path) async def fake_do_update(*, print_output: bool, check_only: bool, output_callback=None): @@ -167,7 +169,7 @@ async def fake_do_update(*, print_output: bool, check_only: bool, output_callbac assert status.state is orchestrator.UpdateJobState.UPDATED assert status.result == "UPDATED" assert "smoke check did not pass" in (status.message or "").lower() - assert orchestrator.UPDATE_LAST_SUCCESS_FILE.exists() + assert not orchestrator.UPDATE_LAST_SUCCESS_FILE.exists() @pytest.mark.asyncio From 915a6808ec7433def4bfe9a96d2e971189ff40c9 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 17:52:29 -0400 Subject: [PATCH 15/16] test(cli): normalize export help output --- tests/core/test_export_cli.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/core/test_export_cli.py b/tests/core/test_export_cli.py index 4dd23fc8..754abb3f 100644 --- a/tests/core/test_export_cli.py +++ b/tests/core/test_export_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import re import zipfile from pathlib import Path @@ -15,6 +16,8 @@ from pythinker_code.session import Session from pythinker_code.wire.types import TextPart, TurnBegin +_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + @pytest.fixture def isolated_share_dir(monkeypatch, tmp_path: Path) -> Path: @@ -202,5 +205,6 @@ def test_export_help_is_leaf_command() -> None: result = CliRunner().invoke(cli, ["export", "--help"], color=False) assert result.exit_code == 0, result.output - assert "Usage: root export [OPTIONS] [SESSION_ID]" in result.output - assert "COMMAND [ARGS]..." not in result.output + output = _ANSI_RE.sub("", result.output) + assert "Usage: root export [OPTIONS] [SESSION_ID]" in output + assert "COMMAND [ARGS]..." not in output From 2fdb12bc5b93c133766b4f062658c399cdab8de5 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sun, 31 May 2026 18:04:46 -0400 Subject: [PATCH 16/16] docs(ci): tighten release tag filters --- .github/workflows/homebrew-tap.yml | 2 +- .github/workflows/linux-installer.yml | 2 +- .github/workflows/promote-release.yml | 2 +- .github/workflows/release-pythinker-cli.yml | 2 +- .github/workflows/windows-installer.yml | 2 +- ...1-release-orchestration-p1-release-tool.md | 60 +++++++++++++++++++ ...-orchestration-p2-distribution-channels.md | 14 ++--- 7 files changed, 72 insertions(+), 12 deletions(-) diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index fa35c8c1..cc5d25a6 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -3,7 +3,7 @@ name: Update Homebrew tap on: push: tags: - - "v[0-9]*.[0-9]*.[0-9]*" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: version: diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml index cf8cb345..cd89c47b 100644 --- a/.github/workflows/linux-installer.yml +++ b/.github/workflows/linux-installer.yml @@ -4,7 +4,7 @@ on: push: tags: # Same scheme as release-pythinker-cli.yml and windows-installer.yml. - - "v[0-9]*.[0-9]*.[0-9]*" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: version: diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 81693648..8f0f333f 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -16,7 +16,7 @@ name: Promote release on: push: tags: - - "v[0-9]*.[0-9]*.[0-9]*" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: tag: diff --git a/.github/workflows/release-pythinker-cli.yml b/.github/workflows/release-pythinker-cli.yml index f5c061dc..83655e68 100644 --- a/.github/workflows/release-pythinker-cli.yml +++ b/.github/workflows/release-pythinker-cli.yml @@ -3,7 +3,7 @@ name: Release (pythinker-code) on: push: tags: - - "v[0-9]*.[0-9]*.[0-9]*" + - "v+([0-9]).+([0-9]).+([0-9])" permissions: contents: write diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index 3bfe71d8..d67b5497 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -4,7 +4,7 @@ on: push: tags: # Match the existing PyPI release workflow's tag scheme: v... - - "v[0-9]*.[0-9]*.[0-9]*" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: version: diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md index d2c2a445..b749eecc 100644 --- a/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p1-release-tool.md @@ -53,6 +53,7 @@ There are no admin/secret/App actions in P1. 1. - [ ] Create the branch. `git switch -c p1/release-tool`. 2. - [ ] Write the failing test. Create `tests/test_release_py.py` with: + ```python from __future__ import annotations @@ -118,20 +119,26 @@ There are no admin/secret/App actions in P1. assert result.returncode == 1 assert "pythinker-review version mismatch" in result.stderr ``` + (The expected substring matches the script's existing `f"{name} version mismatch: ..."` error format at `check_pythinker_dependency_versions.py:82`.) 3. - [ ] Run it and see it fail. `uv run pytest tests/test_release_py.py -q` → both tests fail (non-zero exit because argparse rejects the unknown flag: `error: unrecognized arguments: --pythinker-review-pyproject`). 4. - [ ] Add the argparse flag. In `scripts/check_pythinker_dependency_versions.py`, after `parser.add_argument("--pythinker-host-pyproject", type=Path, required=True)` (line 47), add: + ```python parser.add_argument("--pythinker-review-pyproject", type=Path, required=True) ``` + 5. - [ ] Add the third tuple. Change the loop header (lines 65-68) from: + ```python for name, pyproject_path in ( ("pythinker-core", args.pythinker_core_pyproject), ("pythinker-host", args.pythinker_host_pyproject), ): ``` + to: + ```python for name, pyproject_path in ( ("pythinker-core", args.pythinker_core_pyproject), @@ -139,8 +146,10 @@ There are no admin/secret/App actions in P1. ("pythinker-review", args.pythinker_review_pyproject), ): ``` + 6. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q` → `2 passed`. 7. - [ ] Update the CI caller. In `.github/workflows/ci-pythinker-cli.yml`, change the existing dependency-check block to use the project-managed launcher and include review: + ```yaml uv run python scripts/check_pythinker_dependency_versions.py \ --root-pyproject pyproject.toml \ @@ -148,6 +157,7 @@ There are no admin/secret/App actions in P1. --pythinker-host-pyproject packages/pythinker-host/pyproject.toml \ --pythinker-review-pyproject packages/pythinker-review/pyproject.toml ``` + 8. - [ ] Update the release caller. In `.github/workflows/release-pythinker-cli.yml`, apply the identical change to the block at lines 57-60 (same trailing-`\` addition on the host line + the new review line). 9. - [ ] Sanity-check the real workspace passes. `uv run python scripts/check_pythinker_dependency_versions.py --root-pyproject pyproject.toml --pythinker-core-pyproject packages/pythinker-core/pyproject.toml --pythinker-host-pyproject packages/pythinker-host/pyproject.toml --pythinker-review-pyproject packages/pythinker-review/pyproject.toml` → `ok: pythinker-code dependencies match workspace package versions`. 10. - [ ] Lint the workflows. `uvx actionlint .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml` (if `actionlint` is unavailable, fall back to `uv run python -c "import yaml,sys; [yaml.safe_load(open(f)) for f in sys.argv[1:]]" .github/workflows/ci-pythinker-cli.yml .github/workflows/release-pythinker-cli.yml`) → no output / exit 0. @@ -162,6 +172,7 @@ There are no admin/secret/App actions in P1. - Test: `tests/test_release_py.py` 1. - [ ] Write the failing test. Append to `tests/test_release_py.py`: + ```python import importlib.util @@ -198,8 +209,10 @@ There are no admin/secret/App actions in P1. with pytest.raises(release_tool.ReleaseError): release_tool.assert_monotonic(current="0.27.0", target="0.26.0") ``` + 2. - [ ] Run and see it fail. `uv run pytest tests/test_release_py.py -q` → `ModuleNotFoundError`/import error (file does not exist yet). 3. - [ ] Create the module with the validation helpers + the shared changelog-path constant. Write `scripts/release.py`: + ```python #!/usr/bin/env python3 """Pythinker-code release orchestrator. @@ -270,6 +283,7 @@ There are no admin/secret/App actions in P1. raise ReleaseError(f"missing project.version in {pyproject_path}") return version ``` + 4. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q` → previous tests still pass + the 4 new ones pass. 5. - [ ] Commit. `git add scripts/release.py tests/test_release_py.py && git commit -m "feat(release): add release.py validation helpers"` @@ -282,6 +296,7 @@ There are no admin/secret/App actions in P1. - Test: `tests/test_release_py.py` 1. - [ ] Write the failing test. Append to `tests/test_release_py.py`: + ```python def test_set_root_version_rewrites_and_parses_back(tmp_path: Path) -> None: src = ( @@ -321,8 +336,10 @@ There are no admin/secret/App actions in P1. with pytest.raises(release_tool.ReleaseError): release_tool.set_dependency_pin(p, "pythinker-core", "1.2.0") ``` + 2. - [ ] Run and see it fail. `uv run pytest tests/test_release_py.py -q -k "set_root_version or set_dependency_pin"` → `AttributeError: module 'release_tool' has no attribute 'set_root_version'`. 3. - [ ] Implement the rewrites. Append to `scripts/release.py`: + ```python _DEP_PIN_RE = re.compile( r"^(?P[A-Za-z0-9._-]+)(?P\[[^\]]+\])?==(?P[^;\s]+)(?P.*)$" @@ -369,6 +386,7 @@ There are no admin/secret/App actions in P1. if expected is None or expected.split("==", 1)[1].split(";")[0].strip() != version: raise ReleaseError(f"parse-back failed: {name} pin in {pyproject_path} != {version}") ``` + 4. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q -k "set_root_version or set_dependency_pin"` → `3 passed`. 5. - [ ] Commit. `git add scripts/release.py tests/test_release_py.py && git commit -m "feat(release): add tomlkit version/pin rewrites with parse-back assertions"` @@ -382,6 +400,7 @@ There are no admin/secret/App actions in P1. - Test: `tests/test_release_py.py` 1. - [ ] Write the failing test. Append to `tests/test_release_py.py`: + ```python def test_promote_changelog_preserves_body_and_reinserts_unreleased(tmp_path: Path) -> None: src = ( @@ -421,8 +440,10 @@ There are no admin/secret/App actions in P1. with pytest.raises(release_tool.ReleaseError): release_tool.promote_changelog(p, "0.28.0", release_date="2026-06-01") ``` + 2. - [ ] Run and see it fail. `uv run pytest tests/test_release_py.py -q -k promote_changelog` → `AttributeError: ... 'promote_changelog'`. 3. - [ ] Implement promotion. Append to `scripts/release.py`: + ```python _UNRELEASED_RE = re.compile(r"^## Unreleased[ \t]*$", re.MULTILINE) @@ -444,8 +465,10 @@ There are no admin/secret/App actions in P1. ) path.write_text(new_text, encoding="utf-8") ``` + 4. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q -k promote_changelog` → `3 passed`. 5. - [ ] Add the missing `## Unreleased` anchor to breaking-changes.md so promotion is uniform across all three files. In `docs/en/release-notes/breaking-changes.md`, change lines 1-5 from: + ``` # Breaking changes and migration @@ -453,7 +476,9 @@ There are no admin/secret/App actions in P1. ## 0.27.0 (2026-05-31) ``` + to: + ``` # Breaking changes and migration @@ -463,6 +488,7 @@ There are no admin/secret/App actions in P1. ## 0.27.0 (2026-05-31) ``` + 6. - [ ] Commit. `git add scripts/release.py tests/test_release_py.py docs/en/release-notes/breaking-changes.md && git commit -m "feat(release): promote changelog Unreleased heading preserving body"` --- @@ -476,6 +502,7 @@ There are no admin/secret/App actions in P1. Rewrite **only** these patterns (everything else — including the `--version 0.27.0` flag examples at README:303 and getting-started.md:34 — is left untouched, per §3): the `## 🆕 What's New in X` heading, the `pythinker-code==X` pip snippet, `PythinkerSetup-X.Y.Z.exe`, `pythinker-code_X.Y.Z_.deb`, `pythinker-code-X.Y.Z..rpm`, and `/releases/download/vX.Y.Z/`. **Badges are a deliberate no-op:** the only version-bearing badge, the PyPI badge at README:12, is shields.io-live (`https://img.shields.io/pypi/v/pythinker-code...`) and the Python badge at README:13 is a `3.12%2B` requires-python floor — no badge carries a literal package version, so the contract's "badges" clause is satisfied by zero rewrites (a lockstep guard in Task 7 prevents future hardcoded-version-badge drift). 1. - [ ] Write the failing test. Append to `tests/test_release_py.py`: + ```python def test_rewrite_version_strings_targets_only_release_patterns() -> None: text = ( @@ -498,8 +525,10 @@ Rewrite **only** these patterns (everything else — including the `--version 0. assert "--version 0.27.0" in out assert "--version 0.28.0" not in out ``` + 2. - [ ] Run and see it fail. `uv run pytest tests/test_release_py.py -q -k rewrite_version_strings` → `AttributeError`. 3. - [ ] Implement targeted rewrites. Append to `scripts/release.py`: + ```python def rewrite_version_strings(text: str, *, old: str, new: str) -> str: """Replace ONLY release-pattern occurrences of `old` with `new`. @@ -526,6 +555,7 @@ Rewrite **only** these patterns (everything else — including the `--version 0. original = path.read_text(encoding="utf-8") path.write_text(rewrite_version_strings(original, old=old, new=new), encoding="utf-8") ``` + 4. - [ ] Run and see it pass. `uv run pytest tests/test_release_py.py -q -k rewrite_version_strings` → `1 passed`. 5. - [ ] Commit. `git add scripts/release.py tests/test_release_py.py && git commit -m "feat(release): pattern-targeted README/asset version rewrites"` @@ -540,6 +570,7 @@ Rewrite **only** these patterns (everything else — including the `--version 0. The git/gh/uv orchestration is genuine I/O and is verified by `--dry-run` + a rehearsal in "Phase verification". `--dry-run` runs Phase 1 (validate) + prints the intended rewrites and tag order, but writes no files, runs no `uv lock`, and creates no branch/PR. Note: `validate()` asserts the `## Unreleased` anchor in **all three** changelog files (via `CHANGELOG_FILES`) before any write, so a missing anchor in a docs file fails loud in Phase 1 and never leaves a partially-rewritten tree (atomic Phase-2 guarantee). 1. - [ ] Implement the phases + CLI. Append to `scripts/release.py`: + ```python def _run(cmd: list[str], *, dry_run: bool, check: bool = True) -> subprocess.CompletedProcess[str]: if dry_run: @@ -689,6 +720,7 @@ The git/gh/uv orchestration is genuine I/O and is verified by `--dry-run` + a re if __name__ == "__main__": raise SystemExit(main()) ``` + (No dead `text = ROOT_PYPROJECT` line — the anchor check is the `CHANGELOG_FILES` loop inside `validate()`.) 2. - [ ] Lint the module. `uv run ruff check scripts/release.py && uv run ruff format --check scripts/release.py` → exit 0 (run `uv run ruff format scripts/release.py` first if formatting fails). There should be zero F841/unused-variable findings. 3. - [ ] Confirm the unit tests still pass. `uv run pytest tests/test_release_py.py -q` → all pure-function tests pass (orchestration is not under pytest). @@ -706,6 +738,7 @@ The git/gh/uv orchestration is genuine I/O and is verified by `--dry-run` + a re This test is the CI safety net. It asserts only relationships true on every main commit (never "a tag exists"). The real tree was verified: CHANGELOG.md has `## 0.27.0 (` at line 20 and `## Unreleased` at line 16, so `test_changelog_has_dated_heading_for_version` is green as-is. 1. - [ ] Write the test as failing-by-construction first, then make it green against the real tree. Create `tests/test_version_lockstep.py`: + ```python from __future__ import annotations @@ -802,6 +835,7 @@ This test is the CI safety net. It asserts only relationships true on every main for found in flag_re.findall(text): assert re.fullmatch(SEMVER, found), found ``` + (No dead `v = re.escape(VERSION)` line — the asset test iterates `shape_res` over `SEMVER` and compares each capture to `VERSION` directly, so ruff F841 cannot fire.) 2. - [ ] Run against the real tree and see it pass. `uv run pytest tests/test_version_lockstep.py -q` → all pass (current repo: VERSION `0.27.0`, review pin `0.1.0`, asset names all `0.27.0`, flag examples `0.27.0` valid shape, no hardcoded-version badge). 3. - [ ] Lint the test (F841 guard). `uv run ruff check tests/test_version_lockstep.py` → exit 0 (proves no dead-assignment regression slipped in). @@ -820,6 +854,7 @@ This test is the CI safety net. It asserts only relationships true on every main Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff (line 98). The env read is the literal first logic of `_detect_upgrade_command()` so non-brew managed channels (Docker/Nix/Scoop/WinGet) short-circuit. Crucially, the marker return must be *consumed*, not rendered raw: there are exactly two call sites of `_detect_upgrade_command()` — `_update_prompt_text()` (615) and `do_update()` (1215). `_update_prompt_text` needs its own branch (else it renders `__pythinker_managed_channel__ docker` as the "Update method"); `do_update` needs an early-return placed **after detection (1215) but before the `_update_candidate_unavailable_reason` readiness gate (1216)** so a managed install does not mis-fire the PyPI-still-publishing check and never reaches the exec path. The managed early-return mirrors the existing native-can't-auto-update path (lines 1246-1252): print a manual-action hint and return `UpdateResult.UPDATE_AVAILABLE`. 1. - [ ] Write the failing tests. Append to `tests/ui_and_conv/test_shell_update.py`: + ```python def test_brew_unchanged_when_pythinker_managed_unset(monkeypatch): monkeypatch.delenv("PYTHINKER_MANAGED", raising=False) @@ -859,12 +894,16 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( assert "docker" in rendered assert update.MANAGED_CHANNEL_MARKER not in rendered ``` + 2. - [ ] Run and see it fail. `uv run pytest tests/ui_and_conv/test_shell_update.py -q -k "pythinker_managed or brew_unchanged or managed_channel_hint"` → `test_pythinker_managed_channel_short_circuits` fails (`AttributeError: ... MANAGED_CHANNEL_MARKER`); the others fail too because the constant does not exist at import time. 3. - [ ] Add the marker constant. In `src/pythinker_code/ui/shell/update.py`, after the existing `NATIVE_INSTALLER_MARKER = "__pythinker_native_installer__"` (line 61), add: + ```python MANAGED_CHANNEL_MARKER = "__pythinker_managed_channel__" ``` + 4. - [ ] Add the env read at the top of `_detect_upgrade_command()`. Change lines 95-99 from: + ```python def _detect_upgrade_command() -> list[str]: """Pick the right upgrade argv based on how this interpreter was installed.""" @@ -872,7 +911,9 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( if "/cellar/pythinker-code/" in exe or "/homebrew/cellar/pythinker-code/" in exe: return ["brew", "upgrade", "pythinker-code"] ``` + to: + ```python def _detect_upgrade_command() -> list[str]: """Pick the right upgrade argv based on how this interpreter was installed.""" @@ -887,8 +928,10 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( if "/cellar/pythinker-code/" in exe or "/homebrew/cellar/pythinker-code/" in exe: return ["brew", "upgrade", "pythinker-code"] ``` + (`os` is already imported at line 5; no new import.) 5. - [ ] Add the managed branch to `_update_prompt_text()`. Change lines 615-619 from: + ```python upgrade_command = _detect_upgrade_command() if upgrade_command == [NATIVE_INSTALLER_MARKER]: @@ -896,7 +939,9 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( else: update_method = _format_upgrade_command(upgrade_command) ``` + to: + ```python upgrade_command = _detect_upgrade_command() if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]: @@ -906,14 +951,18 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( else: update_method = _format_upgrade_command(upgrade_command) ``` + 6. - [ ] Add the managed early-return to `do_update()`. Change lines 1215-1218 from: + ```python upgrade_command = _detect_upgrade_command() unavailable_reason = await _update_candidate_unavailable_reason( session, latest_version, upgrade_command ) ``` + to (insert the early-return BEFORE the readiness gate, so a managed channel never mis-fires the PyPI check or the exec path): + ```python upgrade_command = _detect_upgrade_command() if upgrade_command[:1] == [MANAGED_CHANNEL_MARKER]: @@ -928,6 +977,7 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( session, latest_version, upgrade_command ) ``` + 7. - [ ] Run and see it pass. `uv run pytest tests/ui_and_conv/test_shell_update.py -q -k "pythinker_managed or brew_unchanged or managed_channel_hint"` → `4 passed`. 8. - [ ] Confirm no regression in the existing updater tests + types. `uv run pytest tests/ui_and_conv/test_shell_update.py -q` → all pass; `uv run pyright src/pythinker_code/ui/shell/update.py` → 0 errors (the file is in the `strict` set at `pyproject.toml:138`). 9. - [ ] Commit. `git add src/pythinker_code/ui/shell/update.py tests/ui_and_conv/test_shell_update.py && git commit -m "feat(update): add PYTHINKER_MANAGED channel hint with consumer handling; keep brew unchanged"` @@ -942,6 +992,7 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( `release.py.open_pr()` emits a branch named `release/X.Y.Z` and a PR title `chore(release): prepare X.Y.Z`. The `changelog-entry-required.yml` workflow must skip its "require a CHANGELOG entry" check for exactly that shape (title `chore(release)*` at line 54, head branch `release/*` at line 57), because a release-prep PR consumes `## Unreleased` into a dated block and resets it, which would otherwise read as a net removal and fail. This coupling is real and load-bearing, so it is asserted here (kept out of the version-string-focused lockstep test, per the punch-list). 1. - [ ] Write the test. Append to `tests/test_release_update_pipeline.py`: + ```python def test_changelog_workflow_skips_release_prep_prs() -> None: """release.py opens `release/X.Y.Z` PRs titled `chore(release): prepare X.Y.Z`. @@ -956,6 +1007,7 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( # Head-branch guard: release/* → skip. assert "release/*)" in wf, "missing release/* branch skip" ``` + 2. - [ ] Run against the real workflow and see it pass. `uv run pytest tests/test_release_update_pipeline.py -q -k changelog_workflow_skips_release_prep` → `1 passed` (the guards exist today at `changelog-entry-required.yml:54` and `:57`). 3. - [ ] Prove the guard bites (temporary mutation). Comment out the `release/*)` case line in `.github/workflows/changelog-entry-required.yml`, re-run the test → it FAILS. Restore the line (`git checkout .github/workflows/changelog-entry-required.yml`), re-run → passes. 4. - [ ] Commit. `git add tests/test_release_update_pipeline.py && git commit -m "test(release): assert changelog workflow skips release-prep PRs"` @@ -969,13 +1021,16 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( 1. - [ ] Read the current nodes. Use the Read tool on `.agents/skills/release/SKILL.md` lines 1-55 (covers the `---` front matter, the `update_files` node body at 22-25, and the `uv_sync: "Run uv sync."` line at 35 — both edited nodes are in view; prefer Read over sed per repo CLAUDE.md). 2. - [ ] Replace the manual-bump prose in `update_files`. Change the node body (lines 22-25) from: + ``` update_files: |md Update the relevant pyproject.toml (and rust/Cargo.toml if root version changes), CHANGELOG.md (keep the Unreleased header), and breaking-changes.md in both languages. | ``` + to: + ``` update_files: |md Run `uv run python scripts/release.py --set-version X.Y.Z [--bump-core A.B.C --bump-host A.B.C]`. @@ -985,14 +1040,19 @@ Brew must NOT set `PYTHINKER_MANAGED`; it keeps its existing cellar path-sniff ( There is no `--bump-review` (review is frozen at 0.1.0). | ``` + 3. - [ ] Update the `uv_sync` node. Change line 35 from: + ``` uv_sync: "Run uv sync." ``` + to: + ``` uv_sync: "release.py already runs `uv lock` + `uv sync --frozen --all-extras --all-packages` as Phase-2/3 steps; no separate uv sync needed." ``` + 4. - [ ] Verify the front matter + d2 graph still parse (no structural breakage): Read lines 1-6 → unchanged `---`/`name:`/`description:`/`type:` front matter, and confirm the edited node lines are still inside the ```` ```d2 ```` fenced block. 5. - [ ] Commit. `git add .agents/skills/release/SKILL.md && git commit -m "docs(release): repoint release skill at scripts/release.py"` diff --git a/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md b/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md index 7e9ad2c7..b0c6ab62 100644 --- a/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md +++ b/docs/superpowers/plans/2026-05-31-release-orchestration-p2-distribution-channels.md @@ -31,7 +31,7 @@ These touch org admin, secrets, and an external repo — they are **operator act ```bash gh repo create TechMatrix-labs/scoop-pythinker \ --public \ - --description "Scoop bucket for Pythinker Code. Auto-updated by pythinker-code/.github/workflows/scoop-bucket.yml on every v* tag. Do not hand-edit bucket/*." \ + --description "Scoop bucket for Pythinker Code. Auto-updated by pythinker-code/.github/workflows/scoop-bucket.yml on every semver release tag. Do not hand-edit bucket/*." \ --disable-wiki # Verify: gh repo view TechMatrix-labs/scoop-pythinker --json visibility,name -q '.name + " " + .visibility' @@ -88,10 +88,10 @@ gh api /orgs/TechMatrix-labs/packages?package_type=container --jq '.[].name' |---|---|---| | Create | `Dockerfile` | Thin `python:3.14-slim` image; `pip install pythinker-code==${V}` from PyPI; sets `PYTHINKER_MANAGED=docker`; entrypoint `pythinker`. | | Create | `.dockerignore` | Keep the build context tiny (the wheel comes from PyPI, not the repo). | -| Create | `.github/workflows/docker.yml` | On `push: tags: v*`: wait-for-PyPI, build amd64 + arm64 by digest, stitch manifest to `ghcr.io/techmatrix-labs/pythinker-code:`, advance `:latest` only when the release is promoted (non-prerelease) + ancestor-check. Best-effort. | +| Create | `.github/workflows/docker.yml` | On semver release tags (`v+([0-9]).+([0-9]).+([0-9])`): wait-for-PyPI, build amd64 + arm64 by digest, stitch manifest to `ghcr.io/techmatrix-labs/pythinker-code:`, advance `:latest` only when the release is promoted (non-prerelease) + ancestor-check. Best-effort. | | Create | `packages/scoop-bucket/generate-manifest.py` | Stdlib generator: poll the release, read the EXISTING Windows onedir zip + `.sha256`, render `bucket/pythinker-code.json`. Mirrors `packages/homebrew-tap/generate-formula.py`. | | Create | `packages/scoop-bucket/pythinker-code.json.tmpl` | Scoop manifest template with `__VERSION__`/`__URL__`/`__SHA256__` placeholders; `bin: pythinker\pythinker.exe`; `env_set: PYTHINKER_MANAGED=scoop`; version-less `autoupdate`. | -| Create | `.github/workflows/scoop-bucket.yml` | On `push: tags: v*` + `workflow_dispatch`: generate the manifest, mint `pythinker-scoop-publisher` token, git-push `bucket/pythinker-code.json` into `scoop-pythinker`. Mirrors `homebrew-tap.yml`. | +| Create | `.github/workflows/scoop-bucket.yml` | On semver release tags (`v+([0-9]).+([0-9]).+([0-9])`) + `workflow_dispatch`: generate the manifest, mint `pythinker-scoop-publisher` token, git-push `bucket/pythinker-code.json` into `scoop-pythinker`. Mirrors `homebrew-tap.yml`. | | Create | `tests/test_scoop_manifest.py` | Failing-test-first pytest for `generate-manifest.py` (mirrors `tests/test_homebrew_formula.py`). | | Modify | `flake.nix` | Add `apps.default` (`type=app`, `program=.../bin/pythinker`); add `--set PYTHINKER_MANAGED "nix"` to the `makeWrapper` installPhase. | | Modify | `.github/workflows/ci-pythinker-cli.yml` | Extend the existing `nix-test` job to also run `nix run .#default -- --version` (the `apps.default` smoke check). | @@ -179,7 +179,7 @@ git commit -m "feat(docker): thin python:3.14-slim image installing the publishe - [ ] 1. Create `.github/workflows/docker.yml`. Topology mirrors h-agent's `docker-publish.yml` (build-amd64 / build-arm64 / merge / move-latest), with three pythinker-specific changes the advisor flagged: (a) GHCR login with `GITHUB_TOKEN` (not Docker Hub); (b) a **wait-for-PyPI** pre-check before buildx (the wheel publishes in a parallel job and may 404); (c) `:latest` advances **only when the release is non-prerelease** (promote has flipped it) AND the ancestor-check passes. Lowercase image name is mandatory for GHCR. -> **Runbook note — `:latest` does NOT auto-advance after promotion.** `docker.yml` triggers only on `push: tags: v*` and `workflow_dispatch` — there is **no `release:` trigger**. At tag-push time the GitHub Release is still a prerelease (created prerelease by `release-pythinker-cli.yml`), so the `move-latest` gate evaluates `isPrerelease == true` and **skips** — `:latest` is intentionally NOT moved. `promote-release.yml` later flips the release to non-prerelease via a release *edit*, which does **not** re-fire `docker.yml`. Therefore a maintainer MUST manually re-dispatch after promotion to advance `:latest`: +> **Runbook note — `:latest` does NOT auto-advance after promotion.** `docker.yml` triggers only on semver release tags (`v+([0-9]).+([0-9]).+([0-9])`) and `workflow_dispatch` — there is **no `release:` trigger**. At tag-push time the GitHub Release is still a prerelease (created prerelease by `release-pythinker-cli.yml`), so the `move-latest` gate evaluates `isPrerelease == true` and **skips** — `:latest` is intentionally NOT moved. `promote-release.yml` later flips the release to non-prerelease via a release *edit*, which does **not** re-fire `docker.yml`. Therefore a maintainer MUST manually re-dispatch after promotion to advance `:latest`: > ```bash > gh workflow run docker.yml -f version=X.Y.Z # run AFTER promote flips vX.Y.Z to non-prerelease > ``` @@ -191,7 +191,7 @@ name: Docker (GHCR) on: push: tags: - - "v[0-9]*.[0-9]*.[0-9]*" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: version: @@ -830,7 +830,7 @@ name: Update Scoop bucket on: push: tags: - - "v[0-9]*.[0-9]*.[0-9]*" + - "v+([0-9]).+([0-9]).+([0-9])" workflow_dispatch: inputs: version: @@ -945,7 +945,7 @@ jobs: This bucket is auto-updated by the [scoop-bucket.yml](https://github.com/TechMatrix-labs/pythinker-code/blob/main/.github/workflows/scoop-bucket.yml) - workflow on every \`v*.*.*\` tag. Do not hand-edit \`bucket/*\` — your + workflow on every semver release tag. Do not hand-edit \`bucket/*\` — your edits will be overwritten on the next release. EOF fi