@groeponline/pi-wishcraft 1.16.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +52 -22
  2. package/CHANGELOG.md +65 -0
  3. package/CONTRIBUTING.md +12 -1
  4. package/RELEASE.md +142 -49
  5. package/bash-mode/completion.ts +2 -2
  6. package/build-stamp.json +1 -1
  7. package/docs/agents/domain.md +60 -0
  8. package/docs/agents/issue-tracker.md +88 -0
  9. package/docs/configuration.md +6 -1
  10. package/docs/design/regression-testing.md +1 -1
  11. package/docs/testing.md +81 -0
  12. package/package.json +17 -10
  13. package/src/config/settings-registry.ts +2 -1
  14. package/src/config/structural-preset-table.ts +0 -1
  15. package/src/extension/commands/commands.ts +0 -1
  16. package/src/extension/core/peer-guard.ts +7 -2
  17. package/src/extension/core/segment-context.ts +139 -44
  18. package/src/extension/core/state.ts +1 -0
  19. package/src/extension/core/types.ts +5 -0
  20. package/src/extension/queue/queue-integration.ts +20 -2
  21. package/src/extension/session/session-lifecycle.ts +24 -3
  22. package/src/extension/skills/inline-invocation.ts +1 -1
  23. package/src/extension/ui/custom-editor.ts +1 -5
  24. package/src/extension/ui/deck/config-diagnostic-lines.ts +0 -6
  25. package/src/extension/ui/ports-panel.ts +0 -1
  26. package/src/extension/ui/token-overlays.ts +1 -1
  27. package/src/extension/welcome/welcome-integration.ts +65 -5
  28. package/src/i18n/nl.ts +3 -0
  29. package/src/segments/core.ts +0 -1
  30. package/src/segments/system.ts +12 -3
  31. package/src/studio/component.ts +0 -1
  32. package/src/theme/theme.ts +1 -1
  33. package/src/tools/graph.ts +0 -3
  34. package/src/usage/tps-ring.ts +172 -32
  35. package/src/welcome/banner.ts +14 -0
  36. package/src/welcome/overlay.ts +139 -7
  37. package/src/welcome/quick-launch.ts +44 -0
  38. package/src/welcome/types.ts +13 -0
  39. package/src/welcome/widgets/sessions-widget.ts +13 -9
  40. package/src/welcome/widgets/shortcuts-widget.ts +13 -5
  41. package/src/welcome/widgets/whats-new-widget.ts +16 -3
  42. package/src/working-vibes/frames.ts +81 -5
  43. package/src/working-vibes/index.ts +6 -1
  44. package/src/working-vibes/native-indicator.ts +71 -0
  45. package/src/working-vibes/storage.ts +5 -3
@@ -0,0 +1,88 @@
1
+ # Issue tracker: Linear + GitHub
2
+
3
+ This repo uses a dual-tracker workflow:
4
+ - **Linear**: Long-term planning, roadmap, feature tracking
5
+ - **GitHub**: Bug reports, PR-linked issues, immediate triage
6
+
7
+ ## Conventions
8
+
9
+ ### GitHub (primary for bugs and triage)
10
+ - **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
11
+ - **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
12
+ - **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
13
+ - **Comment on an issue**: `gh issue comment <number> --body "..."`
14
+ - **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
15
+ - **Close**: `gh issue close <number> --comment "..."`
16
+
17
+ Infer the repo from `git remote -v`; `gh` does this automatically when run inside a clone.
18
+
19
+ ### Linear (for planning and features)
20
+ - **Create a ticket**: `linear issue create --title "..." --description "..." --team <team-id>`. Use `linear teams list` to find team IDs.
21
+ - **Read a ticket**: `linear issue view <ticket-id>` or `linear issue search --query "<title>"`.
22
+ - **List tickets**: `linear issue list --team <team-id> --state <state>` with appropriate filters (e.g., `--state Backlog`, `--state In Progress`).
23
+ - **Comment on a ticket**: `linear comment create <ticket-id> --body "..."`
24
+ - **Change state**: `linear issue update <ticket-id> --state <state>`
25
+ - **Assign**: `linear issue update <ticket-id> --assignee <user-id>`
26
+ - **Link to GitHub**: Add a GitHub issue URL in the Linear ticket description, or use Linear's GitHub integration if configured.
27
+
28
+ Use Linear for feature planning, roadmap items, and long-term tracking. Reference Linear tickets in GitHub issue bodies when applicable. Link GitHub PRs to related Linear tickets for traceability.
29
+
30
+ ### Linear conventions
31
+
32
+ **Labels** (create in Linear):
33
+ - `type:feature` — New features or enhancements
34
+ - `type:bug` — Bugs that belong in the roadmap (not urgent)
35
+ - `type:refactor` — Technical debt or architectural work
36
+ - `type:research` — Investigation or exploration tasks
37
+ - `priority:high` / `priority:medium` / `priority:low` — Priority levels
38
+
39
+ **States** (use Linear's default workflow or customize):
40
+ - `Backlog` — Planned but not started
41
+ - `In Progress` — Active work
42
+ - `In Review` — Awaiting review
43
+ - `Done` — Completed
44
+ - `Canceled` — Declined or superseded
45
+
46
+ ## Pull requests as a triage surface
47
+
48
+ **PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
49
+
50
+ When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
51
+
52
+ - **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
53
+ - **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,comments` then fetch `authorAssociation` per PR with `gh api repos/:owner/:repo/pulls/<number> --jq '.author_association'` (the `gh pr list --json` field set does not include `authorAssociation`), keeping only `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
54
+ - **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
55
+
56
+ GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`.
57
+
58
+ ## When a skill says "publish to the issue tracker"
59
+
60
+ **Use GitHub when:**
61
+ - Bug reports or regressions
62
+ - Urgent issues requiring immediate triage
63
+ - PR-linked issues or feedback from code review
64
+ - Questions or clarifications about implementation
65
+
66
+ **Use Linear when:**
67
+ - Feature proposals or enhancements
68
+ - Roadmap items or long-term planning
69
+ - Research tasks or investigations
70
+ - Technical debt or architectural refactoring
71
+ - Multi-step work that benefits from project tracking
72
+
73
+ If unsure, default to GitHub for bugs/immediate work, Linear for features/planning.
74
+
75
+ ## When a skill says "fetch the relevant ticket"
76
+
77
+ Run `gh issue view <number> --comments` for GitHub issues. For Linear tickets, use the Linear CLI or API.
78
+
79
+ ## Wayfinding operations
80
+
81
+ Used by `/wayfinder`. The **map** is a single Linear ticket with **child** tickets as tasks.
82
+
83
+ - **Map**: a single Linear ticket labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `linear issue create --title "Map: <topic>" --description "..." --team <team-id> --label wayfinder:map`.
84
+ - **Child ticket**: a Linear ticket linked to the map as a parent using Linear's parent-child relationship. Add the child to the map's sub-issues and put `Part of <map-id>` at the top of the child description. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
85
+ - **Blocking**: Use Linear's **native issue dependencies**. Add a blocker with `linear issue update <child> --blocking <blocker-id>`. Linear reports blocking status in the UI. A ticket is unblocked when every blocker is resolved.
86
+ - **Frontier query**: list the map's open children (`linear issue list --team <team-id> --parent <map-id> --state Backlog,In Progress`), drop any with an open blocker or an assignee; first in map order wins.
87
+ - **Claim**: `linear issue update <ticket-id> --assignee @me`, the session's first write.
88
+ - **Resolve**: `linear comment create <ticket-id> --body "<answer>"`, then `linear issue update <ticket-id> --state Done`, then append a context pointer (gist + link) to the map's Decisions-so-far.
@@ -210,8 +210,11 @@ The indicator is intentionally separate from the structural appearance motion: i
210
210
  | `pulse` | expanding/contracting center | clear activity at a glance |
211
211
  | `bar` | travelling block across a track | high-information dashboards |
212
212
  | `ascii` | portable `[=]`, `[-]`, `[.]` frames | SSH, basic terminals, and logs |
213
+ | `lantern` | rising block ramp (▁→█) | the signature look, ambient sessions |
214
+ | `braille` | classic braille spinner | compact terminals and log capture |
215
+ | `motion` | frames from the Motion Catalog (the motion assigned to `thinking`) at that definition's own `generator.intervalMs` | matching the working spinner to the rest of the motion language |
213
216
 
214
- Configure it under `wishcraft.workingIndicatorStyle`; supported values are `dots` (default), `pulse`, `bar`, and `ascii`. Motion accessibility settings still apply: reduced/off motion uses a static frame.
217
+ Configure it under `wishcraft.workingIndicatorStyle`; supported values are `dots` (default), `pulse`, `bar`, `ascii`, `lantern`, `braille`, and `motion`. The list in the settings UI is derived from `workingIndicatorStyles()`, so it cannot drift from what the renderer accepts. Motion accessibility settings still apply: reduced/off motion uses a static frame, functional motion alternates two frames, and ASCII glyph mode forces the ASCII set — `motion` falls back to `lantern` when its motion id is unknown.
215
218
 
216
219
  ```json
217
220
  {
@@ -506,6 +509,8 @@ Four questions — language, status preset, motion level, welcome overlay — th
506
509
 
507
510
  On the very first launch the welcome panel shows three next steps under **Getting started** instead of a changelog wall. Later launches show the usual changelog delta under **What's new**.
508
511
 
512
+ The panel is a cockpit, not a splash screen. `Tab`/`Shift+Tab` step between **What's new**, **Recent Crafts** and **Quick Launch**, ↑/↓ move inside the focused section (the selected changelog bullet expands across wrapped lines instead of being truncated), and `Enter` — only after navigation has paused the countdown — primes the prompt for the focused quick-launch row (`# ` for an idea, `! ` for a sticky bash session). `Recent Crafts` is read-only: Pi exposes no session-resume handle. No printable key is ever captured by the overlay, so the first letter of the prompt you start typing still dismisses it. With `wishcraft.welcome.animateLantern` on, the lantern keeps burning on the ambient heartbeat after the boot reveal instead of freezing on its last frame.
513
+
509
514
  ## Segment options: two accepted shapes
510
515
 
511
516
  Segment options may be written either hand-edited at the top level of `powerline`, or nested under `powerline.segmentOptions` (the shape the settings UI writes):
@@ -40,7 +40,7 @@ Wishcraft establishes a multi-tiered test strategy that verifies behavior withou
40
40
 
41
41
  1. **No Headless TUI Requirement**: Core algorithms must never instantiate `ctx.ui` or direct terminal stdout in tests. Renderers emit plain string buffers or token trees.
42
42
  2. **Deterministic Time Control**: The `MotionScheduler` accepts an injected virtual clock or step function for precise, millisecond-accurate timeline assertions.
43
- 3. **Circular Dependency Prevention**: Verified automatically on every build via `npx madge --circular src/`.
43
+ 3. **Circular Dependency Prevention**: Verified automatically on every build via `bun run circular`.
44
44
  4. **Strict TypeScript Checking**: `tsc --noEmit` runs under strict mode with zero implicit `any`.
45
45
 
46
46
  ---
@@ -0,0 +1,81 @@
1
+ # Testing & tooling
2
+
3
+ Bun owns installation and script execution. The test **runner** stays Node's
4
+ `node:test`, and that split is deliberate.
5
+
6
+ ## Why Bun installs but Node runs the tests
7
+
8
+ `bun test` runs this suite, but not identically: measured on 1.4.2 with 1037
9
+ tests, it reports **1035 pass / 2 fail** where `node --test` reports 1037/0.
10
+
11
+ | Failing under `bun test` | Cause |
12
+ |---|---|
13
+ | `ports.test.ts` — "serves stale rows past the TTL instead of blanking the number" | Bun's fake-timer semantics differ from `node:test`'s `mock.timers`; the TTL never advances as the test expects. |
14
+ | `ptyshell-managed.test.ts` — "carries cwd between commands via the sentinel" | Bun's PTY/child-process timing diverges; the sentinel is read before the shell writes it. |
15
+
16
+ Both suites assert real behaviour that Node's runner exercises correctly, so
17
+ the choice is "keep the runner that proves the behaviour" rather than
18
+ "rewrite two suites to fit a faster runner". The coverage thresholds
19
+ (`--test-coverage-lines=70 --test-coverage-functions=60`) are `node:test` flags
20
+ as well. Everything else — install, script invocation, lint, typecheck — is Bun.
21
+
22
+ Re-evaluate when Bun's runner gains `node:test` timer parity; the gate is a
23
+ single command change in `package.json`, not a rewrite.
24
+
25
+ ## Commands
26
+
27
+ ```bash
28
+ bun install --frozen-lockfile # install exactly what bun.lock pins
29
+ bun run typecheck # tsc --noEmit
30
+ bun run lint # oxlint --deny-warnings
31
+ bun run test # node:test + coverage thresholds
32
+ bun run check # typecheck + lint + test
33
+ bun run circular # madge --circular
34
+ bun run verify:package # package + Pi contract gates
35
+ bun run audit # dependency audit gate
36
+ ```
37
+
38
+ ## Pre-commit hook
39
+
40
+ ```bash
41
+ bun run hooks:install # git config core.hooksPath .githooks
42
+ ```
43
+
44
+ `.githooks/pre-commit` runs `typecheck`, `lint` and only the test files the
45
+ commit touches. It is fast by design; the full suite is CI's job (or
46
+ `bun run check` before pushing).
47
+
48
+ ## Lint
49
+
50
+ `oxlint` (config in `.oxlintrc.json`) is the lint gate: correctness rules are
51
+ errors, and CI runs with `--deny-warnings`, so a warning fails the build the
52
+ same way an error does. `no-control-regex` is off repo-wide because matching
53
+ ANSI SGR sequences is the product, not an accident.
54
+
55
+ ## CI shape
56
+
57
+ `.github/workflows/test.yml` runs seven independent lanes in parallel —
58
+ `whitespace`, `typecheck`, `lint`, `test`, `circular`, `package-contract`,
59
+ `audit` — each installing from the frozen lockfile through the
60
+ `.github/actions/bun-setup` composite action. A final `verify` job aggregates
61
+ them and keeps that name as the single required status check; it also carries
62
+ the release-candidate promotion dispatch, so the Verify → Promote chain is
63
+ unchanged.
64
+
65
+ ## Dependency audit
66
+
67
+ `scripts/audit-gate.mjs` reads `bun audit --json` plus `bun pm ls --all`.
68
+ Because `bun.lock` carries no `package-lock.json` for npm to read, the npm
69
+ audit schema is gone; the gate keeps the same guarantees in bun's terms:
70
+
71
+ - a new high/critical advisory fails, matched by GHSA id **and** package name;
72
+ - a baselined advisory whose vulnerable range moved fails (the exemption is
73
+ pinned to the range a human reviewed);
74
+ - an installed version the baseline does not list fails, so a second
75
+ vulnerable copy of an accepted package cannot inherit the exemption;
76
+ - a baselined advisory that stops reporting as high/critical fails, so the
77
+ baseline cannot rot into a blanket exemption;
78
+ - an audit that cannot run exits 2 instead of reading as "zero findings".
79
+
80
+ `audit-baseline.json` and `.oxlintrc.json` are excluded from the published
81
+ tarball; `bun.lock` never ships because `files` does not match it.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.16.0",
3
+ "version": "1.23.0",
4
4
  "description": "Pi coding agent extension: operator cockpit with a live powerline status bar, idea queue, skill search, sticky Bash, hooks, policy guardrails, and TUI session UX.",
5
5
  "type": "module",
6
+ "packageManager": "bun@1.4.2",
6
7
  "engines": {
7
8
  "node": ">=22.19"
8
9
  },
@@ -11,6 +12,7 @@
11
12
  "*.md",
12
13
  "*.json",
13
14
  "!audit-baseline.json",
15
+ "!.oxlintrc.json",
14
16
  "banner.png",
15
17
  "docs/**/*.md",
16
18
  "src/**/*.ts",
@@ -60,14 +62,18 @@
60
62
  },
61
63
  "scripts": {
62
64
  "typecheck": "tsc --noEmit",
65
+ "lint": "oxlint --deny-warnings src tests scripts index.ts bash-mode queue",
66
+ "lint:fix": "oxlint --fix src tests scripts index.ts bash-mode queue",
63
67
  "test": "node --experimental-strip-types --experimental-test-coverage --test-coverage-lines=70 --test-coverage-functions=60 --test tests/**/*.test.ts",
64
- "release": "node scripts/release.mjs",
65
- "prepublishOnly": "tsc --noEmit && node scripts/verify-package.mjs && npm run verify:pi-package",
66
- "verify:package": "node scripts/verify-package.mjs && node scripts/verify-pi-package-contract.mjs",
68
+ "check": "bun run typecheck && bun run lint && bun run test",
69
+ "release": "bun scripts/release.mjs",
70
+ "prepublishOnly": "bun run typecheck && bun run lint && bun scripts/verify-package.mjs && bun run verify:pi-package",
71
+ "verify:package": "bun scripts/verify-package.mjs && bun scripts/verify-pi-package-contract.mjs",
67
72
  "circular": "madge --circular src index.ts bash-mode queue",
68
- "audit": "node scripts/audit-gate.mjs",
69
- "preview": "node --experimental-strip-types scripts/preview.mjs",
70
- "verify:pi-package": "node scripts/verify-pi-package-contract.mjs"
73
+ "audit": "bun scripts/audit-gate.mjs",
74
+ "preview": "bun scripts/preview.mjs",
75
+ "verify:pi-package": "bun scripts/verify-pi-package-contract.mjs",
76
+ "hooks:install": "git config core.hooksPath .githooks"
71
77
  },
72
78
  "peerDependencies": {
73
79
  "@earendil-works/pi-ai": "*",
@@ -75,11 +81,12 @@
75
81
  "@earendil-works/pi-tui": "*"
76
82
  },
77
83
  "devDependencies": {
78
- "@earendil-works/pi-ai": "^0.99.2",
79
- "@earendil-works/pi-coding-agent": "^0.99.2",
80
- "@earendil-works/pi-tui": "^0.99.2",
84
+ "@earendil-works/pi-ai": "^1.5.0",
85
+ "@earendil-works/pi-coding-agent": "^1.5.0",
86
+ "@earendil-works/pi-tui": "^1.5.0",
81
87
  "@types/node": "24.13.3",
82
88
  "madge": "^8.0.0",
89
+ "oxlint": "^1.91.0",
83
90
  "typescript": "5.9.3"
84
91
  },
85
92
  "pi": {
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { tr } from "../i18n/index.ts";
8
8
  import { MOTION_LEVELS } from "../motion/accessibility.ts";
9
+ import { workingIndicatorStyles } from "../working-vibes/frames.ts";
9
10
  import { STRUCTURAL_PRESET_NAMES } from "./types.ts";
10
11
 
11
12
  export type SettingValue = boolean | string | number;
@@ -108,7 +109,7 @@ export const SETTINGS_REGISTRY = [
108
109
  { id: "status.ports.host", path: "powerline.segmentOptions.openPorts.host", group: "status", label: "Ports probe host", kind: "text", hint: "SSH host to probe (fleet) · empty = this machine" },
109
110
  { id: "welcome.enabled", path: "powerline.welcome", group: "vibes", label: "Welcome overlay", kind: "toggle", defaultValue: SETTING_DEFAULTS["powerline.welcome"], hint: "on = overlay at startup, off = no welcome" },
110
111
  { id: "welcome.lanternMotion", path: "wishcraft.welcome.animateLantern", group: "vibes", label: "Animate wishcraft lantern", kind: "toggle", hint: "flicker on the lantern" },
111
- { id: "vibes.indicatorStyle", path: "wishcraft.workingIndicatorStyle", group: "vibes", label: "Working indicator", kind: "select", choices: ["dots", "pulse", "bar", "ascii"], defaultValue: SETTING_DEFAULTS["wishcraft.workingIndicatorStyle"], hint: "frame style used while the agent is working" },
112
+ { id: "vibes.indicatorStyle", path: "wishcraft.workingIndicatorStyle", group: "vibes", label: "Working indicator", kind: "select", choices: workingIndicatorStyles(), defaultValue: SETTING_DEFAULTS["wishcraft.workingIndicatorStyle"], hint: "frame style for Pi's own working spinner while the agent runs" },
112
113
  { id: "skills.inline", path: "wishcraft.inlineSkills", group: "skills", label: "Inline expand /command and $skill", kind: "toggle", hint: "needs a restart to take effect", restartRequired: true },
113
114
  { id: "skills.readHints", path: "wishcraft.readHints", group: "skills", label: "Read hints", kind: "toggle", defaultValue: SETTING_DEFAULTS["wishcraft.readHints"], hint: "off = no continuation hint after partial reads" },
114
115
  { id: "harness.hooks", path: "wishcraft.hooksEnabled", group: "harness", label: "Hooks enabled", kind: "toggle", defaultValue: SETTING_DEFAULTS["wishcraft.hooksEnabled"], hint: "gate for configured preToolUse / postToolUse / sessionStart hooks" },
@@ -11,7 +11,6 @@ import type { StructuralPresetDef, StructuralPresetName } from "./types.ts";
11
11
  import {
12
12
  BORDERLESS_CHROME,
13
13
  DEFAULT_DECK,
14
- DEFAULT_WELCOME,
15
14
  MINIMAL_CHROME,
16
15
  motion,
17
16
  ROUNDED_CHROME,
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { type KeyId } from "@earendil-works/pi-tui";
3
- import type { StatusLinePreset } from "../../config/types.ts";
4
3
  import { PRESETS } from "../../config/presets.ts";
5
4
  import { registerCdCommand } from "../../shell/cd-command.ts";
6
5
  import { registerVibeCommand } from "./vibe-command.ts";
@@ -90,6 +90,11 @@ export const PEER_SYMBOL_REQUIREMENTS: readonly PeerSymbolRequirement[] = [
90
90
  symbol: "truncateToWidth",
91
91
  usedBy: "status line rendering",
92
92
  },
93
+ {
94
+ pkg: "@earendil-works/pi-tui",
95
+ symbol: "wrapTextWithAnsi",
96
+ usedBy: "welcome news expansion",
97
+ },
93
98
  {
94
99
  pkg: "@earendil-works/pi-tui",
95
100
  symbol: "SelectList",
@@ -130,7 +135,7 @@ export function findMissingPeerSymbols(
130
135
  }
131
136
 
132
137
  /** The live peer namespaces, collected for the runtime check. */
133
- export function collectPeerExports(): Record<PeerPackage, unknown> {
138
+ function collectPeerExports(): Record<PeerPackage, unknown> {
134
139
  return {
135
140
  "@earendil-works/pi-ai": piAi,
136
141
  "@earendil-works/pi-coding-agent": piCodingAgent,
@@ -166,7 +171,7 @@ export function formatPeerGuardNotice(
166
171
  }
167
172
 
168
173
  /** Actionable error for a call site that cannot degrade gracefully. */
169
- export function missingSymbolError(symbol: string): Error {
174
+ function missingSymbolError(symbol: string): Error {
170
175
  const requirement = PEER_SYMBOL_REQUIREMENTS.find(
171
176
  (r) => r.symbol === symbol,
172
177
  );
@@ -15,6 +15,12 @@ import { getDefaultColors } from "../../theme/theme.ts";
15
15
  import { getGitStatus } from "../../git/status.ts";
16
16
  import { getQueueContext } from "../queue/queue-context.ts";
17
17
  import { getUsageTokenTotal } from "../../usage/ledger.ts";
18
+ import type { SessionAssistantUsage } from "../../usage/ledger.ts";
19
+ import {
20
+ pushTpsSample,
21
+ ringMsForWindow,
22
+ tpsSamples,
23
+ } from "../../usage/tps-ring.ts";
18
24
  import { tokenBudgetSnapshotForDay } from "../../usage/token-budget.ts";
19
25
  import { dayKey } from "../../usage/usage-store.ts";
20
26
  import {
@@ -119,9 +125,90 @@ export interface BuildSegmentContextOptions {
119
125
  includeTokenBudget?: boolean;
120
126
  }
121
127
 
128
+ /**
129
+ * Structural subset of Pi's extension context that the context builder reads.
130
+ * Typed rather than `any` so a renamed Pi accessor fails the typecheck here
131
+ * instead of silently rendering defaults at runtime.
132
+ */
133
+ export interface SegmentSourceContext {
134
+ sessionManager?: { getSessionId?: () => string | undefined };
135
+ model?: SegmentContext["model"];
136
+ modelRegistry?: {
137
+ // Pi types this as `(model: Model<Api>) => boolean`. Method-bivariance
138
+ // keeps that signature assignable without widening the segment model
139
+ // shape to the full SDK `Model` type.
140
+ isUsingOAuth?: {
141
+ bivariance(model: NonNullable<SegmentContext["model"]>): boolean;
142
+ }["bivariance"];
143
+ };
144
+ cwd?: string;
145
+ settingsManager?: {
146
+ getCompactionSettings?: () => { enabled?: boolean } | undefined;
147
+ };
148
+ }
149
+
150
+ interface ContextMetrics {
151
+ contextTokens: number;
152
+ contextPercent: number;
153
+ contextWindow: number;
154
+ }
155
+
156
+ /**
157
+ * Token/context numbers: cached core usage wins, the live assistant usage is
158
+ * the fallback, and the model's window bounds the percentage.
159
+ */
160
+ function resolveContextMetrics(
161
+ rt: RuntimeState,
162
+ ctx: SegmentSourceContext,
163
+ usage: SessionAssistantUsage | undefined,
164
+ ): ContextMetrics {
165
+ const core =
166
+ rt.isStreaming && rt.liveAssistantUsage ? null : rt.coreContextUsageCache.get(ctx);
167
+ const contextTokens =
168
+ core?.contextTokens ?? (usage ? getUsageTokenTotal(usage) : 0);
169
+ const contextWindow = core?.contextWindow ?? ctx.model?.contextWindow ?? 0;
170
+ const contextPercent =
171
+ core?.contextPercent ??
172
+ (contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0);
173
+ return { contextTokens, contextPercent, contextWindow };
174
+ }
175
+
176
+ interface CustomItemState {
177
+ effectiveCustomItems: SegmentContext["effectiveCustomItems"];
178
+ customItemsById: SegmentContext["customItemsById"];
179
+ hiddenExtensionStatusKeys: Set<string>;
180
+ }
181
+
182
+ /** Merge configured + auto-discovered custom items and hide claimed statuses. */
183
+ function resolveCustomItemState(
184
+ extensionStatuses: ReadonlyMap<string, string>,
185
+ ): CustomItemState {
186
+ const excludedStatusKeys = new Set<string>([
187
+ CUSTOM_COMPACTION_STATUS_KEY,
188
+ "stash",
189
+ ...HIDDEN_POWERLINE_STATUS_KEYS,
190
+ ]);
191
+ const effectiveCustomItems = deriveAutoCustomItems(
192
+ config.customItems,
193
+ extensionStatuses,
194
+ config.customItemsAuto,
195
+ excludedStatusKeys,
196
+ );
197
+ const customItemsById = new Map(
198
+ effectiveCustomItems.map((item) => [item.id, item]),
199
+ );
200
+ const hiddenExtensionStatusKeys = collectHiddenExtensionStatusKeys(
201
+ effectiveCustomItems,
202
+ );
203
+ for (const key of HIDDEN_POWERLINE_STATUS_KEYS) {
204
+ hiddenExtensionStatusKeys.add(key);
205
+ }
206
+ return { effectiveCustomItems, customItemsById, hiddenExtensionStatusKeys };
207
+ }
208
+
122
209
  export function buildSegmentContext(
123
210
  rt: RuntimeState,
124
- ctx: any,
211
+ ctx: SegmentSourceContext,
125
212
  theme: Theme,
126
213
  options: BuildSegmentContextOptions = {},
127
214
  ): SegmentContext {
@@ -133,72 +220,39 @@ export function buildSegmentContext(
133
220
  getDefaultColors,
134
221
  );
135
222
 
136
- // Build usage stats and get thinking level from session (cached; the full
137
- // event list is only re-scanned when events are appended or the trailing
138
- // event's stats-relevant fields change, e.g. in-place streaming updates)
223
+ // Usage stats are cached; the full event list is only re-scanned when events
224
+ // are appended or the trailing event's stats-relevant fields change (e.g.
225
+ // in-place streaming updates).
139
226
  const sessionEvents = rt.sessionBranchCache.get(ctx.sessionManager);
140
227
  const tokenStats = rt.tokenStatsCache.get(sessionEvents);
141
228
  const { input, output, cacheRead, cacheWrite, cost, subagentCost } =
142
229
  tokenStats;
143
230
  const lastAssistant = tokenStats.lastAssistant;
144
- const thinkingLevelFromSession = tokenStats.thinkingLevelFromSession;
145
231
 
146
- // Calculate context percentage.
147
232
  const latestUsage = rt.isStreaming
148
233
  ? (rt.liveAssistantUsage ?? lastAssistant?.usage)
149
234
  : lastAssistant?.usage;
150
- const coreContextUsage =
151
- rt.isStreaming && rt.liveAssistantUsage
152
- ? null
153
- : rt.coreContextUsageCache.get(ctx);
154
- const contextTokens =
155
- coreContextUsage?.contextTokens ??
156
- (latestUsage ? getUsageTokenTotal(latestUsage) : 0);
157
- const contextWindow =
158
- coreContextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
159
- const contextPercent =
160
- coreContextUsage?.contextPercent ??
161
- (contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0);
235
+ const { contextTokens, contextPercent, contextWindow } =
236
+ resolveContextMetrics(rt, ctx, latestUsage);
162
237
 
163
238
  const segmentOptions = mergeSegmentOptions(
164
239
  presetDef.segmentOptions,
165
240
  config.segmentOptions,
166
241
  );
167
242
 
168
- // Get git status (cached)
169
243
  const gitBranch = rt.footerDataRef?.getGitBranch() ?? null;
170
244
  const gitStatus = getGitStatus(gitBranch, segmentOptions.git?.polling);
171
- const extensionStatuses =
172
- rt.footerDataRef?.getExtensionStatuses() ?? new Map();
173
- const excludedStatusKeys = new Set<string>([
174
- CUSTOM_COMPACTION_STATUS_KEY,
175
- "stash",
176
- ...HIDDEN_POWERLINE_STATUS_KEYS,
177
- ]);
178
- const effectiveCustomItems = deriveAutoCustomItems(
179
- config.customItems,
180
- extensionStatuses,
181
- config.customItemsAuto,
182
- excludedStatusKeys,
183
- );
184
- const customItemsById = new Map(
185
- effectiveCustomItems.map((item) => [item.id, item]),
186
- );
187
- const hiddenExtensionStatusKeys = collectHiddenExtensionStatusKeys(
188
- effectiveCustomItems,
189
- );
190
- for (const key of HIDDEN_POWERLINE_STATUS_KEYS) {
191
- hiddenExtensionStatusKeys.add(key);
192
- }
245
+ const extensionStatuses = rt.footerDataRef?.getExtensionStatuses() ?? new Map();
246
+ const { effectiveCustomItems, customItemsById, hiddenExtensionStatusKeys } =
247
+ resolveCustomItemState(extensionStatuses);
193
248
 
194
- // Check if using OAuth subscription
195
249
  const usingSubscription = ctx.model
196
250
  ? (ctx.modelRegistry?.isUsingOAuth?.(ctx.model) ?? false)
197
251
  : false;
198
252
 
199
253
  const thinkingLevel =
200
254
  rt.currentThinkingLevel ??
201
- thinkingLevelFromSession ??
255
+ tokenStats.thinkingLevelFromSession ??
202
256
  rt.getThinkingLevelFn?.() ??
203
257
  "off";
204
258
  const queueSummary = rt.queueStore.summarize(
@@ -248,4 +302,45 @@ export function buildSegmentContext(
248
302
  theme,
249
303
  colors,
250
304
  };
251
- }
305
+ }
306
+ const TPS_EVENT_MIN_GAP_MS = 30;
307
+ let lastTpsEventAt = 0;
308
+ let lastMarkedTurnStart: number | null = null;
309
+
310
+ /**
311
+ * Event-driven TPS sampling, called from `message_update`. The ring used to be
312
+ * filled only while the `tps` segment rendered, so presets without that segment
313
+ * left `/tps` empty and every unrelated repaint added a duplicate sample.
314
+ *
315
+ * This is also the only writer of the turn marker: the first sample of a turn
316
+ * carries `turnStartAt`, the rest do not, so `measureTurn` can find both the
317
+ * turn boundary and the sample that produced its first token.
318
+ */
319
+ export function sampleTpsFromSession(
320
+ rt: RuntimeState,
321
+ ctx: SegmentSourceContext,
322
+ now: number = Date.now(),
323
+ ): void {
324
+ if (now - lastTpsEventAt < TPS_EVENT_MIN_GAP_MS) return;
325
+ lastTpsEventAt = now;
326
+ const events = rt.sessionBranchCache.get(ctx.sessionManager);
327
+ const { input, output, cacheRead } = rt.tokenStatsCache.get(events);
328
+ const windowMs =
329
+ mergeSegmentOptions(getPreset(config.preset).segmentOptions, config.segmentOptions)
330
+ .tps?.windowMs ?? 1000;
331
+ const turnStartAt =
332
+ rt.turnStartedAt !== null && rt.turnStartedAt !== lastMarkedTurnStart
333
+ ? (lastMarkedTurnStart = rt.turnStartedAt)
334
+ : undefined;
335
+ pushTpsSample(
336
+ tpsSamples,
337
+ {
338
+ at: now,
339
+ output,
340
+ input,
341
+ uncachedInput: Math.max(0, input - cacheRead),
342
+ ...(turnStartAt === undefined ? {} : { turnStartAt }),
343
+ },
344
+ ringMsForWindow(windowMs),
345
+ );
346
+ }
@@ -111,6 +111,7 @@ export function createRuntimeState(
111
111
  welcomeHeaderActive: false,
112
112
  welcomeOverlayShouldDismiss: false,
113
113
  lastUserPrompt: "",
114
+ turnStartedAt: null,
114
115
  showLastPrompt: true,
115
116
  stashedEditorText: null,
116
117
  stashedPromptHistory: readPersistedStashHistory(),
@@ -61,6 +61,11 @@ export interface RuntimeState {
61
61
  welcomeHeaderActive: boolean;
62
62
  welcomeOverlayShouldDismiss: boolean;
63
63
  lastUserPrompt: string;
64
+ /**
65
+ * Timestamp (ms) of the current turn's `agent_start`; null between turns.
66
+ * Only the first TPS sample of a turn carries it as a ring marker.
67
+ */
68
+ turnStartedAt: number | null;
64
69
  showLastPrompt: boolean;
65
70
  stashedEditorText: string | null;
66
71
  stashedPromptHistory: string[];
@@ -18,6 +18,9 @@ import { showSelectOverlay } from "../ui/menu-views.ts";
18
18
  import { getQueueContext } from "./queue-context.ts";
19
19
  import { isStaleExtensionContextError } from "../session/stale-context.ts";
20
20
  import { config } from "../core/state.ts";
21
+ import { allWorkingIndicatorFrames } from "../../working-vibes/frames.ts";
22
+ import { MOTION_CATALOG } from "../../motion/catalog.ts";
23
+ import { framesOf } from "../../motion/frames.ts";
21
24
  import type { RuntimeState } from "../core/types.ts";
22
25
 
23
26
  export function requestQueueRender(rt: RuntimeState): void {
@@ -40,9 +43,24 @@ export function queueItemDescription(item: PowerlineQueueItem): string {
40
43
  * Pi's working indicator (spinner frame + "Working...") can end up at the start
41
44
  * of captured text when the editor buffer is captured while the agent runs.
42
45
  * Strip that prefix so ideas never render as a permanently "working" card.
46
+ *
47
+ * The frame alternation is built from the same tables the indicator draws from
48
+ * (`working-vibes/frames.ts` plus the Motion Catalog the `motion` style
49
+ * borrows), so a new style or motion can never leak its glyphs into an idea.
43
50
  */
44
- const CAPTURE_NOISE_PREFIX =
45
- /^[\u2800-\u28FF][\u2800-\u28FF ]*\s*Working[.…]+\s*/;
51
+ const SPINNER_FRAMES = [
52
+ ...new Set([
53
+ ...allWorkingIndicatorFrames(),
54
+ ...MOTION_CATALOG.flatMap((def) => framesOf(def)),
55
+ ]),
56
+ ]
57
+ .filter((frame) => frame.trim().length > 0)
58
+ .sort((a, b) => b.length - a.length)
59
+ .map((frame) => frame.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
60
+
61
+ const CAPTURE_NOISE_PREFIX = new RegExp(
62
+ `^\\s*(?:(?:${SPINNER_FRAMES.join("|")})\\s*)*Working[.…]+\\s*`,
63
+ );
46
64
 
47
65
  export function stripCaptureNoisePrefix(text: string): string {
48
66
  let out = text;