@groeponline/pi-wishcraft 0.22.2 → 0.23.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.23.1] - 2026-08-20
6
+
7
+ ## [0.23.0] - 2026-08-20
8
+
9
+ ### Added
10
+ - `powerline.skills.count` status export, published on session start and after skill-cache invalidation.
11
+ - Read-tool continuation hints on partial core `read` results when the core output omits a range or offset summary.
12
+
13
+ ### Changed
14
+ - Powerline Status menu keeps only ports, TPS, and toggle (removed unimplemented diagnostics entries).
15
+
5
16
  ## [0.22.2] - 2026-08-20
6
17
 
7
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "0.22.2",
3
+ "version": "0.23.1",
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": [
@@ -9,6 +9,7 @@ export const POWERLINE_STATUS_KEYS = {
9
9
  preset: "powerline.preset",
10
10
  tps: "powerline.tps",
11
11
  ports: "powerline.ports",
12
+ skillsCount: "powerline.skills.count",
12
13
  } as const;
13
14
 
14
15
  /** Keys never rendered in powerline's own bar (they exist for other extensions). */
@@ -23,6 +24,8 @@ export interface PowerlineStatusSnapshot {
23
24
  tps?: string | undefined;
24
25
  /** open_ports count as text (`?` when a fleet host probe failed). */
25
26
  ports?: string;
27
+ /** Skill catalog length as a decimal string. */
28
+ skillsCount?: string;
26
29
  }
27
30
 
28
31
  type StatusPublisherCtx = {
@@ -47,9 +50,17 @@ export function buildPowerlineStatusExport(
47
50
  if ("ports" in snapshot) {
48
51
  entries.push([POWERLINE_STATUS_KEYS.ports, snapshot.ports]);
49
52
  }
53
+ if ("skillsCount" in snapshot) {
54
+ entries.push([POWERLINE_STATUS_KEYS.skillsCount, snapshot.skillsCount]);
55
+ }
50
56
  return entries;
51
57
  }
52
58
 
59
+ /** Format a skill-catalog length for the status export. */
60
+ export function formatSkillsCountStatusValue(count: number): string {
61
+ return String(count);
62
+ }
63
+
53
64
  /** Publish only the snapshot keys present, via `ctx.ui.setStatus`. */
54
65
  export function publishPowerlineStatuses(
55
66
  ctx: StatusPublisherCtx | null | undefined,
@@ -0,0 +1,147 @@
1
+ import { readSettings } from "../settings/settings-io.ts";
2
+
3
+ export interface ReadToolInput {
4
+ offset?: number;
5
+ limit?: number;
6
+ }
7
+
8
+ export interface ReadHintDetails {
9
+ truncation?: {
10
+ totalLines?: number;
11
+ outputLines?: number;
12
+ };
13
+ }
14
+
15
+ const CORE_SHOWING_LINES =
16
+ /^\[Showing lines \d+[–-]\d+(?: of \d+)?\.(?: Use offset=\d+ to continue\.)?\]$/i;
17
+ const CORE_MORE_LINES =
18
+ /^\[?\d+ more lines in file\. Use offset=\d+ to continue\.?\]?$/i;
19
+ const OWN_READ_HINT = /^\d+ lines, showing \d+[–-]\d+, next offset \d+$/;
20
+
21
+ function isCoreRangeFooter(line: string): boolean {
22
+ const footer = line.trim();
23
+ return (
24
+ CORE_SHOWING_LINES.test(footer) ||
25
+ CORE_MORE_LINES.test(footer) ||
26
+ OWN_READ_HINT.test(footer)
27
+ );
28
+ }
29
+
30
+ function coreFooter(text: string): string {
31
+ const lines = text.split("\n");
32
+ while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
33
+ return lines[lines.length - 1]?.trim() ?? "";
34
+ }
35
+
36
+ /** Default on. `wishcraft.readHints: false` is the opt-out. */
37
+ export function readHintsEnabled(wishcraftSettings: unknown): boolean {
38
+ if (
39
+ !wishcraftSettings ||
40
+ typeof wishcraftSettings !== "object" ||
41
+ Array.isArray(wishcraftSettings)
42
+ ) {
43
+ return true;
44
+ }
45
+ return (wishcraftSettings as Record<string, unknown>).readHints !== false;
46
+ }
47
+
48
+ /** True when core read output already carries an offset/range continuation line. */
49
+ export function coreReadResultHasRangeSummary(
50
+ text: string,
51
+ _details?: ReadHintDetails,
52
+ ): boolean {
53
+ return isCoreRangeFooter(coreFooter(text));
54
+ }
55
+
56
+ function countContentLines(text: string): number {
57
+ const lines = text.split("\n");
58
+ let end = lines.length;
59
+ while (end > 0 && lines[end - 1] === "") end--;
60
+ if (end > 0 && isCoreRangeFooter(lines[end - 1]!)) end--;
61
+ return end;
62
+ }
63
+
64
+ /** Whether to append a one-line read continuation hint to a core read result. */
65
+ export function shouldAppendReadHint(
66
+ input: ReadToolInput | null | undefined,
67
+ text: string,
68
+ details?: ReadHintDetails,
69
+ ): boolean {
70
+ if (!input) return false;
71
+ if (input.offset === undefined && input.limit === undefined) return false;
72
+ if (coreReadResultHasRangeSummary(text, details)) return false;
73
+
74
+ const lineCount = countContentLines(text);
75
+ if (lineCount === 0) return false;
76
+
77
+ const start = input.offset ?? 1;
78
+ const end = start + lineCount - 1;
79
+ const total = details?.truncation?.totalLines;
80
+ if (total !== undefined && end >= total) return false;
81
+ if (input.limit !== undefined && lineCount < input.limit) return false;
82
+
83
+ return true;
84
+ }
85
+
86
+ /** Format the English continuation hint for a partial read window. */
87
+ export function formatReadHint(
88
+ input: ReadToolInput,
89
+ text: string,
90
+ details?: ReadHintDetails,
91
+ ): string {
92
+ const start = input.offset ?? 1;
93
+ const lineCount = countContentLines(text);
94
+ const end = start + lineCount - 1;
95
+ const next = end + 1;
96
+ const total = details?.truncation?.totalLines;
97
+ if (total !== undefined) {
98
+ return `${total} lines, showing ${start}–${end}, next offset ${next}`;
99
+ }
100
+ return `${lineCount} lines, showing ${start}–${end}, next offset ${next}`;
101
+ }
102
+
103
+ export function extractReadToolResultText(content: unknown): string {
104
+ if (typeof content === "string") return content;
105
+ if (!Array.isArray(content)) return "";
106
+ return content.map((block) =>
107
+ block && typeof block === "object" && "text" in block
108
+ ? String((block as { text?: unknown }).text ?? "")
109
+ : "",
110
+ ).join("\n");
111
+ }
112
+
113
+ /**
114
+ * Append a continuation hint to a core `read` tool_result.
115
+ * Returns a replacement payload; never mutates `event.input` or `event.content`.
116
+ */
117
+ export function appendReadHintToEvent(event: {
118
+ input?: unknown;
119
+ content?: unknown;
120
+ details?: unknown;
121
+ }): { content: Array<{ type: "text"; text: string }> } | undefined {
122
+ const text = extractReadToolResultText(event.content);
123
+ const input = event.input as ReadToolInput | undefined;
124
+ const details = event.details as ReadHintDetails | undefined;
125
+ if (!shouldAppendReadHint(input, text, details) || !input) return undefined;
126
+ if (!Array.isArray(event.content)) return undefined;
127
+ const hint = formatReadHint(input, text, details);
128
+ return {
129
+ content: [
130
+ ...(event.content as Array<{ type: "text"; text: string }>),
131
+ { type: "text", text: hint },
132
+ ],
133
+ };
134
+ }
135
+
136
+ /** Apply the opt-out and append a hint without mutating `event.input`. */
137
+ export function maybeAppendReadHint(
138
+ event: {
139
+ input?: unknown;
140
+ content?: unknown;
141
+ details?: unknown;
142
+ },
143
+ cwd?: string,
144
+ ): { content: Array<{ type: "text"; text: string }> } | undefined {
145
+ if (!readHintsEnabled(readSettings(cwd).wishcraft)) return undefined;
146
+ return appendReadHintToEvent(event);
147
+ }
@@ -70,10 +70,12 @@ import {
70
70
  import { CONTEXT_STATUS_RENDER_MS } from "../core/constants.ts";
71
71
  import type { RuntimeState } from "../core/types.ts";
72
72
  import { isStaleExtensionContextError } from "./stale-context.ts";
73
+ import { dismissWelcome, scheduleDismissWelcome } from "../welcome/welcome-control.ts";
73
74
  import {
74
- dismissWelcome,
75
- scheduleDismissWelcome,
76
- } from "../welcome/welcome-control.ts";
75
+ bindSkillsCountPublisher,
76
+ clearSkillsCountPublisher,
77
+ } from "../skills/skill-status.ts";
78
+ import { maybeAppendReadHint } from "./read-hints.ts";
77
79
 
78
80
  /**
79
81
  * Fire the configured `powerline.costAlert` warning at most once per session.
@@ -160,6 +162,7 @@ export function registerSessionLifecycle(
160
162
  ): void {
161
163
  // Track session start
162
164
  pi.on("session_start", async (event, ctx) => {
165
+ clearSkillsCountPublisher();
163
166
  rt.shellSession?.dispose();
164
167
  rt.shellSession = null;
165
168
  rt.sessionGeneration++;
@@ -196,6 +199,7 @@ export function registerSessionLifecycle(
196
199
 
197
200
  if (ctx.hasUI) {
198
201
  ctx.ui.setStatus("stash", undefined);
202
+ bindSkillsCountPublisher(ctx);
199
203
  const pendingIdeas = rt.queueStore
200
204
  .activeItems(getQueueContext(ctx))
201
205
  .filter((item) => item.intent === "idea").length;
@@ -226,6 +230,7 @@ export function registerSessionLifecycle(
226
230
  });
227
231
 
228
232
  pi.on("session_shutdown", async (_event, ctx) => {
233
+ clearSkillsCountPublisher();
229
234
  rt.sessionGeneration++;
230
235
  rt.dismissWelcomeOverlay?.();
231
236
  rt.dismissWelcomeOverlay = null;
@@ -257,7 +262,7 @@ export function registerSessionLifecycle(
257
262
  });
258
263
 
259
264
  // Invalidate git status on file changes, trigger re-render on potential branch changes
260
- pi.on("tool_result", async (event) => {
265
+ pi.on("tool_result", async (event, ctx) => {
261
266
  if (event.toolName === "write" || event.toolName === "edit") {
262
267
  invalidateGitStatus();
263
268
  requestStatusRender(rt);
@@ -266,6 +271,9 @@ export function registerSessionLifecycle(
266
271
  if (event.toolName === "bash" && event.input?.command) {
267
272
  invalidateGitForCommand(rt, String(event.input.command));
268
273
  }
274
+ if (event.toolName === "read") {
275
+ return maybeAppendReadHint(event, ctx?.cwd);
276
+ }
269
277
  });
270
278
 
271
279
  // Also catch user escape commands (! prefix)
@@ -139,6 +139,7 @@ export function buildConfigGroups(settings: Record<string, unknown>): ConfigGrou
139
139
  title: "Skills",
140
140
  items: [
141
141
  { label: "Inline-expansie /command en $skill", path: "wishcraft.inlineSkills", kind: "toggle", hint: "nog niet actief zonder herstart" },
142
+ { label: "Read hints", path: "wishcraft.readHints", kind: "toggle", hint: "off = no continuation hint after partial reads" },
142
143
  ],
143
144
  },
144
145
  {
@@ -96,6 +96,11 @@ let cachedAt = 0;
96
96
  let cachedEntries: SkillEntry[] | null = null;
97
97
  let cachedPathMap: Map<string, string> | null = null;
98
98
  let cachedCwd: string | null = null;
99
+ let onCacheInvalidated: (() => void) | null = null;
100
+
101
+ export function setSkillCacheInvalidationHandler(handler: (() => void) | null): void {
102
+ onCacheInvalidated = handler;
103
+ }
99
104
 
100
105
  const usageCache = new Map<string, SkillUsage>();
101
106
  let usageLoaded = false;
@@ -106,6 +111,7 @@ export function invalidateSkillCache(): void {
106
111
  cachedEntries = null;
107
112
  cachedPathMap = null;
108
113
  cachedCwd = null;
114
+ onCacheInvalidated?.();
109
115
  }
110
116
 
111
117
  /** Legacy prompts/loose-md dirs die inline-invocation altijd al scanden. */
@@ -0,0 +1,37 @@
1
+ import {
2
+ formatSkillsCountStatusValue,
3
+ publishPowerlineStatuses,
4
+ } from "../core/status-export.ts";
5
+ import {
6
+ invalidateSkillCache,
7
+ loadSkillCatalog,
8
+ setSkillCacheInvalidationHandler,
9
+ } from "./skill-registry.ts";
10
+
11
+ type SkillsCountCtx = {
12
+ cwd?: string;
13
+ ui?: { setStatus?: (key: string, value: string | undefined) => void };
14
+ };
15
+
16
+ function publishSkillsCount(ctx: SkillsCountCtx): void {
17
+ publishPowerlineStatuses(ctx, {
18
+ skillsCount: formatSkillsCountStatusValue(
19
+ loadSkillCatalog(ctx.cwd ?? process.cwd()).length,
20
+ ),
21
+ });
22
+ }
23
+
24
+ /** Drop the session-scoped publisher so later invalidations cannot use a stale UI. */
25
+ export function clearSkillsCountPublisher(): void {
26
+ setSkillCacheInvalidationHandler(null);
27
+ }
28
+
29
+ /**
30
+ * Publish `powerline.skills.count` now and after every skill-cache invalidation.
31
+ * Replaces any previous session callback first.
32
+ */
33
+ export function bindSkillsCountPublisher(ctx: SkillsCountCtx): void {
34
+ clearSkillsCountPublisher();
35
+ setSkillCacheInvalidationHandler(() => publishSkillsCount(ctx));
36
+ invalidateSkillCache();
37
+ }
@@ -21,13 +21,6 @@ export function buildPowerlineMenuItems(): PowerlineMenuNode[] {
21
21
  { id: "ports", label: "Open ports", description: "Full listening list" },
22
22
  { id: "tps", label: "TPS detail", description: "Live 1s window or override" },
23
23
  { id: "toggle", label: "Toggle powerline", description: "Use /powerline to enable or disable" },
24
- { id: "cpu", label: "CPU usage", description: "Current process CPU usage" },
25
- { id: "memory", label: "Memory usage", description: "Current process memory" },
26
- { id: "network", label: "Network status", description: "Network interface status" },
27
- { id: "uptime", label: "System uptime", description: "Host uptime" },
28
- { id: "version", label: "Version info", description: "Runtime and application versions" },
29
- { id: "logs", label: "Recent logs", description: "Show recent status messages" },
30
- { id: "diagnostics", label: "Diagnostics", description: "Run powerline diagnostics" },
31
24
  ],
32
25
  },
33
26
  ];
@@ -35,7 +35,9 @@ function runCommandCached(
35
35
  commandCache.set(command, { at: now, value: out });
36
36
  return out;
37
37
  } catch {
38
- return null;
38
+ // Preserve command failures so renderSegment can isolate this segment and
39
+ // expose a visible fault marker instead of silently hiding it.
40
+ throw new Error(`Command failed: ${command}`);
39
41
  }
40
42
  }
41
43
 
@@ -164,16 +164,24 @@ export function renderSegment(
164
164
  id: StatusLineSegmentId,
165
165
  ctx: SegmentContext,
166
166
  ): RenderedSegment {
167
- let rendered: RenderedSegment;
168
- if (isCustomSegmentId(id)) {
169
- const customId = id.slice("custom:".length);
170
- const computed = customComputedSegments.get(customId);
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 };
167
+ try {
168
+ let rendered: RenderedSegment;
169
+ if (isCustomSegmentId(id)) {
170
+ const customId = id.slice("custom:".length);
171
+ const computed = customComputedSegments.get(customId);
172
+ rendered = computed
173
+ ? computed.render(ctx)
174
+ : renderCustomSegment(id, ctx);
175
+ } else {
176
+ const segment = SEGMENTS[id];
177
+ rendered = segment ? segment.render(ctx) : { content: "", visible: false };
178
+ }
179
+ return applySegmentDecoration(id, ctx, rendered);
180
+ } catch {
181
+ // ponytail: per-segment fault isolation. A throwing segment (most often a
182
+ // user `command` custom segment with a failing script) must not blank the
183
+ // whole footer; surface a visible `!id` marker so the operator sees
184
+ // which segment broke. No per-render log: the bar repaints ~30/s.
185
+ return { content: `!${id}`, visible: true };
177
186
  }
178
- return applySegmentDecoration(id, ctx, rendered);
179
187
  }
@@ -180,7 +180,21 @@ export function countListeningPorts(includeUdp = false, host?: string): number {
180
180
  return host ? -1 : readProcListeningPorts(includeUdp);
181
181
  }
182
182
 
183
- const lines = out
183
+ return parseListeningPortsFromText(out).size;
184
+ }
185
+
186
+ /**
187
+ * Pure port-count parser shared by `countListeningPorts`. Accepts `ss` and
188
+ * `netstat` output. The port separator is `:` on Linux/`ss`/`lsof` but `.` on
189
+ * macOS `netstat` (`*.5900`, `127.0.0.1.631`); matching both keeps the
190
+ * open-ports segment honest on macOS.
191
+ *
192
+ * ponytail: assumes LISTEN rows always carry a port on the local-address
193
+ * column; a bare IPv4 with no port (not emitted by LISTEN output) would
194
+ * false-match the `.<digits>` form.
195
+ */
196
+ export function parseListeningPortsFromText(text: string): Set<number> {
197
+ const lines = text
184
198
  .split("\n")
185
199
  .map((l) => l.trim())
186
200
  .filter(Boolean);
@@ -189,14 +203,14 @@ export function countListeningPorts(includeUdp = false, host?: string): number {
189
203
  for (const line of lines.slice(start)) {
190
204
  // ss/netstat put the local address at different columns; take the first addr:port token
191
205
  for (const col of line.split(/\s+/)) {
192
- const m = /:(\d+)$/.exec(col);
206
+ const m = /(?::|\.)(\d+)$/.exec(col);
193
207
  if (m) {
194
208
  ports.add(Number(m[1]));
195
209
  break;
196
210
  }
197
211
  }
198
212
  }
199
- return ports.size;
213
+ return ports;
200
214
  }
201
215
 
202
216
  function readProcListeningPorts(includeUdp: boolean): number {
@@ -259,7 +273,7 @@ export function parseOpenPortProcesses(text: string): OpenPortProcess[] {
259
273
  // numeric Recv-Q there. That tells us which local-address column to use.
260
274
  const isSsRow = !/^\d+$/.test(tokens[1] ?? "");
261
275
  const local = isSsRow ? tokens[4] : tokens[3];
262
- const portMatch = local ? /:(\d+)$/.exec(local) : null;
276
+ const portMatch = local ? /(?::|\.)(\d+)$/.exec(local) : null;
263
277
  if (!portMatch) continue;
264
278
 
265
279
  let process: string | null = null;
@@ -275,7 +289,7 @@ export function parseOpenPortProcesses(text: string): OpenPortProcess[] {
275
289
  entries.push({
276
290
  port: Number(portMatch[1]),
277
291
  proto: tokens[0].toLowerCase().startsWith("udp") ? "udp" : "tcp",
278
- address: local.replace(/:(\d+)$/, ""),
292
+ address: local.replace(/(?::|\.)(\d+)$/, ""),
279
293
  process,
280
294
  });
281
295
  }