@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,163 @@
1
+ /**
2
+ * Shared overlay chrome: rounded box, accent title, dim hint, and a
3
+ * substring filter over SelectList items (label + value + description).
4
+ * Pi's SelectList.setFilter is prefix-only on `value`; overlays must not
5
+ * inherit that.
6
+ */
7
+ import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ matchesKey,
10
+ type SelectItem,
11
+ SelectList,
12
+ truncateToWidth,
13
+ } from "@earendil-works/pi-tui";
14
+
15
+ const NONE_VALUE = "__none__";
16
+
17
+ export function overlaySelectListTheme(theme: Theme) {
18
+ return {
19
+ selectedPrefix: (text: string) => theme.fg("accent", text),
20
+ selectedText: (text: string) => theme.fg("accent", text),
21
+ description: (text: string) => theme.fg("muted", text),
22
+ scrollInfo: (text: string) => theme.fg("dim", text),
23
+ noMatch: (text: string) => theme.fg("warning", text),
24
+ };
25
+ }
26
+
27
+ /** Case-insensitive substring match on value, label, and description. */
28
+ export function applyOverlayFilter(
29
+ items: readonly SelectItem[],
30
+ query: string,
31
+ ): SelectItem[] {
32
+ const q = query.trim().toLowerCase();
33
+ if (!q) return [...items];
34
+ return items.filter((item) => {
35
+ const hay = `${item.value}\n${item.label}\n${item.description ?? ""}`;
36
+ return hay.toLowerCase().includes(q);
37
+ });
38
+ }
39
+
40
+ export function isOverlayPrintable(data: string): boolean {
41
+ return data.length === 1 && data >= " " && data <= "~";
42
+ }
43
+
44
+ export function applyOverlayQueryKey(query: string, data: string): string | null {
45
+ if (data === "\x15") return "";
46
+ if (data === "\x7f" || data === "\b" || matchesKey(data, "backspace")) {
47
+ return query.slice(0, -1);
48
+ }
49
+ if (isOverlayPrintable(data)) return query + data;
50
+ return null;
51
+ }
52
+
53
+ export function renderOverlayBox(
54
+ theme: Theme,
55
+ width: number,
56
+ title: string,
57
+ body: readonly string[],
58
+ hint: string,
59
+ ): string[] {
60
+ const innerWidth = Math.max(1, width - 2);
61
+ const border = (text: string) => theme.fg("dim", text);
62
+ const wrapRow = (text: string): string =>
63
+ `${border("│")}${truncateToWidth(text, innerWidth, "…", true)}${border("│")}`;
64
+ const lines: string[] = [];
65
+ lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
66
+ lines.push(wrapRow(theme.fg("accent", theme.bold(title))));
67
+ lines.push(border(`├${"─".repeat(innerWidth)}┤`));
68
+ for (const line of body) lines.push(wrapRow(line));
69
+ lines.push(border(`├${"─".repeat(innerWidth)}┤`));
70
+ lines.push(wrapRow(theme.fg("dim", hint)));
71
+ lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
72
+ return lines;
73
+ }
74
+
75
+ function listForQuery(
76
+ items: readonly SelectItem[],
77
+ query: string,
78
+ maxVisible: number,
79
+ theme: Theme,
80
+ ): SelectList {
81
+ const filtered = applyOverlayFilter(items, query);
82
+ const visible =
83
+ filtered.length > 0
84
+ ? filtered
85
+ : [
86
+ {
87
+ label: `geen match voor '${query}'`,
88
+ value: NONE_VALUE,
89
+ },
90
+ ];
91
+ const list = new SelectList(
92
+ visible,
93
+ Math.max(1, Math.min(maxVisible, visible.length)),
94
+ overlaySelectListTheme(theme),
95
+ );
96
+ return list;
97
+ }
98
+
99
+ export async function showSelectOverlay(
100
+ ctx: any,
101
+ title: string,
102
+ hint: string,
103
+ items: SelectItem[],
104
+ maxVisible: number,
105
+ ): Promise<SelectItem | null> {
106
+ return ctx.ui.custom(
107
+ (
108
+ tui: any,
109
+ theme: Theme,
110
+ _keybindings: any,
111
+ done: (result: SelectItem | null) => void,
112
+ ) => {
113
+ let query = "";
114
+ let selectList = listForQuery(items, query, maxVisible, theme);
115
+ const bind = () => {
116
+ selectList.onSelect = (item) => {
117
+ if (item.value === NONE_VALUE) return;
118
+ done(item);
119
+ };
120
+ selectList.onCancel = () => done(null);
121
+ };
122
+ bind();
123
+
124
+ const filterHint = () => {
125
+ const extra = query
126
+ ? `filter '${query}' · ctrl+u clear`
127
+ : "type to filter";
128
+ return `${hint} · ${extra}`;
129
+ };
130
+
131
+ return {
132
+ render: (width: number) =>
133
+ renderOverlayBox(
134
+ theme,
135
+ width,
136
+ title,
137
+ selectList.render(Math.max(1, width - 2)),
138
+ filterHint(),
139
+ ),
140
+ invalidate: () => selectList.invalidate(),
141
+ handleInput: (data: string) => {
142
+ const next = applyOverlayQueryKey(query, data);
143
+ if (next !== null) {
144
+ query = next;
145
+ selectList = listForQuery(items, query, maxVisible, theme);
146
+ bind();
147
+ tui.requestRender();
148
+ return;
149
+ }
150
+ selectList.handleInput(data);
151
+ tui.requestRender();
152
+ },
153
+ };
154
+ },
155
+ {
156
+ overlay: true,
157
+ overlayOptions: () => ({
158
+ verticalAlign: "center",
159
+ horizontalAlign: "center",
160
+ }),
161
+ },
162
+ );
163
+ }
@@ -6,6 +6,7 @@ import {
6
6
  showSegmentNavigator,
7
7
  showSelectOverlay,
8
8
  } from "./menu-views.ts";
9
+ import { showTpsOverlay } from "./token-overlays.ts";
9
10
  import {
10
11
  assertPowerlineMenuBounds,
11
12
  type PowerlineMenuNode,
@@ -47,10 +48,7 @@ async function activatePowerlineMenuAction(
47
48
  return;
48
49
  }
49
50
  if (id === "tps") {
50
- const live = process.env.POWERLINE_TPS
51
- ? `(override ${process.env.POWERLINE_TPS})`
52
- : "(live 1s window)";
53
- ctx.ui.notify(`TPS ${live}`, "info");
51
+ await showTpsOverlay(rt, ctx);
54
52
  return;
55
53
  }
56
54
  if (id === "toggle") {
@@ -0,0 +1,82 @@
1
+ import type { SelectItem } from "@earendil-works/pi-tui";
2
+
3
+ import { config } from "../core/state.ts";
4
+ import type { RuntimeState } from "../core/types.ts";
5
+ import {
6
+ summarizeTpsRing,
7
+ tpsOverlayLines,
8
+ tpsSamples,
9
+ } from "../../usage/tps-ring.ts";
10
+ import {
11
+ formatUsageOverlayLines,
12
+ loadUsageFileFromDisk,
13
+ summarizeUsageOverlay,
14
+ } from "../../usage/usage-store.ts";
15
+ import { parseTokenBudget } from "../../usage/token-budget.ts";
16
+ import { getSessionTotalCost } from "../../usage/ledger.ts";
17
+ import { readSettings } from "../settings/settings-io.ts";
18
+ import { showSelectOverlay } from "./overlay-chrome.ts";
19
+
20
+ function sessionTotals(rt: RuntimeState, ctx: any) {
21
+ const events = rt.sessionBranchCache.get(ctx.sessionManager);
22
+ const snap = rt.tokenStatsCache.get(events);
23
+ return {
24
+ input: snap.input,
25
+ output: snap.output,
26
+ cacheRead: snap.cacheRead,
27
+ cacheWrite: snap.cacheWrite,
28
+ cost: getSessionTotalCost(snap),
29
+ };
30
+ }
31
+
32
+ export function buildTpsOverlayItems(rt: RuntimeState, ctx: any): SelectItem[] {
33
+ const events = rt.sessionBranchCache.get(ctx.sessionManager);
34
+ const snap = rt.tokenStatsCache.get(events);
35
+ const summary = summarizeTpsRing(
36
+ tpsSamples,
37
+ Date.now(),
38
+ { input: snap.input, output: snap.output },
39
+ {
40
+ windowMs: config.segmentOptions?.tps?.windowMs ?? 1000,
41
+ override: process.env.POWERLINE_TPS,
42
+ },
43
+ );
44
+ const lines = tpsOverlayLines(summary);
45
+ return lines.map((line) => ({ label: line, value: line }));
46
+ }
47
+
48
+ export function buildUsageOverlayItems(rt: RuntimeState, ctx: any): SelectItem[] {
49
+ const wishcraft = readSettings(ctx.cwd ?? process.cwd()).wishcraft;
50
+ const budget = parseTokenBudget(wishcraft);
51
+ const summary = summarizeUsageOverlay({
52
+ file: loadUsageFileFromDisk(),
53
+ session: sessionTotals(rt, ctx),
54
+ dailyLimit: budget.daily,
55
+ });
56
+ const lines = formatUsageOverlayLines(summary);
57
+ return lines.map((line) => ({ label: line, value: line }));
58
+ }
59
+
60
+ export async function showTpsOverlay(rt: RuntimeState, ctx: any): Promise<void> {
61
+ const items = buildTpsOverlayItems(rt, ctx);
62
+ const picked = await showSelectOverlay(
63
+ ctx,
64
+ "TPS",
65
+ "same 1s window as the segment · enter copy · esc close",
66
+ items,
67
+ Math.min(items.length, 12),
68
+ );
69
+ if (picked) ctx.ui.notify(picked.value, "info");
70
+ }
71
+
72
+ export async function showUsageOverlay(rt: RuntimeState, ctx: any): Promise<void> {
73
+ const items = buildUsageOverlayItems(rt, ctx);
74
+ const picked = await showSelectOverlay(
75
+ ctx,
76
+ "Usage",
77
+ "today / week from wishcraft-usage.json · enter copy · esc close",
78
+ items,
79
+ Math.min(items.length, 16),
80
+ );
81
+ if (picked) ctx.ui.notify(picked.value, "info");
82
+ }
@@ -2,6 +2,7 @@ import {
2
2
  WelcomeComponent,
3
3
  WelcomeHeader,
4
4
  discoverLoadedCounts,
5
+ discoverWhatsNew,
5
6
  getRecentSessions,
6
7
  } from "../../welcome/index.ts";
7
8
  import { estimateInitialContextTokens } from "../../usage/context.ts";
@@ -19,6 +20,7 @@ export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
19
20
  const queueCount = queueSummary.queueCount + queueSummary.ideaCount;
20
21
  const hasStash =
21
22
  rt.stashedEditorText !== null || rt.stashedPromptHistory.length > 0;
23
+ const whatsNew = discoverWhatsNew();
22
24
 
23
25
  const header = new WelcomeHeader(
24
26
  modelName,
@@ -28,6 +30,7 @@ export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
28
30
  initialContextTokens,
29
31
  queueCount,
30
32
  hasStash,
33
+ whatsNew,
31
34
  );
32
35
  rt.welcomeHeaderActive = true;
33
36
 
@@ -83,6 +86,7 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
83
86
  const queueCount = queueSummary.queueCount + queueSummary.ideaCount;
84
87
  const hasStash =
85
88
  rt.stashedEditorText !== null || rt.stashedPromptHistory.length > 0;
89
+ const whatsNew = discoverWhatsNew();
86
90
 
87
91
  ctx.ui
88
92
  .custom(
@@ -100,18 +104,17 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
100
104
  initialContextTokens,
101
105
  queueCount,
102
106
  hasStash,
107
+ whatsNew,
103
108
  );
104
109
 
105
110
  let countdown = 30;
106
111
  let dismissed = false;
107
112
  let interval: ReturnType<typeof setInterval> | null = null;
108
- let glow: ReturnType<typeof setInterval> | null = null;
109
113
 
110
114
  const dismiss = () => {
111
115
  if (dismissed) return;
112
116
  dismissed = true;
113
117
  if (interval) clearInterval(interval);
114
- if (glow) clearInterval(glow);
115
118
  rt.dismissWelcomeOverlay = null;
116
119
  done();
117
120
  };
@@ -124,12 +127,6 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
124
127
  if (countdown <= 0) dismiss();
125
128
  }, 1000);
126
129
 
127
- // wensballon: zachte vlam-flikker, alleen tijdens de overlay
128
- glow = setInterval(() => {
129
- if (dismissed) return;
130
- tui.requestRender();
131
- }, 140);
132
-
133
130
  rt.dismissWelcomeOverlay = dismiss;
134
131
 
135
132
  if (rt.welcomeOverlayShouldDismiss) {
@@ -145,7 +142,6 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
145
142
  dispose: () => {
146
143
  dismissed = true;
147
144
  if (interval) clearInterval(interval);
148
- if (glow) clearInterval(glow);
149
145
  },
150
146
  };
151
147
  },
@@ -5,6 +5,7 @@ import type {
5
5
  StatusLineSegment,
6
6
  StatusLineSegmentId,
7
7
  } from "../config/types.ts";
8
+ import { getIcons } from "../theme/icons.ts";
8
9
  import {
9
10
  modelSegment,
10
11
  shellModeSegment,
@@ -43,6 +44,97 @@ import {
43
44
  // Segment Registry
44
45
  // ═══════════════════════════════════════════════════════════════════════════
45
46
 
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+ // Segment decoration (labels + templates)
49
+ //
50
+ // `powerline.segmentLabels.<id>` inserts a label between the icon and the
51
+ // value for ANY segment; `segmentOptions.<id>.template` replaces the value
52
+ // text ("{value}" is substituted). Both are applied centrally here so every
53
+ // segment honors them uniformly (previously only tps/open_ports did).
54
+ // ═══════════════════════════════════════════════════════════════════════════
55
+
56
+ const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
57
+
58
+ function stripAnsi(text: string): string {
59
+ return text.replace(ANSI_ESCAPE_RE, "");
60
+ }
61
+
62
+ /** Index of the first visible (non-escape) character in a rendered string. */
63
+ function firstVisibleCharIndex(content: string): number {
64
+ for (let i = 0; i < content.length; i++) {
65
+ if (content[i] === "\x1b") {
66
+ const match = ANSI_ESCAPE_RE.exec(content.slice(i));
67
+ if (match) {
68
+ i += match[0].length - 1;
69
+ continue;
70
+ }
71
+ }
72
+ return i;
73
+ }
74
+ return content.length;
75
+ }
76
+
77
+ function leadingIconAndValue(
78
+ visible: string,
79
+ ): { icon: string; value: string } {
80
+ const trimmed = visible.trimStart();
81
+ const spaceIndex = trimmed.indexOf(" ");
82
+ if (spaceIndex <= 0) return { icon: "", value: trimmed };
83
+
84
+ const candidate = trimmed.slice(0, spaceIndex);
85
+ const icons = getIcons();
86
+ const iconValues = new Set(
87
+ Object.values(icons).filter((icon) => icon.length > 0),
88
+ );
89
+ if (!iconValues.has(candidate)) return { icon: "", value: trimmed };
90
+ return { icon: candidate, value: trimmed.slice(spaceIndex + 1).trimStart() };
91
+ }
92
+
93
+ /**
94
+ * Apply `segmentLabels` (label between icon and value) and
95
+ * `segmentOptions.<id>.template` ({value} substitution) to a rendered
96
+ * segment. Label-only insertion preserves the original ANSI styling spans;
97
+ * templates rebuild the content with the segment's leading style.
98
+ */
99
+ export function applySegmentDecoration(
100
+ id: StatusLineSegmentId,
101
+ ctx: SegmentContext,
102
+ rendered: RenderedSegment,
103
+ ): RenderedSegment {
104
+ const label = ctx.segmentLabels?.get(id);
105
+ const optionsForId = (
106
+ ctx.options as Record<string, { template?: string } | undefined> | undefined
107
+ )?.[id];
108
+ const template = optionsForId?.template;
109
+ if (!label && !template) return rendered;
110
+ if (!rendered.visible || !rendered.content) return rendered;
111
+
112
+ const visible = stripAnsi(rendered.content);
113
+ const { icon, value } = leadingIconAndValue(visible);
114
+ const firstVisibleIndex = firstVisibleCharIndex(rendered.content);
115
+ const styledPrefix = rendered.content.slice(0, firstVisibleIndex);
116
+
117
+ if (!template) {
118
+ // Label-only: pure insertion right after the icon, so every inner ANSI
119
+ // span of the original content is preserved untouched.
120
+ let insertAt = firstVisibleIndex + icon.length;
121
+ if (rendered.content[insertAt] === " ") insertAt += 1;
122
+ return {
123
+ content:
124
+ rendered.content.slice(0, insertAt) +
125
+ `${label} ` +
126
+ rendered.content.slice(insertAt),
127
+ visible: true,
128
+ };
129
+ }
130
+
131
+ // Template (with optional label): rebuild with the segment's leading style.
132
+ let newValue = template.replaceAll("{value}", value);
133
+ if (label) newValue = `${label} ${newValue}`;
134
+ const content = `${styledPrefix}${icon ? `${icon} ` : ""}${newValue}${rendered.content.endsWith("\x1b[0m") ? "\x1b[0m" : ""}`;
135
+ return { content, visible: true };
136
+ }
137
+
46
138
  export const SEGMENTS: Record<BuiltinStatusLineSegmentId, StatusLineSegment> = {
47
139
  model: modelSegment,
48
140
  shell_mode: shellModeSegment,
@@ -72,47 +164,16 @@ export function renderSegment(
72
164
  id: StatusLineSegmentId,
73
165
  ctx: SegmentContext,
74
166
  ): RenderedSegment {
167
+ let rendered: RenderedSegment;
75
168
  if (isCustomSegmentId(id)) {
76
169
  const customId = id.slice("custom:".length);
77
170
  const computed = customComputedSegments.get(customId);
78
- if (computed) return applyLabelAndTemplate(id, computed.render(ctx), ctx);
79
- return applyLabelAndTemplate(id, renderCustomSegment(id, ctx), ctx);
80
- }
81
-
82
- const segment = SEGMENTS[id];
83
- if (!segment) {
84
- return { content: "", visible: false };
85
- }
86
- return applyLabelAndTemplate(id, segment.render(ctx), ctx);
87
- }
88
-
89
- const ANSI_RE = /\x1b\[[0-9;]*m/g;
90
-
91
- /** Centrale label + template-afhandeling voor álle segments.
92
- * Template (segmentOptions.<seg>.template) wint op het label; `{value}`
93
- * wordt vervangen door de kale (ANSI-vrije) segmenttekst. */
94
- function applyLabelAndTemplate(
95
- id: string,
96
- rendered: RenderedSegment,
97
- ctx: SegmentContext,
98
- ): RenderedSegment {
99
- if (!rendered.visible || !rendered.content) return rendered;
100
- const template = (
101
- (ctx.options as Record<string, { template?: string } | undefined>)?.[id]
102
- ?.template
103
- )?.trim();
104
- if (template) {
105
- const plain = rendered.content.replace(ANSI_RE, "");
106
- return {
107
- ...rendered,
108
- content: template.includes("{value}")
109
- ? template.replace("{value}", plain)
110
- : template,
111
- };
112
- }
113
- const label = ctx.segmentLabels?.get(id);
114
- if (label) {
115
- return { ...rendered, content: `${label} ${rendered.content}` };
171
+ rendered = computed
172
+ ? computed.render(ctx)
173
+ : renderCustomSegment(id, ctx);
174
+ } else {
175
+ const segment = SEGMENTS[id];
176
+ rendered = segment ? segment.render(ctx) : { content: "", visible: false };
116
177
  }
117
- return rendered;
178
+ return applySegmentDecoration(id, ctx, rendered);
118
179
  }