@groeponline/pi-wishcraft 1.17.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 (43) hide show
  1. package/AGENTS.md +42 -22
  2. package/CHANGELOG.md +58 -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/configuration.md +6 -1
  8. package/docs/design/regression-testing.md +1 -1
  9. package/docs/testing.md +81 -0
  10. package/package.json +17 -10
  11. package/src/config/settings-registry.ts +2 -1
  12. package/src/config/structural-preset-table.ts +0 -1
  13. package/src/extension/commands/commands.ts +0 -1
  14. package/src/extension/core/peer-guard.ts +5 -0
  15. package/src/extension/core/segment-context.ts +139 -44
  16. package/src/extension/core/state.ts +1 -0
  17. package/src/extension/core/types.ts +5 -0
  18. package/src/extension/queue/queue-integration.ts +20 -2
  19. package/src/extension/session/session-lifecycle.ts +24 -3
  20. package/src/extension/skills/inline-invocation.ts +1 -1
  21. package/src/extension/ui/custom-editor.ts +1 -5
  22. package/src/extension/ui/deck/config-diagnostic-lines.ts +0 -6
  23. package/src/extension/ui/ports-panel.ts +0 -1
  24. package/src/extension/ui/token-overlays.ts +1 -1
  25. package/src/extension/welcome/welcome-integration.ts +65 -5
  26. package/src/i18n/nl.ts +3 -0
  27. package/src/segments/core.ts +0 -1
  28. package/src/segments/system.ts +12 -3
  29. package/src/studio/component.ts +0 -1
  30. package/src/theme/theme.ts +1 -1
  31. package/src/tools/graph.ts +0 -3
  32. package/src/usage/tps-ring.ts +172 -32
  33. package/src/welcome/banner.ts +14 -0
  34. package/src/welcome/overlay.ts +139 -7
  35. package/src/welcome/quick-launch.ts +44 -0
  36. package/src/welcome/types.ts +13 -0
  37. package/src/welcome/widgets/sessions-widget.ts +13 -9
  38. package/src/welcome/widgets/shortcuts-widget.ts +13 -5
  39. package/src/welcome/widgets/whats-new-widget.ts +16 -3
  40. package/src/working-vibes/frames.ts +81 -5
  41. package/src/working-vibes/index.ts +6 -1
  42. package/src/working-vibes/native-indicator.ts +71 -0
  43. package/src/working-vibes/storage.ts +5 -3
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.17.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",
@@ -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;
@@ -4,6 +4,7 @@ import { BashTranscriptStore } from "../../../bash-mode/transcript.ts";
4
4
  import { BashCompletionEngine } from "../../../bash-mode/completion.ts";
5
5
  import { parsePowerlineConfig } from "../../config/powerline-config.ts";
6
6
  import { policyFromEnvironment } from "../../motion/accessibility.ts";
7
+ import { defaultMotionFor } from "../../motion/catalog.ts";
7
8
  import { registerCustomSegments } from "../../segments/index.ts";
8
9
  import { registerCustomPresets } from "../../config/presets.ts";
9
10
  import { invalidateGitStatus } from "../../git/status.ts";
@@ -34,6 +35,7 @@ import {
34
35
  requestImmediateStatusRender,
35
36
  requestStatusRender,
36
37
  resetLayoutCache,
38
+ sampleTpsFromSession,
37
39
  } from "../core/segment-context.ts";
38
40
  import { getQueueContext } from "../queue/queue-context.ts";
39
41
  import {
@@ -62,6 +64,10 @@ import {
62
64
  clearSkillsCountPublisher,
63
65
  } from "../skills/skill-status.ts";
64
66
  import { maybeAppendReadHint } from "./read-hints.ts";
67
+ import {
68
+ applyNativeWorkingIndicator,
69
+ resetNativeWorkingIndicator,
70
+ } from "../../working-vibes/native-indicator.ts";
65
71
  import {
66
72
  dispatchSignalEvent,
67
73
  settleSignal,
@@ -181,7 +187,7 @@ export function registerSessionLifecycle(
181
187
  }
182
188
  });
183
189
 
184
- pi.on("session_shutdown", async (_event, ctx) => {
190
+ pi.on("session_shutdown", async (_event, _ctx) => {
185
191
  clearSkillsCountPublisher();
186
192
  rt.sessionGeneration++;
187
193
  rt.dismissWelcomeOverlay?.();
@@ -260,9 +266,16 @@ export function registerSessionLifecycle(
260
266
 
261
267
  // Generate themed working message before agent starts (has access to user's prompt)
262
268
  pi.on("before_agent_start", async (event, ctx) => {
263
- rt.lastUserPrompt = event.prompt;
269
+ const prompt =
270
+ event.messageType === "user"
271
+ ? event.prompt
272
+ : event.message.display && typeof event.message.content === "string"
273
+ ? event.message.content
274
+ : "";
275
+ rt.lastUserPrompt = prompt;
264
276
  if (ctx.hasUI) {
265
- onVibeBeforeAgentStart(event.prompt, ctx.ui.setWorkingMessage);
277
+ onVibeBeforeAgentStart(prompt, ctx.ui.setWorkingMessage);
278
+ applyNativeWorkingIndicator(ctx.ui, rt.motionPolicy, defaultMotionFor("thinking"));
266
279
  }
267
280
  });
268
281
 
@@ -270,6 +283,7 @@ export function registerSessionLifecycle(
270
283
  // Also dismiss welcome when agent starts responding (handles `p "command"` case)
271
284
  pi.on("agent_start", async (_event, ctx) => {
272
285
  rt.isStreaming = true;
286
+ rt.turnStartedAt = Date.now();
273
287
  rt.liveAssistantUsage = null;
274
288
  onVibeAgentStart();
275
289
  dismissWelcome(rt, ctx);
@@ -287,6 +301,11 @@ export function registerSessionLifecycle(
287
301
  rt.liveAssistantUsage = event.message.usage;
288
302
  rt.currentCtx = ctx;
289
303
  rt.layoutDirty = true;
304
+ try {
305
+ sampleTpsFromSession(rt, ctx);
306
+ } catch (error) {
307
+ if (!isStaleExtensionContextError(error)) throw error;
308
+ }
290
309
  rt.statusRenderScheduler.schedule(CONTEXT_STATUS_RENDER_MS);
291
310
  if (rt.signal.event !== "streaming") {
292
311
  dispatchSignalEvent(rt, config.appearance, "streaming");
@@ -406,6 +425,7 @@ export function registerSessionLifecycle(
406
425
 
407
426
  pi.on("agent_end", async (_event, ctx) => {
408
427
  rt.isStreaming = false;
428
+ rt.turnStartedAt = null;
409
429
  rt.liveAssistantUsage = null;
410
430
  rt.coreContextUsageCache.reset();
411
431
  dispatchSignalEvent(rt, config.appearance, "success");
@@ -423,6 +443,7 @@ export function registerSessionLifecycle(
423
443
  try {
424
444
  if (hasUI) {
425
445
  onVibeAgentEnd(ctx.ui.setWorkingMessage); // working-vibes internal state + reset message
446
+ resetNativeWorkingIndicator(ctx.ui);
426
447
  if (rt.stashedEditorText !== null) {
427
448
  if (ctx.ui.getEditorText().trim() === "") {
428
449
  ctx.ui.setEditorText(rt.stashedEditorText);
@@ -162,7 +162,7 @@ export function expandInlineTriggers(text: string): string {
162
162
  */
163
163
  export function setupInlineInvocation(
164
164
  pi: ExtensionAPI,
165
- rt: RuntimeState,
165
+ _rt: RuntimeState,
166
166
  ): void {
167
167
  pi.on("input", async (event: any) => {
168
168
  // Alleen interactive input verwerken
@@ -48,14 +48,10 @@ import {
48
48
  runPowerlineShortcut,
49
49
  stashOrRestoreEditorText,
50
50
  } from "../shortcuts/shortcuts-router.ts";
51
- import {
52
- dismissWelcome,
53
- scheduleDismissWelcome,
54
- } from "../welcome/welcome-control.ts";
51
+ import { scheduleDismissWelcome } from "../welcome/welcome-control.ts";
55
52
  import { config } from "../core/state.ts";
56
53
  import type { RuntimeState } from "../core/types.ts";
57
54
  import {
58
- ensureShellSession,
59
55
  getShellCwd,
60
56
  getShellHistoryEntries,
61
57
  getShellPath,
@@ -19,12 +19,6 @@ const MAX_CONFLICTS = 3;
19
19
  const MAX_UNKNOWN = 3;
20
20
  const MAX_INVALID = 4;
21
21
 
22
- const MARK: Record<"ok" | "warn" | "fail", string> = {
23
- ok: "✓",
24
- warn: "!",
25
- fail: "✗",
26
- };
27
-
28
22
  /** Localised heading for the config diagnosis block. */
29
23
  export function configDiagnosticTitle(): string {
30
24
  return tr("diag.title", "Config diagnosis");
@@ -21,7 +21,6 @@ import { matchesKey, type SelectItem, SelectList } from "@earendil-works/pi-tui"
21
21
  import { tr } from "../../i18n/index.ts";
22
22
  import { config } from "../core/state.ts";
23
23
  import {
24
- filterPorts,
25
24
  formatAddresses,
26
25
  formatOwner,
27
26
  formatPortsSummary,
@@ -35,7 +35,7 @@ export function buildTpsOverlayItems(rt: RuntimeState, ctx: any): SelectItem[] {
35
35
  const summary = summarizeTpsRing(
36
36
  tpsSamples,
37
37
  Date.now(),
38
- { input: snap.input, output: snap.output },
38
+ { input: snap.input, output: snap.output, cacheRead: snap.cacheRead },
39
39
  {
40
40
  windowMs: config.segmentOptions?.tps?.windowMs ?? 1000,
41
41
  override: process.env.POWERLINE_TPS,
@@ -183,7 +183,7 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
183
183
  (
184
184
  tui: any,
185
185
  _theme: any,
186
- _keybindings: any,
186
+ keybindings: any,
187
187
  done: (result: void) => void,
188
188
  ) => {
189
189
  const welcome = new WelcomeComponent(
@@ -205,22 +205,51 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
205
205
  let countdown = 30;
206
206
  let dismissed = false;
207
207
  let interval: ReturnType<typeof setInterval> | null = null;
208
+ let revealTimer: ReturnType<typeof setInterval> | null = null;
209
+
210
+ const stopTimers = () => {
211
+ if (interval) clearInterval(interval);
212
+ if (revealTimer) clearInterval(revealTimer);
213
+ interval = null;
214
+ revealTimer = null;
215
+ };
208
216
 
209
217
  const dismiss = () => {
210
218
  if (dismissed) return;
211
219
  dismissed = true;
212
- if (interval) clearInterval(interval);
220
+ stopTimers();
213
221
  rt.dismissWelcomeOverlay = null;
214
222
  done();
215
223
  };
216
224
 
217
225
  interval = setInterval(() => {
218
- if (dismissed) return;
226
+ if (dismissed || welcome.isTimerPaused()) return;
219
227
  countdown--;
220
228
  welcome.setCountdown(countdown);
221
229
  tui.requestRender();
222
230
  if (countdown <= 0) dismiss();
223
231
  }, 1000);
232
+ interval.unref?.();
233
+
234
+ // The countdown only repaints once a second, which stepped the boot
235
+ // reveal at 1 FPS. Repaint on the ambient heartbeat: bounded while
236
+ // the reveal window is open, continuous while the lantern animates.
237
+ const revealStartedAt = Date.now();
238
+ const animateArt = artSettings.animate;
239
+ revealTimer = setInterval(() => {
240
+ if (dismissed) {
241
+ if (revealTimer) clearInterval(revealTimer);
242
+ revealTimer = null;
243
+ return;
244
+ }
245
+ if (!animateArt && !bootRevealActive(revealStartedAt, Date.now())) {
246
+ if (revealTimer) clearInterval(revealTimer);
247
+ revealTimer = null;
248
+ return;
249
+ }
250
+ tui.requestRender();
251
+ }, BOOT_REVEAL_HEARTBEAT_MS);
252
+ revealTimer.unref?.();
224
253
 
225
254
  rt.dismissWelcomeOverlay = dismiss;
226
255
 
@@ -233,10 +262,41 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
233
262
  focused: false,
234
263
  invalidate: () => welcome.invalidate(),
235
264
  render: (width: number) => welcome.render(width),
236
- handleInput: () => dismiss(),
265
+ handleInput: (data: string) => {
266
+ // Only non-printable navigation is intercepted; Tab/Shift+Tab
267
+ // switch section, arrows move inside it, Enter (only once the
268
+ // countdown is paused) primes the focused quick-launch row.
269
+ // Every printable key still just dismisses, so typing is never
270
+ // swallowed by the overlay.
271
+ const nextSection = data === "\t";
272
+ const prevSection = data === "\x1b[Z";
273
+ if (nextSection || prevSection) {
274
+ welcome.cycleSection(prevSection ? -1 : 1);
275
+ tui.requestRender();
276
+ return;
277
+ }
278
+ const up = keybindings?.matches?.(data, "tui.select.up") || data === "\x1b[A";
279
+ const down = keybindings?.matches?.(data, "tui.select.down") || data === "\x1b[B";
280
+ if (up || down) {
281
+ welcome.moveFocus(up ? -1 : 1);
282
+ tui.requestRender();
283
+ return;
284
+ }
285
+ const enter =
286
+ keybindings?.matches?.(data, "tui.select.confirm") ||
287
+ data === "\r" ||
288
+ data === "\n";
289
+ if (enter && welcome.isTimerPaused()) {
290
+ const insert = welcome.activate();
291
+ dismiss();
292
+ if (insert) ctx.ui.setEditorText?.(insert);
293
+ return;
294
+ }
295
+ dismiss();
296
+ },
237
297
  dispose: () => {
238
298
  dismissed = true;
239
- if (interval) clearInterval(interval);
299
+ stopTimers();
240
300
  },
241
301
  };
242
302
  },
package/src/i18n/nl.ts CHANGED
@@ -311,6 +311,9 @@ export const NL: Record<string, string> = {
311
311
  "welcome.tagline": "Schrijf het op, laat het stijgen, houd je focus helder.",
312
312
  "welcome.title": " pi-wishcraft ",
313
313
  "welcome.countdown": "Druk op een toets om verder te gaan ({n}s)",
314
+ "welcome.pausedHint": "↑/↓ nieuws bekijken · andere toets gaat verder",
315
+ "welcome.pausedHint.sessions": "↑/↓ sessies bekijken · tab wisselt sectie · andere toets gaat verder",
316
+ "welcome.pausedHint.shortcuts": "↑/↓ kiezen · enter vult de prompt · tab wisselt sectie",
314
317
  "welcome.whatsNew": "Wat is nieuw",
315
318
  "welcome.firstRun": "Aan de slag",
316
319
  "welcome.firstRun.1": "# <idee> — leg een gedachte vast zonder te onderbreken",