@groeponline/pi-wishcraft 0.20.0 → 0.22.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 (70) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +83 -0
  3. package/ROADMAP.md +18 -20
  4. package/bash-mode/editor-ghost.ts +63 -0
  5. package/bash-mode/editor-history.ts +81 -0
  6. package/bash-mode/editor-input.ts +35 -0
  7. package/bash-mode/editor.ts +16 -134
  8. package/bash-mode/shell-session.ts +12 -15
  9. package/bash-mode/types.ts +2 -0
  10. package/docs/bash-mode.md +46 -0
  11. package/docs/commands.md +122 -0
  12. package/docs/configuration.md +307 -0
  13. package/docs/index.md +18 -0
  14. package/docs/segments.md +168 -0
  15. package/docs/skill-manager.md +7 -0
  16. package/docs/stash-and-shortcuts.md +64 -0
  17. package/docs/working-vibes.md +71 -0
  18. package/package.json +2 -1
  19. package/queue/store.ts +72 -3
  20. package/src/config/custom-items.ts +45 -0
  21. package/src/config/parse.ts +19 -3
  22. package/src/config/powerline-config.ts +5 -0
  23. package/src/config/primitives.ts +13 -1
  24. package/src/config/segment-options.ts +29 -24
  25. package/src/config/settings-patch.ts +23 -2
  26. package/src/config/types.ts +32 -17
  27. package/src/extension/commands/bash-mode-actions.ts +1 -0
  28. package/src/extension/commands/commands.ts +36 -18
  29. package/src/extension/commands/powerline-completions.ts +8 -0
  30. package/src/extension/commands/powerline-doctor.ts +247 -0
  31. package/src/extension/commands/powerline-export.ts +94 -0
  32. package/src/extension/commands/queue-commands.ts +29 -1
  33. package/src/extension/commands/vibe-command.ts +49 -1
  34. package/src/extension/core/constants.ts +1 -0
  35. package/src/extension/core/segment-context.ts +38 -2
  36. package/src/extension/core/state.ts +5 -1
  37. package/src/extension/core/status-export.ts +68 -0
  38. package/src/extension/core/types.ts +4 -0
  39. package/src/extension/hooks/repairs.ts +89 -16
  40. package/src/extension/session/activate.ts +1 -2
  41. package/src/extension/session/cost-alert.ts +36 -0
  42. package/src/extension/session/session-lifecycle.ts +82 -1
  43. package/src/extension/settings/settings-io.ts +23 -17
  44. package/src/extension/settings/wishcraft-config.ts +2 -1
  45. package/src/extension/shortcuts/shortcuts-config.ts +5 -0
  46. package/src/extension/ui/layout.ts +1 -1
  47. package/src/extension/ui/menu-items.ts +379 -0
  48. package/src/extension/ui/menu-views.ts +377 -191
  49. package/src/extension/ui/overlay-chrome.ts +163 -0
  50. package/src/extension/ui/powerline-menu-view.ts +2 -4
  51. package/src/extension/ui/token-overlays.ts +82 -0
  52. package/src/extension/welcome/welcome-integration.ts +5 -9
  53. package/src/segments/registry.ts +100 -39
  54. package/src/segments/system.ts +204 -64
  55. package/src/segments/usage.ts +10 -4
  56. package/src/usage/ledger.ts +6 -0
  57. package/src/usage/token-budget.ts +57 -0
  58. package/src/usage/tps-ring.ts +151 -0
  59. package/src/usage/usage-store.ts +351 -0
  60. package/src/welcome/banner.ts +2 -1
  61. package/src/welcome/index.ts +3 -1
  62. package/src/welcome/overlay.ts +2 -0
  63. package/src/welcome/renderer.ts +52 -10
  64. package/src/welcome/types.ts +1 -2
  65. package/src/welcome/whats-new.ts +156 -0
  66. package/src/welcome/widgets/index.ts +3 -0
  67. package/src/welcome/widgets/whats-new-widget.ts +19 -0
  68. package/src/working-vibes/generate.ts +144 -58
  69. package/src/working-vibes/index.ts +10 -2
  70. package/src/working-vibes/storage.ts +5 -0
@@ -0,0 +1,64 @@
1
+ # Stash & shortcuts
2
+
3
+ ## Editor stash
4
+
5
+ Use `Alt+S` / `Option+S` as a quick stash toggle while drafting. It keeps one active stash and clears the editor when stashing. Powerline listens for unambiguous Alt/Meta-S escape encodings by default. If your old terminal setup only emits the printable German sharp-S character for Option+S and you still want that to trigger stash, set `"stashSharpSShortcut": true` under `powerline`.
6
+
7
+ | Editor | Stash | `Alt+S` result |
8
+ |--------|-------|----------------|
9
+ | Has text | Empty | Stash current text, clear editor |
10
+ | Empty | Has stash | Restore stash into editor |
11
+ | Has text | Has stash | Update stash with current text, clear editor |
12
+ | Empty | Empty | Show "Nothing to stash" |
13
+
14
+ Auto-restore after an agent run only happens when the editor is still empty. If you typed meanwhile, the stash is preserved.
15
+
16
+ The `stash` indicator appears in the powerline bar (on presets with `extension_statuses`). Active stash is still session-local and resets on session switch / disable, but stash history is persisted to the agent dir at `powerline-footer/stash-history.json` so it survives restarts. By default the agent dir is `~/.pi/agent`; set `PI_CODING_AGENT_DIR` to move global powerline settings, stash history, sessions, vibes, skills, commands, and extension discovery with Pi.
17
+
18
+ ## Stash history
19
+
20
+ Open prompt history with either:
21
+
22
+ - `ctrl+alt+h`
23
+ - `/stash-history`
24
+
25
+ Prompt history now has two sources:
26
+
27
+ - stashed prompts: up to 12 recent stashed prompts (newest first)
28
+ - recent project prompts: up to 50 recent user-submitted prompts pulled from pi sessions in the current project folder
29
+
30
+ Selecting a stashed entry lets you insert it or promote it to an idea. Project prompt history entries insert into the editor. If the editor already has text, you can choose `Replace`, `Append`, or `Cancel`.
31
+
32
+ ## Editor clipboard and navigation shortcuts
33
+
34
+ - `ctrl+alt+c`: copy full editor content
35
+ - `ctrl+alt+x`: cut full editor content (copy, then clear)
36
+ - `ctrl+alt+q`: open the queued-prompt picker
37
+ - `cmd+shift+up`: move the editor cursor to the start of the first line
38
+ - `cmd+shift+down`: move the editor cursor to the end of the last line
39
+
40
+ Copy/cut actions do not modify stash state or stash history. Dragging files, folders, images, or screenshots from Finder into the custom editor inserts their path strings. Pi owns chat scrolling, selection, and fixed input behavior natively.
41
+
42
+ ## Shortcut configuration
43
+
44
+ You can override shortcut keys in the agent settings file:
45
+
46
+ ```json
47
+ {
48
+ "powerlineShortcuts": {
49
+ "stashHistory": "ctrl+alt+h",
50
+ "copyEditor": "ctrl+alt+c",
51
+ "cutEditor": "ctrl+alt+x",
52
+ "ideaCapture": null,
53
+ "queueOpen": "ctrl+alt+q",
54
+ "editorStart": "cmd+shift+up",
55
+ "editorEnd": "cmd+shift+down"
56
+ }
57
+ }
58
+ ```
59
+
60
+ After changing bindings, run `/reload`. Invalid bindings, reserved key conflicts like `Alt+S`, or duplicate conflicts fall back to safe defaults. Set a binding to `null` or `""` to disable that action. `cmd` and `command` are accepted aliases for Pi's `super` modifier for the documented Command navigation keys.
61
+
62
+ ## Editor autocomplete composition
63
+
64
+ Powerline wraps Pi's autocomplete provider so bash mode can add shell-aware suggestions. When another editor extension was already installed, powerline now passes Pi's provider through that previous editor's `setAutocompleteProvider()` first and then wraps the resulting provider. This preserves prior autocomplete-provider wrappers where possible, but it is not full render/input composition between custom editors.
@@ -0,0 +1,71 @@
1
+ # Working vibes
2
+
3
+ Transform boring "Working..." messages into themed phrases that match your style:
4
+
5
+ ```text
6
+ /vibe star trek → "Running diagnostics...", "Engaging warp drive..."
7
+ /vibe pirate → "Hoisting the sails...", "Charting course..."
8
+ /vibe zen → "Breathing deeply...", "Finding balance..."
9
+ /vibe noir → "Following the trail...", "Checking the angles..."
10
+ /vibe → Shows current theme, mode, and model
11
+ /vibe off → Disables (back to "Working...")
12
+ /vibe model → Shows current model
13
+ /vibe model openai/gpt-4o-mini → Use a different model
14
+ /vibe mode → Shows current mode (generate or file)
15
+ /vibe mode file → Switch to file-based mode (instant, no API calls)
16
+ /vibe mode generate → Switch to on-demand generation (contextual)
17
+ /vibe generate mafia 200 → Pre-generate 200 vibes and save to file
18
+ /vibe test mafia → Preview 3 sample vibes without committing the theme
19
+ ```
20
+
21
+ ## Configuration
22
+
23
+ In the agent settings file:
24
+
25
+ ```json
26
+ {
27
+ "workingVibe": "star trek", // Theme phrase
28
+ "workingVibeMode": "generate", // "generate" (on-demand) or "file" (pre-generated)
29
+ "workingVibeModel": "openai-codex/gpt-5.4-mini", // Optional: model to use (default)
30
+ "workingVibeFallback": "Working", // Optional: fallback message
31
+ "workingVibeRefreshInterval": 30, // Optional: seconds between refreshes (default 30)
32
+ "workingVibePrompt": "Generate a {theme} loading message for: {task}", // Optional: custom prompt template
33
+ "workingVibeMaxLength": 65 // Optional: max message length (default 65)
34
+ }
35
+ ```
36
+
37
+ ## Modes
38
+
39
+ | Mode | Description | Pros | Cons |
40
+ |------|-------------|------|------|
41
+ | `generate` | On-demand AI generation (default) | Contextual, hints at actual task | Model-dependent cost and latency |
42
+ | `file` | Pull from pre-generated file | Instant, zero cost, works offline | Not contextual |
43
+
44
+ **File mode setup:**
45
+
46
+ ```bash
47
+ /vibe generate mafia 200 # Generate 200 vibes, save to the agent dir
48
+ /vibe mode file # Switch to file mode
49
+ /vibe mafia # Now uses the file
50
+ ```
51
+
52
+ **How file mode works:**
53
+
54
+ 1. Vibes are loaded from `vibes/{theme}.txt` in the agent dir into memory
55
+ 2. Uses seeded shuffle (Mulberry32 PRNG): cycles through all vibes before repeating
56
+ 3. New seed each session: different order every time you restart pi
57
+ 4. Zero latency, zero cost, works offline
58
+
59
+ **Prompt template variables (generate mode only):**
60
+
61
+ - `{theme}`: the current vibe theme (e.g., "star trek", "mafia")
62
+ - `{task}`: context hint (user prompt initially, then agent's response text or tool info on refresh)
63
+ - `{exclude}`: recent vibes to avoid (auto-populated, e.g., "Don't use: vibe1, vibe2...")
64
+
65
+ **How it works:**
66
+
67
+ 1. When you send a message, shows "Channeling {theme}..." placeholder
68
+ 2. AI generates a themed message in the background (3s timeout)
69
+ 3. Message updates to the themed version (e.g., "Engaging warp drive...")
70
+ 4. During long tasks, refreshes on tool calls (rate-limited, default 30s)
71
+ 5. Cost and latency depend on your configured `workingVibeModel`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Wishcraft cockpit for the pi coding agent — powerline status, vibes, idea inbox, bash mode, and full customization.",
5
5
  "type": "module",
6
6
  "files": [
@@ -8,6 +8,7 @@
8
8
  "*.md",
9
9
  "*.json",
10
10
  "banner.png",
11
+ "docs/**/*.md",
11
12
  "src/**/*.ts",
12
13
  "src/**/*.txt",
13
14
  "bash-mode/**/*.ts",
package/queue/store.ts CHANGED
@@ -23,7 +23,9 @@ import { ACTIVE_QUEUE_STATUSES } from "./types.ts";
23
23
 
24
24
  const STORE_DIR = "powerline-footer";
25
25
  const INBOX_FILE = "inbox.jsonl";
26
+ const ARCHIVE_FILE = "inbox.archive.jsonl";
26
27
  const ALIASES_FILE = "projects.json";
28
+ const DEFAULT_SENT_RETENTION_MS = 24 * 60 * 60 * 1000;
27
29
  const LOCK_RETRY_MS = 25;
28
30
  const LOCK_TIMEOUT_MS = 2000;
29
31
 
@@ -126,10 +128,12 @@ function sleepSync(ms: number): void {
126
128
  export function getQueueStorePaths(): {
127
129
  inboxPath: string;
128
130
  aliasesPath: string;
131
+ archivePath: string;
129
132
  } {
130
133
  return {
131
134
  inboxPath: getAgentPath(STORE_DIR, INBOX_FILE),
132
135
  aliasesPath: getAgentPath(STORE_DIR, ALIASES_FILE),
136
+ archivePath: getAgentPath(STORE_DIR, ARCHIVE_FILE),
133
137
  };
134
138
  }
135
139
 
@@ -155,13 +159,23 @@ export function createQueueItem(
155
159
  export class PowerlineQueueStore {
156
160
  private readonly inboxPath: string;
157
161
  private readonly aliasesPath: string;
162
+ private readonly archivePath: string;
163
+ /** How long completed (sent) items stay in the inbox before pruning/archiving. */
164
+ private sentRetentionMs: number = DEFAULT_SENT_RETENTION_MS;
158
165
 
159
166
  constructor(
160
167
  inboxPath: string = getQueueStorePaths().inboxPath,
161
168
  aliasesPath: string = getQueueStorePaths().aliasesPath,
169
+ archivePath: string = getQueueStorePaths().archivePath,
162
170
  ) {
163
171
  this.inboxPath = inboxPath;
164
172
  this.aliasesPath = aliasesPath;
173
+ this.archivePath = archivePath;
174
+ }
175
+
176
+ /** Configure the retention window for completed items (clamped to >= 1 hour). */
177
+ setSentRetentionMs(ms: number): void {
178
+ this.sentRetentionMs = Math.max(60 * 60 * 1000, Number.isFinite(ms) ? ms : DEFAULT_SENT_RETENTION_MS);
165
179
  }
166
180
 
167
181
  list(): PowerlineQueueItem[] {
@@ -323,13 +337,16 @@ export class PowerlineQueueStore {
323
337
  }
324
338
  }
325
339
 
326
- private writeItems(items: readonly PowerlineQueueItem[]): void {
340
+ private writeItems(
341
+ items: readonly PowerlineQueueItem[],
342
+ now: number = Date.now(),
343
+ ): void {
327
344
  mkdirSync(dirname(this.inboxPath), { recursive: true });
345
+ const retentionCutoff = now - this.sentRetentionMs;
328
346
  const activeOrRecent = items
329
347
  .filter(
330
348
  (item) =>
331
- item.status !== "sent" ||
332
- Date.now() - item.updatedAt < 24 * 60 * 60 * 1000,
349
+ item.status !== "sent" || item.updatedAt >= retentionCutoff,
333
350
  )
334
351
  .map((item) => JSON.stringify(item))
335
352
  .join("\n");
@@ -339,6 +356,58 @@ export class PowerlineQueueStore {
339
356
  );
340
357
  }
341
358
 
359
+ /**
360
+ * Move completed (sent) items older than the retention window out of the
361
+ * inbox into the archive file, keeping the inbox lean. Returns how many
362
+ * items were archived and how many sent items remain in the inbox.
363
+ *
364
+ * `archiveSentItems` and `writeItems` share one `now` snapshot so a sent
365
+ * item counted as remaining cannot be pruned on the same write.
366
+ */
367
+ archiveSentItems(olderThanMs: number = this.sentRetentionMs): {
368
+ archived: number;
369
+ remainingSent: number;
370
+ } {
371
+ return this.withStoreLock(() => {
372
+ const now = Date.now();
373
+ const items = this.list();
374
+ // writeItems prunes sent items older than sentRetentionMs on every write.
375
+ // If the requested archive window were wider than that, items in the gap
376
+ // would be silently dropped (neither archived nor kept) while still being
377
+ // reported as "remaining". Clamp the cutoff so anything past the inbox
378
+ // retention window is archived rather than lost.
379
+ const effectiveOlderThanMs = Math.min(olderThanMs, this.sentRetentionMs);
380
+ const cutoff = now - effectiveOlderThanMs;
381
+ const archived: PowerlineQueueItem[] = [];
382
+ const kept: PowerlineQueueItem[] = [];
383
+ let remainingSent = 0;
384
+
385
+ for (const item of items) {
386
+ if (item.status === "sent" && item.updatedAt < cutoff) {
387
+ archived.push(item);
388
+ } else {
389
+ kept.push(item);
390
+ if (item.status === "sent") remainingSent += 1;
391
+ }
392
+ }
393
+
394
+ if (archived.length > 0) {
395
+ mkdirSync(dirname(this.archivePath), { recursive: true });
396
+ const existing = existsSync(this.archivePath)
397
+ ? readFileSync(this.archivePath, "utf-8")
398
+ : "";
399
+ const lines = archived.map((item) => JSON.stringify(item)).join("\n");
400
+ this.writeAtomic(
401
+ this.archivePath,
402
+ `${existing.trimEnd() ? `${existing.trimEnd()}\n` : ""}${lines}\n`,
403
+ );
404
+ this.writeItems(kept, now);
405
+ }
406
+
407
+ return { archived: archived.length, remainingSent };
408
+ });
409
+ }
410
+
342
411
  private writeJson(path: string, value: unknown): void {
343
412
  mkdirSync(dirname(path), { recursive: true });
344
413
  this.writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
@@ -9,6 +9,7 @@ import {
9
9
  } from "./primitives.ts";
10
10
  import { normalizeSegmentOptions } from "./segment-options.ts";
11
11
  import { normalizeStatusLineSegmentId } from "./segment-ids.ts";
12
+ import { isNotificationExtensionStatus } from "./extension-statuses.ts";
12
13
  import type {
13
14
  ColorScheme,
14
15
  CustomSegmentConfig,
@@ -180,3 +181,47 @@ export function normalizeCustomItems(raw: unknown): CustomStatusItem[] {
180
181
 
181
182
  return [...deduped.values()];
182
183
  }
184
+
185
+ /**
186
+ * `powerline.customItems.auto` — when `customItems` is an object/record with
187
+ * an `auto: true` key, offer live extension status keys as segments without
188
+ * listing each one explicitly (the ChefBar status bridge).
189
+ */
190
+ export function normalizeCustomItemsAuto(raw: unknown): boolean {
191
+ if (isRecord(raw)) return raw.auto === true;
192
+ return false;
193
+ }
194
+
195
+ /**
196
+ * Build the effective custom item list at render time. When `enabled`, every
197
+ * non-notification extension status key becomes an implicit right-aligned
198
+ * custom item unless it is already claimed by an explicit item, is an
199
+ * excluded internal key, or is not a valid segment id. Auto items are marked
200
+ * `excludeFromExtensionStatuses` so each status renders exactly once.
201
+ */
202
+ export function deriveAutoCustomItems(
203
+ customItems: readonly CustomStatusItem[],
204
+ extensionStatuses: ReadonlyMap<string, string>,
205
+ enabled: boolean,
206
+ excludedStatusKeys: ReadonlySet<string>,
207
+ ): CustomStatusItem[] {
208
+ if (!enabled) return [...customItems];
209
+
210
+ const claimed = new Set(customItems.map((item) => item.statusKey));
211
+ const auto: CustomStatusItem[] = [];
212
+ for (const [statusKey, value] of extensionStatuses) {
213
+ if (claimed.has(statusKey) || excludedStatusKeys.has(statusKey)) continue;
214
+ if (!normalizeCustomItemId(statusKey)) continue;
215
+ if (isNotificationExtensionStatus(value)) continue;
216
+
217
+ auto.push({
218
+ id: statusKey,
219
+ statusKey,
220
+ position: "right",
221
+ hideWhenMissing: true,
222
+ excludeFromExtensionStatuses: true,
223
+ });
224
+ }
225
+
226
+ return [...customItems, ...auto];
227
+ }
@@ -1,13 +1,16 @@
1
1
  import {
2
2
  normalizeCustomItems,
3
+ normalizeCustomItemsAuto,
3
4
  normalizeCustomPresets,
4
5
  normalizeCustomSegments,
5
6
  } from "./custom-items.ts";
6
7
  import {
7
8
  isRecord,
8
9
  normalizeCaptureSigil,
10
+ normalizeCostAlert,
9
11
  normalizePlacement,
10
12
  normalizePreset,
13
+ normalizeRetentionHours,
11
14
  normalizeSegmentLabels,
12
15
  normalizeSeparator,
13
16
  } from "./primitives.ts";
@@ -37,7 +40,11 @@ export interface PowerlineConfig {
37
40
  invalidPlacement: string | null;
38
41
  welcome: boolean;
39
42
  stashSharpSShortcut: boolean;
40
- queue: { captureSigil: string | false };
43
+ /** Session cost threshold (USD) for a once-per-session warning. */
44
+ costAlert: number | null;
45
+ /** Auto-promote live extension status keys into `custom:<key>` segments. */
46
+ customItemsAuto: boolean;
47
+ queue: { captureSigil: string | false; retentionHours: number };
41
48
  /** User-defined computed segments (command/env/static), keyed by id */
42
49
  segments: Record<string, CustomSegmentConfig>;
43
50
  /** User-defined presets, keyed by name */
@@ -63,7 +70,9 @@ export function parsePowerlineConfig(
63
70
  invalidPlacement: null,
64
71
  welcome: true,
65
72
  stashSharpSShortcut: false,
66
- queue: { captureSigil: "#" },
73
+ costAlert: null,
74
+ customItemsAuto: false,
75
+ queue: { captureSigil: "#", retentionHours: 24 },
67
76
  segments: {},
68
77
  presets: {},
69
78
  segmentLabels: {},
@@ -90,7 +99,10 @@ export function parsePowerlineConfig(
90
99
  );
91
100
  const { placement, invalidPlacement } = normalizePlacement(value.placement);
92
101
  const queue = isRecord(value.queue)
93
- ? { captureSigil: normalizeCaptureSigil(value.queue.captureSigil) }
102
+ ? {
103
+ captureSigil: normalizeCaptureSigil(value.queue.captureSigil),
104
+ retentionHours: normalizeRetentionHours(value.queue.retentionHours),
105
+ }
94
106
  : defaultConfig.queue;
95
107
  const customItemIds = new Set(customItems.map((item) => item.id));
96
108
  const customPresetDefs = normalizeCustomPresets(
@@ -119,6 +131,10 @@ export function parsePowerlineConfig(
119
131
  invalidPlacement,
120
132
  welcome: value.welcome !== false,
121
133
  stashSharpSShortcut: value.stashSharpSShortcut === true,
134
+ costAlert: normalizeCostAlert(value.costAlert),
135
+ customItemsAuto:
136
+ normalizeCustomItemsAuto(value.customItems) ||
137
+ value.customItemsAuto === true,
122
138
  queue,
123
139
  segments: customSegments,
124
140
  presets: customPresetDefs,
@@ -6,6 +6,11 @@ export { parsePowerlineConfig } from "./parse.ts";
6
6
  export { mergeSegmentOptions } from "./segment-options.ts";
7
7
  export { mergeSegmentsWithCustomItems } from "./layout.ts";
8
8
  export {
9
+ deriveAutoCustomItems,
10
+ normalizeCustomItemsAuto,
11
+ } from "./custom-items.ts";
12
+ export {
13
+ nextPowerlineSettingWithCustomPreset,
9
14
  nextPowerlineSettingWithPreset,
10
15
  nextPowerlineSettingWithOptions,
11
16
  } from "./settings-patch.ts";
@@ -40,7 +40,7 @@ export function normalizePlacement(value: unknown): {
40
40
  };
41
41
  }
42
42
 
43
- const SEPARATOR_STYLES = [
43
+ export const SEPARATOR_STYLES = [
44
44
  "powerline",
45
45
  "powerline-thin",
46
46
  "slash",
@@ -97,6 +97,18 @@ export function normalizeCaptureSigil(value: unknown): string | false {
97
97
  return normalized && !/\s/.test(normalized) ? normalized : "#";
98
98
  }
99
99
 
100
+ export function normalizeRetentionHours(value: unknown): number {
101
+ if (typeof value !== "number" || !Number.isFinite(value)) return 24;
102
+ return Math.min(24 * 365, Math.max(1, Math.floor(value)));
103
+ }
104
+
105
+ export function normalizeCostAlert(value: unknown): number | null {
106
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
107
+ return null;
108
+ }
109
+ return Math.round(value * 100) / 100;
110
+ }
111
+
100
112
  export function normalizeCustomSegmentType(
101
113
  value: unknown,
102
114
  ): "command" | "env" | "static" | null {
@@ -115,41 +115,46 @@ export function normalizeSegmentOptions(
115
115
  ...(typeof raw.openPorts.includeUdp === "boolean"
116
116
  ? { includeUdp: raw.openPorts.includeUdp }
117
117
  : {}),
118
+ ...(typeof raw.openPorts.host === "string" && raw.openPorts.host.trim()
119
+ ? { host: raw.openPorts.host.trim() }
120
+ : {}),
118
121
  };
119
122
  }
120
123
 
121
124
  if (isRecord(raw.tps)) {
122
125
  options.tps = {
123
- // De lookback gebruikt [0.5·windowMs, 2·windowMs] binnen een 5s-ring, dus
124
- // windowMs boven 2500 zou nooit een sample vinden. Afkappen op 2500.
125
126
  ...(typeof raw.tps.windowMs === "number" &&
126
- Number.isFinite(raw.tps.windowMs) &&
127
- raw.tps.windowMs > 0
128
- ? { windowMs: Math.min(raw.tps.windowMs, 2500) }
129
- : {}),
130
- ...(raw.tps.mode === "both" ||
131
- raw.tps.mode === "out" ||
132
- raw.tps.mode === "in"
133
- ? { mode: raw.tps.mode }
134
- : {}),
135
- ...(typeof raw.tps.hideIdle === "boolean"
136
- ? { hideIdle: raw.tps.hideIdle }
127
+ Number.isFinite(raw.tps.windowMs)
128
+ ? { windowMs: Math.min(5000, Math.max(500, Math.floor(raw.tps.windowMs))) }
137
129
  : {}),
138
130
  };
139
131
  }
140
132
 
141
- // GR: Generic per-segment template override (segmentOptions.<seg>.template) that
142
- // applies to any segment id (incl. custom items). Kept alongside the typed fields
143
- // above; read centrally at render time via the options record.
144
- const optionsAsRecord = options as unknown as Record<
145
- string,
146
- { template?: string }
133
+ // Generic `template` override for every segment option group:
134
+ // segmentOptions.<id>.template: "{value} tok/s" replaces the value text.
135
+ const TEMPLATE_OPTION_KEYS = [
136
+ "model",
137
+ "path",
138
+ "git",
139
+ "time",
140
+ "cost",
141
+ "context",
142
+ "cache_read",
143
+ "openPorts",
144
+ "tps",
145
+ ] as const;
146
+ const genericOptions = options as Record<
147
+ (typeof TEMPLATE_OPTION_KEYS)[number],
148
+ { template?: string } | undefined
147
149
  >;
148
- for (const [seg, segRaw] of Object.entries(raw)) {
149
- if (!isRecord(segRaw)) continue;
150
- if (typeof segRaw.template !== "string" || !segRaw.template.trim()) continue;
151
- const holder = isRecord(optionsAsRecord[seg]) ? optionsAsRecord[seg] : {};
152
- optionsAsRecord[seg] = { ...holder, template: segRaw.template.trim() };
150
+ for (const key of TEMPLATE_OPTION_KEYS) {
151
+ const rawOpt = raw[key];
152
+ if (isRecord(rawOpt) && typeof rawOpt.template === "string") {
153
+ const template = rawOpt.template.trim();
154
+ if (template) {
155
+ genericOptions[key] = { ...genericOptions[key], template };
156
+ }
157
+ }
153
158
  }
154
159
 
155
160
  return options;
@@ -1,6 +1,6 @@
1
1
  import { isRecord } from "./primitives.ts";
2
2
  import type { PowerlineConfig } from "./parse.ts";
3
- import type { StatusLinePreset } from "./types.ts";
3
+ import type { CustomPresetConfig, StatusLinePreset } from "./types.ts";
4
4
 
5
5
  export function nextPowerlineSettingWithPreset(
6
6
  existingPowerlineSetting: unknown,
@@ -12,10 +12,31 @@ export function nextPowerlineSettingWithPreset(
12
12
  return { ...existingPowerlineSetting, preset };
13
13
  }
14
14
 
15
+ export function nextPowerlineSettingWithCustomPreset(
16
+ existingPowerlineSetting: unknown,
17
+ name: string,
18
+ presetDef: CustomPresetConfig,
19
+ ): unknown {
20
+ if (!isRecord(existingPowerlineSetting)) {
21
+ return { preset: name, presets: { [name]: presetDef } };
22
+ }
23
+ const existingPresets = isRecord(existingPowerlineSetting.presets)
24
+ ? existingPowerlineSetting.presets
25
+ : {};
26
+ return {
27
+ ...existingPowerlineSetting,
28
+ preset: name,
29
+ presets: { ...existingPresets, [name]: presetDef },
30
+ };
31
+ }
32
+
15
33
  export function nextPowerlineSettingWithOptions(
16
34
  existingPowerlineSetting: unknown,
17
35
  updates: Partial<
18
- Pick<PowerlineConfig, "welcome" | "stashSharpSShortcut" | "placement">
36
+ Pick<
37
+ PowerlineConfig,
38
+ "welcome" | "stashSharpSShortcut" | "placement" | "disabledSegments"
39
+ >
19
40
  >,
20
41
  currentPreset: StatusLinePreset,
21
42
  ): unknown {
@@ -80,14 +80,23 @@ export type PowerlinePlacement = "above" | "below";
80
80
  export type StatusLinePreset =
81
81
  "default" | "minimal" | "compact" | "full" | "nerd" | "ascii" | "chef";
82
82
 
83
+ // Optional `{value}` template override shared by all segment option groups.
84
+ // Replaces `{value}` in the rendered value text (e.g. "{value} tok/s").
85
+ export interface SegmentFormatOption {
86
+ template?: string;
87
+ }
88
+
83
89
  // Per-segment options
84
90
  export interface StatusLineSegmentOptions {
85
- model?: { showThinkingLevel?: boolean; display?: "name" | "qualified" };
86
- path?: {
91
+ model?: SegmentFormatOption & {
92
+ showThinkingLevel?: boolean;
93
+ display?: "name" | "qualified";
94
+ };
95
+ path?: SegmentFormatOption & {
87
96
  mode?: "basename" | "abbreviated" | "full";
88
97
  maxLength?: number;
89
98
  };
90
- git?: {
99
+ git?: SegmentFormatOption & {
91
100
  showBranch?: boolean;
92
101
  showStaged?: boolean;
93
102
  showUnstaged?: boolean;
@@ -103,25 +112,26 @@ export interface StatusLineSegmentOptions {
103
112
  /** Max length of the commit subject before truncation. Default 24. */
104
113
  maxCommitSubjectLength?: number;
105
114
  };
106
- time?: { format?: "12h" | "24h"; showSeconds?: boolean };
107
- cost?: {
115
+ time?: SegmentFormatOption & { format?: "12h" | "24h"; showSeconds?: boolean };
116
+ cost?: SegmentFormatOption & {
108
117
  subscriptionDisplay?: "subscription" | "reported-cost" | "both";
109
118
  currency?: CostCurrencyCode;
110
119
  };
111
- context?: { format?: "full" | "percent" };
112
- cache_read?: { format?: "tokens" | "percent" | "both" };
113
- openPorts?: {
114
- /** Include UDP listeners (mDNS/DHCP/ephemeral) in the count. Default false. */ includeUdp?: boolean;
120
+ context?: SegmentFormatOption & { format?: "full" | "percent" };
121
+ cache_read?: SegmentFormatOption & { format?: "tokens" | "percent" | "both" };
122
+ openPorts?: SegmentFormatOption & {
123
+ /** Include UDP listeners (mDNS/DHCP/ephemeral) in the count. Default false. */
124
+ includeUdp?: boolean;
125
+ /**
126
+ * SSH host to probe instead of the local machine (fleet open-ports).
127
+ * Best-effort, opt-in: requires passwordless/agent SSH to that host and
128
+ * falls back to `?` when the probe cannot run.
129
+ */
130
+ host?: string;
115
131
  };
116
- tps?: {
117
- /** Rolling window length (ms) for the tokens/sec lookback. Default 1000. */
132
+ tps?: SegmentFormatOption & {
133
+ /** Sliding rate window length in ms (default 1000; wider = smoother, e.g. 2000 for fast models). */
118
134
  windowMs?: number;
119
- /** Which rates to show. Default "both". */
120
- mode?: "both" | "out" | "in";
121
- /** Hide the segment entirely when idle (both rates 0). Default false (show dim "0"). */
122
- hideIdle?: boolean;
123
- /** Render template applied centrally; {value} is the plain segment content. Overrides segmentLabels. */
124
- template?: string;
125
135
  };
126
136
  }
127
137
 
@@ -241,12 +251,17 @@ export interface SegmentContext {
241
251
  extensionStatuses: ReadonlyMap<string, string>;
242
252
  hiddenExtensionStatusKeys: ReadonlySet<string>;
243
253
  customItemsById: ReadonlyMap<string, CustomStatusItem>;
254
+ /** Explicit custom items + auto-promoted status items (customItems.auto). */
255
+ effectiveCustomItems: readonly CustomStatusItem[];
244
256
 
245
257
  // Options
246
258
  options: StatusLineSegmentOptions;
247
259
  /** Per-segment custom text labels (from powerline.segmentLabels). */
248
260
  segmentLabels: ReadonlyMap<string, string>;
249
261
 
262
+ /** Daily token budget progress (wishcraft.tokenBudget.daily). */
263
+ tokenBudget?: { dailyLimit: number | null; dailyUsed: number };
264
+
250
265
  // Theming
251
266
  theme: ThemeLike;
252
267
  colors: ColorScheme;
@@ -43,6 +43,7 @@ export const ensureShellSession = async (
43
43
  () => requestStatusRender(rt),
44
44
  (command, cwd) =>
45
45
  appendProjectHistory(rt.currentCtx?.cwd ?? process.cwd(), command, cwd),
46
+ rt.bashModeSettings.initScript,
46
47
  );
47
48
  }
48
49
  await rt.shellSession.ensureReady();