jfl 0.6.1 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jfl",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Just Fucking Launch - CLI for AI-powered GTM and development",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,6 +14,12 @@ import { execSync } from "child_process"
14
14
  import type { PiContext, PiTheme } from "./types.js"
15
15
  import { hudRenderCall, hudRenderResult } from "./tool-renderers.js"
16
16
 
17
+ function clipLine(text: string, maxW: number): string {
18
+ const stripped = text.replace(/\x1b\[[0-9;]*m/g, "")
19
+ if (stripped.length <= maxW) return text
20
+ return text.slice(0, maxW - 1) + "…"
21
+ }
22
+
17
23
  let projectRoot = ""
18
24
 
19
25
  function getDaysToLaunch(root: string): string | null {
@@ -51,6 +57,22 @@ function getProjectPhase(root: string): string {
51
57
  return "unknown"
52
58
  }
53
59
 
60
+ function getLastJournalTitle(root: string): string | null {
61
+ try {
62
+ const journalDir = join(root, ".jfl", "journal")
63
+ if (!existsSync(journalDir)) return null
64
+ const files = execSync(`ls -t "${journalDir}"/*.jsonl 2>/dev/null | head -1`, {
65
+ cwd: root, timeout: 3000, encoding: "utf-8",
66
+ }).trim()
67
+ if (!files) return null
68
+ const content = readFileSync(files, "utf-8").trim()
69
+ const lastLine = content.split("\n").pop()
70
+ if (!lastLine) return null
71
+ const entry = JSON.parse(lastLine)
72
+ return entry.title ?? null
73
+ } catch { return null }
74
+ }
75
+
54
76
  function buildHudLines(root: string): string[] {
55
77
  const configPath = join(root, ".jfl", "config.json")
56
78
  let projectName = root.split("/").pop() ?? "JFL"
@@ -66,41 +88,30 @@ function buildHudLines(root: string): string[] {
66
88
 
67
89
  const days = getDaysToLaunch(root)
68
90
  const phase = getProjectPhase(root)
91
+ const lastWork = getLastJournalTitle(root)
69
92
 
70
93
  const lines = [
71
- `◆ ${projectName} [${projectType}]`,
94
+ `◆ ${projectName}`,
95
+ phase !== "unknown" ? ` ${phase}` : "",
72
96
  days ? ` ${days}` : "",
73
- phase !== "unknown" ? ` Phase: ${phase}` : "",
97
+ lastWork ? ` Last: ${lastWork.slice(0, 60)}` : "",
74
98
  ].filter(Boolean)
75
99
 
76
- try {
77
- const crmOutput = execSync("./crm list --compact 2>/dev/null | head -3", {
78
- cwd: root,
79
- timeout: 5000,
80
- encoding: "utf-8",
81
- }).trim()
82
- if (crmOutput) {
83
- lines.push(" Pipeline:", ...crmOutput.split("\n").map(l => ` ${l}`))
84
- }
85
- } catch {}
86
-
87
100
  return lines
88
101
  }
89
102
 
90
103
  export function setupHudTool(ctx: PiContext): void {
91
104
  projectRoot = ctx.session.projectRoot
92
105
 
93
- // Themed widget above editor
94
106
  ctx.ui.setWidget("jfl-hud", (_tui: any, theme: PiTheme) => {
95
107
  const lines = buildHudLines(projectRoot)
96
108
  const themed = lines.map((line, i) => {
97
- if (i === 0) return theme.fg("accent", line)
98
- if (line.includes("Phase:")) return theme.fg("warning", line)
99
- if (line.includes("Pipeline")) return theme.fg("accent", line)
109
+ if (i === 0) return theme.fg("accent", theme.bold(line))
110
+ if (line.includes("Last:")) return theme.fg("dim", line)
100
111
  return theme.fg("muted", line)
101
112
  })
102
113
  return {
103
- render: () => themed,
114
+ render: (width: number) => themed.map(l => clipLine(l, width)),
104
115
  invalidate() {},
105
116
  }
106
117
  })
@@ -135,11 +146,10 @@ export async function updateHudWidget(ctx: PiContext): Promise<void> {
135
146
  ctx.ui.setWidget("jfl-hud", (_tui: any, theme: PiTheme) => {
136
147
  const lines = buildHudLines(projectRoot)
137
148
  const themed = lines.map((line, i) => {
138
- if (i === 0) return theme.fg("accent", line)
139
- if (line.includes("Phase:")) return theme.fg("warning", line)
140
- if (line.includes("Pipeline")) return theme.fg("accent", line)
149
+ if (i === 0) return theme.fg("accent", theme.bold(line))
150
+ if (line.includes("Last:")) return theme.fg("dim", line)
141
151
  return theme.fg("muted", line)
142
152
  })
143
- return { render: () => themed, invalidate() {} }
153
+ return { render: (width: number) => themed.map(l => clipLine(l, width)), invalidate() {} }
144
154
  })
145
155
  }
@@ -317,24 +317,26 @@ export default async function jflExtension(pi: any): Promise<void> {
317
317
 
318
318
  ctx.log(`JFL: ${projectName} — session ready`)
319
319
 
320
- if (config.pi?.auto_start !== false && pi.sendUserMessage) {
320
+ if (config.pi?.auto_start !== false && pi.sendMessage) {
321
321
  setTimeout(() => {
322
- pi.sendUserMessage([
323
- `JFL session started in "${projectName}" on branch ${ctx.session.branch}.`,
324
- "",
325
- "Complete these steps before responding to the user:",
326
- "1. Use jfl_context to get recent project context (journal, knowledge, decisions)",
327
- "2. Use jfl_memory_search to check for any recent decisions or blockers",
328
- "3. Use jfl_hud to get the project dashboard (timeline, phase, pipeline)",
329
- "4. Show a brief status update with:",
330
- " - What was worked on recently (from journal)",
331
- " - Current phase and focus",
332
- " - Any blocking issues or warnings",
333
- " - Suggested next action",
334
- "",
335
- "Follow the CLAUDE.md instructions injected in your system prompt.",
336
- "Write journal entries as you work. Capture decisions immediately.",
337
- ].join("\n"))
322
+ pi.sendMessage({
323
+ customType: "jfl-session-init",
324
+ content: [
325
+ `JFL session ready: "${projectName}" on branch ${ctx.session.branch}.`,
326
+ "",
327
+ "You have full project context injected in your system prompt (CLAUDE.md + recent journal + knowledge docs).",
328
+ "Tools available: jfl_context, jfl_memory_search, jfl_hud, jfl_journal.",
329
+ "",
330
+ "Greet the user naturally with a brief status:",
331
+ "- What was worked on recently (from your injected context)",
332
+ "- Current phase and any blockers",
333
+ "- A suggested next action",
334
+ "",
335
+ "Keep it to 3-5 lines. No setup noise. Just be ready to work.",
336
+ "Write journal entries as you work. Capture decisions immediately.",
337
+ ].join("\n"),
338
+ display: false,
339
+ }, { triggerTurn: true, deliverAs: "steer" })
338
340
  }, 500)
339
341
  }
340
342
  })
@@ -12,13 +12,14 @@ import type { PiTheme } from "./types.js"
12
12
 
13
13
  // ─── Shared helpers ──────────────────────────────────────────────────────────
14
14
 
15
+ const MAX_LINE_W = 140
16
+
15
17
  function visibleLen(text: string): number {
16
18
  return text.replace(/\x1b\[[0-9;]*m/g, "").length
17
19
  }
18
20
 
19
- function truncLine(text: string, maxW: number): string {
21
+ function truncLine(text: string, maxW: number = MAX_LINE_W): string {
20
22
  if (visibleLen(text) <= maxW) return text
21
- // Walk char-by-char, skipping ANSI sequences, until we hit maxW visible chars
22
23
  let visible = 0
23
24
  let i = 0
24
25
  while (i < text.length && visible < maxW - 1) {
@@ -32,6 +33,10 @@ function truncLine(text: string, maxW: number): string {
32
33
  return text.slice(0, i) + "\x1b[0m…"
33
34
  }
34
35
 
36
+ function safeRender(lines: string[]): string[] {
37
+ return lines.map(l => truncLine(l, MAX_LINE_W))
38
+ }
39
+
35
40
  function wrapText(text: string, width: number): string[] {
36
41
  const lines: string[] = []
37
42
  for (const raw of text.split("\n")) {
@@ -55,7 +60,7 @@ function sectionBorder(theme: PiTheme, label: string, width: number): string {
55
60
 
56
61
  export function hudRenderCall(args: Record<string, any>, theme: PiTheme): any {
57
62
  const label = theme.fg("accent", "◆") + " " + theme.fg("toolTitle", theme.bold("HUD"))
58
- return { render: () => [label], invalidate() {} }
63
+ return { render: () => safeRender([label]), invalidate() {} }
59
64
  }
60
65
 
61
66
  export function hudRenderResult(result: any, opts: { expanded: boolean }, theme: PiTheme): any {
@@ -65,7 +70,7 @@ export function hudRenderResult(result: any, opts: { expanded: boolean }, theme:
65
70
  if (!opts.expanded && lines.length > 5) {
66
71
  const display = lines.slice(0, 5).map(l => theme.fg("toolOutput", l))
67
72
  display.push(theme.fg("dim", `... ${lines.length - 5} more lines (Ctrl+O)`))
68
- return { render: () => display, invalidate() {} }
73
+ return { render: () => safeRender(display), invalidate() {} }
69
74
  }
70
75
 
71
76
  const themed = lines.map(line => {
@@ -75,7 +80,7 @@ export function hudRenderResult(result: any, opts: { expanded: boolean }, theme:
75
80
  return theme.fg("toolOutput", line)
76
81
  })
77
82
 
78
- return { render: () => themed, invalidate() {} }
83
+ return { render: () => safeRender(themed), invalidate() {} }
79
84
  }
80
85
 
81
86
  // ─── Context Tool ───────────────────────────────────────────────────────────
@@ -83,48 +88,45 @@ export function hudRenderResult(result: any, opts: { expanded: boolean }, theme:
83
88
  export function contextRenderCall(args: Record<string, any>, theme: PiTheme): any {
84
89
  const query = args.query ?? ""
85
90
  const label = theme.fg("toolTitle", theme.bold("context ")) + theme.fg("accent", `"${query}"`)
86
- return { render: () => [label], invalidate() {} }
91
+ return { render: () => safeRender([label]), invalidate() {} }
87
92
  }
88
93
 
89
94
  export function contextRenderResult(result: any, opts: { expanded: boolean }, theme: PiTheme): any {
90
- const MAX_W = 140
91
95
  const raw = extractText(result)
92
- if (raw === "No relevant context found." || raw === "No context available.") {
93
- return { render: () => [theme.fg("dim", "No relevant context found")], invalidate() {} }
96
+ if (raw === "No relevant context found." || raw === "No context available." || raw === "Context Hub unavailable.") {
97
+ return { render: () => safeRender([theme.fg("dim", "No context available")]), invalidate() {} }
94
98
  }
95
99
 
96
100
  const sections = raw.split(/---\n/).filter(Boolean)
97
- const lines: string[] = []
101
+ const lineCount = raw.split("\n").length
98
102
 
99
- const max = opts.expanded ? sections.length : Math.min(sections.length, 3)
100
- for (let i = 0; i < max; i++) {
101
- const section = sections[i].trim()
102
- const firstLine = section.split("\n")[0] ?? ""
103
+ // Collapsed: one-line summary
104
+ if (!opts.expanded) {
105
+ const summary = theme.fg("success", "✓ ") + theme.fg("muted", `${sections.length} items, ${lineCount} lines loaded (Ctrl+O to expand)`)
106
+ return { render: () => safeRender([summary]), invalidate() {} }
107
+ }
103
108
 
109
+ // Expanded: show sections with truncation
110
+ const lines: string[] = []
111
+ for (const section of sections) {
112
+ const firstLine = section.trim().split("\n")[0] ?? ""
104
113
  if (firstLine.startsWith("[")) {
105
114
  const typeMatch = firstLine.match(/^\[(\w+)\]\s*(.*)/)
106
115
  if (typeMatch) {
107
116
  const typeColor = typeMatch[1] === "decision" ? "warning" : typeMatch[1] === "feature" ? "success" : "muted"
108
- lines.push(truncLine(`${theme.fg(typeColor, `[${typeMatch[1]}]`)} ${theme.fg("text", typeMatch[2])}`, MAX_W))
117
+ lines.push(truncLine(`${theme.fg(typeColor, `[${typeMatch[1]}]`)} ${theme.fg("text", typeMatch[2])}`))
109
118
  } else {
110
- lines.push(truncLine(theme.fg("text", firstLine), MAX_W))
119
+ lines.push(truncLine(theme.fg("text", firstLine)))
111
120
  }
112
121
  } else {
113
- lines.push(truncLine(theme.fg("text", firstLine), MAX_W))
122
+ lines.push(truncLine(theme.fg("text", firstLine)))
114
123
  }
115
-
116
- if (opts.expanded) {
117
- const rest = section.split("\n").slice(1)
118
- for (const l of rest) lines.push(truncLine(theme.fg("muted", l), MAX_W))
119
- lines.push("")
120
- }
121
- }
122
-
123
- if (!opts.expanded && sections.length > 3) {
124
- lines.push(theme.fg("dim", `... ${sections.length - 3} more results (Ctrl+O)`))
124
+ const rest = section.trim().split("\n").slice(1)
125
+ for (const l of rest) lines.push(truncLine(theme.fg("muted", l)))
126
+ lines.push("")
125
127
  }
126
128
 
127
- return { render: () => lines, invalidate() {} }
129
+ return { render: () => safeRender(lines), invalidate() {} }
128
130
  }
129
131
 
130
132
  // ─── CRM Tool ───────────────────────────────────────────────────────────────
@@ -133,13 +135,13 @@ export function crmRenderCall(args: Record<string, any>, theme: PiTheme): any {
133
135
  const cmd = args.command ?? "list"
134
136
  const extra = args.args ? ` ${args.args}` : ""
135
137
  const label = theme.fg("toolTitle", theme.bold("crm ")) + theme.fg("accent", cmd) + theme.fg("dim", extra)
136
- return { render: () => [label], invalidate() {} }
138
+ return { render: () => safeRender([label]), invalidate() {} }
137
139
  }
138
140
 
139
141
  export function crmRenderResult(result: any, opts: { expanded: boolean }, theme: PiTheme): any {
140
142
  const raw = extractText(result)
141
143
  if (raw.startsWith("Error")) {
142
- return { render: () => [theme.fg("error", raw)], invalidate() {} }
144
+ return { render: () => safeRender([theme.fg("error", raw)]), invalidate() {} }
143
145
  }
144
146
 
145
147
  const lines = raw.split("\n").filter(Boolean)
@@ -154,10 +156,10 @@ export function crmRenderResult(result: any, opts: { expanded: boolean }, theme:
154
156
  if (!opts.expanded && themed.length > 8) {
155
157
  const display = themed.slice(0, 8)
156
158
  display.push(theme.fg("dim", `... ${themed.length - 8} more (Ctrl+O)`))
157
- return { render: () => display, invalidate() {} }
159
+ return { render: () => safeRender(display), invalidate() {} }
158
160
  }
159
161
 
160
- return { render: () => themed, invalidate() {} }
162
+ return { render: () => safeRender(themed), invalidate() {} }
161
163
  }
162
164
 
163
165
  // ─── Memory Search Tool ─────────────────────────────────────────────────────
@@ -166,36 +168,33 @@ export function memoryRenderCall(args: Record<string, any>, theme: PiTheme): any
166
168
  const query = args.query ?? ""
167
169
  const type = args.type && args.type !== "all" ? ` [${args.type}]` : ""
168
170
  const label = theme.fg("toolTitle", theme.bold("memory ")) + theme.fg("accent", `"${query}"`) + theme.fg("muted", type)
169
- return { render: () => [label], invalidate() {} }
171
+ return { render: () => safeRender([label]), invalidate() {} }
170
172
  }
171
173
 
172
174
  export function memoryRenderResult(result: any, opts: { expanded: boolean }, theme: PiTheme): any {
173
175
  const raw = extractText(result)
174
176
  if (raw.includes("unavailable") || raw.includes("No memories")) {
175
- return { render: () => [theme.fg("dim", raw)], invalidate() {} }
177
+ return { render: () => safeRender([theme.fg("dim", "No memories found")]), invalidate() {} }
176
178
  }
177
179
 
178
180
  const entries = raw.split(/\n\n---\n\n/).filter(Boolean)
179
- const lines: string[] = []
180
181
 
181
- const max = opts.expanded ? entries.length : Math.min(entries.length, 4)
182
- for (let i = 0; i < max; i++) {
183
- const entry = entries[i].trim()
184
- const [header, ...body] = entry.split("\n")
185
- if (header) lines.push(theme.fg("accent", header))
186
- if (opts.expanded) {
187
- for (const l of body) lines.push(theme.fg("muted", l))
188
- lines.push("")
189
- } else if (body.length > 0) {
190
- lines.push(theme.fg("dim", body[0].slice(0, 80) + (body[0].length > 80 ? "…" : "")))
191
- }
182
+ // Collapsed: one-line summary
183
+ if (!opts.expanded) {
184
+ const summary = theme.fg("success", "✓ ") + theme.fg("muted", `${entries.length} memories found (Ctrl+O to expand)`)
185
+ return { render: () => safeRender([summary]), invalidate() {} }
192
186
  }
193
187
 
194
- if (!opts.expanded && entries.length > 4) {
195
- lines.push(theme.fg("dim", `... ${entries.length - 4} more (Ctrl+O)`))
188
+ // Expanded: show entries
189
+ const lines: string[] = []
190
+ for (const entry of entries) {
191
+ const [header, ...body] = entry.trim().split("\n")
192
+ if (header) lines.push(truncLine(theme.fg("accent", header)))
193
+ for (const l of body) lines.push(truncLine(theme.fg("muted", l)))
194
+ lines.push("")
196
195
  }
197
196
 
198
- return { render: () => lines, invalidate() {} }
197
+ return { render: () => safeRender(lines), invalidate() {} }
199
198
  }
200
199
 
201
200
  // ─── Synopsis Tool ──────────────────────────────────────────────────────────
@@ -204,7 +203,7 @@ export function synopsisRenderCall(args: Record<string, any>, theme: PiTheme): a
204
203
  const hours = args.hours ?? 24
205
204
  const author = args.author ? ` by ${args.author}` : ""
206
205
  const label = theme.fg("toolTitle", theme.bold("synopsis ")) + theme.fg("accent", `${hours}h`) + theme.fg("muted", author)
207
- return { render: () => [label], invalidate() {} }
206
+ return { render: () => safeRender([label]), invalidate() {} }
208
207
  }
209
208
 
210
209
  export function synopsisRenderResult(result: any, opts: { expanded: boolean }, theme: PiTheme): any {
@@ -225,17 +224,17 @@ export function synopsisRenderResult(result: any, opts: { expanded: boolean }, t
225
224
  if (!opts.expanded && themed.length > 15) {
226
225
  const display = themed.slice(0, 15)
227
226
  display.push(theme.fg("dim", `... ${themed.length - 15} more lines (Ctrl+O)`))
228
- return { render: () => display, invalidate() {} }
227
+ return { render: () => safeRender(display), invalidate() {} }
229
228
  }
230
229
 
231
- return { render: () => themed, invalidate() {} }
230
+ return { render: () => safeRender(themed), invalidate() {} }
232
231
  }
233
232
 
234
233
  // ─── Eval Status Tool ───────────────────────────────────────────────────────
235
234
 
236
235
  export function evalStatusRenderCall(args: Record<string, any>, theme: PiTheme): any {
237
236
  const label = theme.fg("toolTitle", theme.bold("eval")) + " " + theme.fg("accent", "status")
238
- return { render: () => [label], invalidate() {} }
237
+ return { render: () => safeRender([label]), invalidate() {} }
239
238
  }
240
239
 
241
240
  export function evalStatusRenderResult(result: any, opts: { expanded: boolean }, theme: PiTheme): any {
@@ -253,7 +252,7 @@ export function evalStatusRenderResult(result: any, opts: { expanded: boolean },
253
252
  return theme.fg("toolOutput", line)
254
253
  })
255
254
 
256
- return { render: () => themed, invalidate() {} }
255
+ return { render: () => safeRender(themed), invalidate() {} }
257
256
  }
258
257
 
259
258
  // ─── Eval Compare Tool ──────────────────────────────────────────────────────
@@ -262,7 +261,7 @@ export function evalCompareRenderCall(args: Record<string, any>, theme: PiTheme)
262
261
  const a = args.a ?? "-2"
263
262
  const b = args.b ?? "-1"
264
263
  const label = theme.fg("toolTitle", theme.bold("eval")) + " " + theme.fg("accent", `compare ${a} ↔ ${b}`)
265
- return { render: () => [label], invalidate() {} }
264
+ return { render: () => safeRender([label]), invalidate() {} }
266
265
  }
267
266
 
268
267
  export function evalCompareRenderResult(result: any, _opts: { expanded: boolean }, theme: PiTheme): any {
@@ -273,7 +272,7 @@ export function evalCompareRenderResult(result: any, _opts: { expanded: boolean
273
272
  if (/unchanged|same/i.test(line)) return theme.fg("dim", line)
274
273
  return theme.fg("toolOutput", line)
275
274
  })
276
- return { render: () => lines, invalidate() {} }
275
+ return { render: () => safeRender(lines), invalidate() {} }
277
276
  }
278
277
 
279
278
  // ─── Policy Score Tool ──────────────────────────────────────────────────────
@@ -282,7 +281,7 @@ export function policyScoreRenderCall(args: Record<string, any>, theme: PiTheme)
282
281
  const action = args.action_type ?? "?"
283
282
  const scope = args.scope ? `[${args.scope}]` : ""
284
283
  const label = theme.fg("toolTitle", theme.bold("policy ")) + theme.fg("accent", action) + " " + theme.fg("dim", scope)
285
- return { render: () => [label], invalidate() {} }
284
+ return { render: () => safeRender([label]), invalidate() {} }
286
285
  }
287
286
 
288
287
  export function policyScoreRenderResult(result: any, _opts: { expanded: boolean }, theme: PiTheme): any {
@@ -293,7 +292,7 @@ export function policyScoreRenderResult(result: any, _opts: { expanded: boolean
293
292
  if (/delta|score|predict/i.test(line)) return theme.fg("accent", line)
294
293
  return theme.fg("toolOutput", line)
295
294
  })
296
- return { render: () => lines, invalidate() {} }
295
+ return { render: () => safeRender(lines), invalidate() {} }
297
296
  }
298
297
 
299
298
  // ─── Policy Rank Tool ───────────────────────────────────────────────────────
@@ -302,7 +301,7 @@ export function policyRankRenderCall(args: Record<string, any>, theme: PiTheme):
302
301
  let count = 0
303
302
  try { count = JSON.parse(args.actions ?? "[]").length } catch {}
304
303
  const label = theme.fg("toolTitle", theme.bold("policy ")) + theme.fg("accent", `rank ${count} actions`)
305
- return { render: () => [label], invalidate() {} }
304
+ return { render: () => safeRender([label]), invalidate() {} }
306
305
  }
307
306
 
308
307
  export function policyRankRenderResult(result: any, _opts: { expanded: boolean }, theme: PiTheme): any {
@@ -313,7 +312,7 @@ export function policyRankRenderResult(result: any, _opts: { expanded: boolean }
313
312
  if (/delta|score/i.test(line)) return `${medal} ${theme.fg("accent", line)}`
314
313
  return `${medal} ${theme.fg("toolOutput", line)}`
315
314
  })
316
- return { render: () => themed, invalidate() {} }
315
+ return { render: () => safeRender(themed), invalidate() {} }
317
316
  }
318
317
 
319
318
  // ─── Training Buffer Tool ───────────────────────────────────────────────────
@@ -323,7 +322,7 @@ export function trainingBufferRenderCall(args: Record<string, any>, theme: PiThe
323
322
  const outcome = args.outcome ?? ""
324
323
  const icon = outcome === "improved" ? theme.fg("success", "↑") : outcome === "regressed" ? theme.fg("error", "↓") : theme.fg("dim", "→")
325
324
  const label = theme.fg("toolTitle", theme.bold("train ")) + `${icon} ${theme.fg("accent", action)}`
326
- return { render: () => [label], invalidate() {} }
325
+ return { render: () => safeRender([label]), invalidate() {} }
327
326
  }
328
327
 
329
328
  export function trainingBufferRenderResult(result: any, _opts: { expanded: boolean }, theme: PiTheme): any {
@@ -331,7 +330,7 @@ export function trainingBufferRenderResult(result: any, _opts: { expanded: boole
331
330
  const line = raw.includes("recorded") || raw.includes("saved")
332
331
  ? theme.fg("success", "✓ ") + theme.fg("muted", raw)
333
332
  : theme.fg("toolOutput", raw)
334
- return { render: () => [line], invalidate() {} }
333
+ return { render: () => safeRender([line]), invalidate() {} }
335
334
  }
336
335
 
337
336
  // ─── Mine Tuples Tool ───────────────────────────────────────────────────────
@@ -340,7 +339,7 @@ export function mineTuplesRenderCall(args: Record<string, any>, theme: PiTheme):
340
339
  const source = args.source ?? "all"
341
340
  const write = args.write === "yes" ? theme.fg("warning", " (write)") : ""
342
341
  const label = theme.fg("toolTitle", theme.bold("mine ")) + theme.fg("accent", source) + write
343
- return { render: () => [label], invalidate() {} }
342
+ return { render: () => safeRender([label]), invalidate() {} }
344
343
  }
345
344
 
346
345
  export function mineTuplesRenderResult(result: any, _opts: { expanded: boolean }, theme: PiTheme): any {
@@ -350,7 +349,7 @@ export function mineTuplesRenderResult(result: any, _opts: { expanded: boolean }
350
349
  if (/error|failed/i.test(line)) return theme.fg("error", line)
351
350
  return theme.fg("toolOutput", line)
352
351
  })
353
- return { render: () => lines, invalidate() {} }
352
+ return { render: () => safeRender(lines), invalidate() {} }
354
353
  }
355
354
 
356
355
  // ─── Extract text helper ────────────────────────────────────────────────────