@clubnet/seedclub 0.2.21 → 0.2.23

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.
@@ -168,7 +168,7 @@ function resolveOutputDir(programSlug: string, requested?: string): string {
168
168
  const raw = requested.replace(/^~(?=$|\/)/, homedir());
169
169
  return resolve(raw);
170
170
  }
171
- return join(homedir(), "Downloads", "seedclub", "transcripts", programSlug);
171
+ return join(homedir(), "Downloads", sanitizeFilePart(programSlug || "transcripts", "transcripts"));
172
172
  }
173
173
 
174
174
  function parseTimeFilter(input?: string): { hour: number; minute?: number } | null {
@@ -363,8 +363,6 @@ export function registerTranscriptsCommand(pi: ExtensionAPI) {
363
363
  return;
364
364
  }
365
365
 
366
- await writeFile(join(outDir, "index.json"), JSON.stringify(manifest, null, 2), "utf8");
367
-
368
366
  const preview = manifest
369
367
  .slice(0, 8)
370
368
  .map((entry) => ` ${entry.file}`)
@@ -3,6 +3,7 @@
3
3
  * Returns JSON results on success, clear error messages on failure.
4
4
  */
5
5
 
6
+ import { Text } from "@mariozechner/pi-tui";
6
7
  import { NotConnectedError } from "./api-client.js";
7
8
 
8
9
  const MAX_TOOL_RESULT_DEPTH = 6;
@@ -46,6 +47,79 @@ function compactToolResult(value: any, depth = 0): any {
46
47
  return Object.fromEntries(entries.map(([key, item]) => [key, compactToolResult(item, depth + 1)]));
47
48
  }
48
49
 
50
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
51
+
52
+ export function makeProgressCallRenderer(
53
+ label: string,
54
+ detail?: (args: any) => string | undefined,
55
+ ) {
56
+ return (args: any, theme: any, context: any) => {
57
+ const state = (context.state ??= {});
58
+ if (context.isPartial) {
59
+ if (!state.spinnerTimer) {
60
+ state.spinnerFrame = 0;
61
+ state.spinnerTimer = setInterval(() => {
62
+ state.spinnerFrame = ((state.spinnerFrame ?? 0) + 1) % SPINNER_FRAMES.length;
63
+ context.invalidate();
64
+ }, 90);
65
+ state.spinnerTimer.unref?.();
66
+ }
67
+ } else if (state.spinnerTimer) {
68
+ clearInterval(state.spinnerTimer);
69
+ state.spinnerTimer = undefined;
70
+ }
71
+
72
+ const frame = context.isPartial ? SPINNER_FRAMES[state.spinnerFrame ?? 0] : "✓";
73
+ const tone = context.isPartial ? "accent" : context.isError ? "error" : "success";
74
+ let text = `${theme.fg(tone, frame)} ${theme.fg("toolTitle", label)}`;
75
+ const detailText = detail?.(args);
76
+ if (detailText) text += theme.fg("dim", ` · ${detailText}`);
77
+ return new Text(text, 0, 0);
78
+ };
79
+ }
80
+
81
+ function summarizeDetails(details: any): string | undefined {
82
+ if (!details || typeof details !== "object") return undefined;
83
+ if (typeof details.error === "string" && details.error.trim()) return details.error.trim();
84
+ if (Array.isArray(details.data)) return `${details.data.length} row${details.data.length === 1 ? "" : "s"}`;
85
+ if (Array.isArray(details.records)) return `${details.records.length} record${details.records.length === 1 ? "" : "s"}`;
86
+ if (typeof details.count === "number") return `${details.count} item${details.count === 1 ? "" : "s"}`;
87
+ if (typeof details.total === "number") return `${details.total} total`;
88
+ if (details.program?.slug) return String(details.program.slug);
89
+ return undefined;
90
+ }
91
+
92
+ function firstTextLine(content: Array<{ type: string; text?: string }> | undefined): string | null {
93
+ if (!Array.isArray(content)) return null;
94
+ const text = content.find((item) => item?.type === "text" && typeof item.text === "string")?.text?.trim();
95
+ if (!text) return null;
96
+ return text.split("\n")[0] ?? null;
97
+ }
98
+
99
+ export function makeProgressResultRenderer(
100
+ successLabel = "Completed",
101
+ summary?: (details: any, args: any) => string | undefined,
102
+ ) {
103
+ return (result: any, options: any, theme: any, context: any) => {
104
+ const state = context.state ?? {};
105
+ if (state.spinnerTimer) {
106
+ clearInterval(state.spinnerTimer);
107
+ state.spinnerTimer = undefined;
108
+ }
109
+ if (options?.isPartial) {
110
+ return new Text(theme.fg("dim", "Working..."), 0, 0);
111
+ }
112
+ if (result?.isError) {
113
+ const line = firstTextLine(result?.content) ?? "Request failed";
114
+ return new Text(`${theme.fg("error", "✕")} ${theme.fg("error", line)}`, 0, 0);
115
+ }
116
+ const detailText = summary?.(result?.details, context?.args) ?? summarizeDetails(result?.details);
117
+ let text = `${theme.fg("success", "✓")} ${theme.fg("muted", successLabel)}`;
118
+ if (detailText) text += theme.fg("dim", ` · ${detailText}`);
119
+ return new Text(text, 0, 0);
120
+ };
121
+ }
122
+
49
123
  export function wrapExecute(fn: (params: any) => Promise<any>) {
50
124
  return async (_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any) => {
51
125
  try {
@@ -1,7 +1,8 @@
1
1
  import { Type } from "@sinclair/typebox";
2
+ import { Text } from "@mariozechner/pi-tui";
2
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
4
  import { ApiError, api } from "../api-client.js";
4
- import { wrapExecute } from "../tool-utils.js";
5
+ import { makeProgressCallRenderer, makeProgressResultRenderer, wrapExecute } from "../tool-utils.js";
5
6
 
6
7
  const DEFAULT_RECORD_LIMIT = 5;
7
8
  const MAX_RECORD_LIMIT = 50;
@@ -132,6 +133,11 @@ export function registerCrmTools(pi: ExtensionAPI) {
132
133
  ),
133
134
  }),
134
135
  execute: wrapExecute(listCrmRecords),
136
+ renderCall: makeProgressCallRenderer("Loading CRM records"),
137
+ renderResult: makeProgressResultRenderer("CRM records loaded", (details) => {
138
+ const count = Array.isArray(details?.records) ? details.records.length : details?.total;
139
+ return typeof count === "number" ? `${count} record${count === 1 ? "" : "s"}` : undefined;
140
+ }),
135
141
  });
136
142
 
137
143
  pi.registerTool({
@@ -142,6 +148,8 @@ export function registerCrmTools(pi: ExtensionAPI) {
142
148
  recordId: Type.String({ description: "CRM record id" }),
143
149
  }),
144
150
  execute: wrapExecute(getCrmRecord),
151
+ renderCall: makeProgressCallRenderer("Loading CRM record", (args) => args?.recordId || undefined),
152
+ renderResult: makeProgressResultRenderer("CRM record loaded", (details) => details?.record?.id || undefined),
145
153
  });
146
154
 
147
155
  pi.registerTool({
@@ -154,6 +162,8 @@ export function registerCrmTools(pi: ExtensionAPI) {
154
162
  noteType: Type.Optional(Type.String({ description: "Optional note type" })),
155
163
  }),
156
164
  execute: wrapExecute(createCrmNote),
165
+ renderCall: makeProgressCallRenderer("Saving CRM note", (args) => args?.recordId || undefined),
166
+ renderResult: makeProgressResultRenderer("CRM note saved"),
157
167
  });
158
168
 
159
169
  pi.registerTool({
@@ -168,6 +178,8 @@ export function registerCrmTools(pi: ExtensionAPI) {
168
178
  assignedUserId: Type.Optional(Type.String({ description: "Optional assigned user id" })),
169
179
  }),
170
180
  execute: wrapExecute(createCrmTask),
181
+ renderCall: makeProgressCallRenderer("Creating CRM task", (args) => args?.recordId || undefined),
182
+ renderResult: makeProgressResultRenderer("CRM task created"),
171
183
  });
172
184
 
173
185
  pi.registerTool({
@@ -179,5 +191,27 @@ export function registerCrmTools(pi: ExtensionAPI) {
179
191
  search: Type.Optional(Type.String({ description: "Optional contact search text" })),
180
192
  }),
181
193
  execute: wrapExecute(listProgramContacts),
194
+ renderCall: makeProgressCallRenderer("Loading program contacts", (args) => args?.programSlug || undefined),
195
+ renderResult(result: any, _args: any, theme: any) {
196
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
197
+ const details = result.details ?? {};
198
+ const rows = Array.isArray(details?.data) ? details.data : [];
199
+ const program = details?.program?.slug ?? details?.program?.name ?? "program";
200
+ if (!rows.length) return new Text(theme.fg("dim", `No contacts found for ${program}`), 0, 0);
201
+
202
+ const shown = rows.slice(0, 8);
203
+ let text = theme.fg("muted", `${rows.length} contact${rows.length === 1 ? "" : "s"} loaded for ${program}`);
204
+ for (const row of shown) {
205
+ const name = row?.party?.display_name ?? row?.person?.full_name ?? "Unknown";
206
+ const org = row?.roles?.[0]?.organization_name ?? row?.organization?.name ?? null;
207
+ const role = row?.roles?.[0]?.role ?? row?.organization_role ?? null;
208
+ const detail = [org, role].filter(Boolean).join(" · ");
209
+ text += `\n ${name}${detail ? ` — ${detail}` : ""}`;
210
+ }
211
+ if (rows.length > shown.length) {
212
+ text += theme.fg("dim", `\n ...and ${rows.length - shown.length} more in this response`);
213
+ }
214
+ return new Text(text, 0, 0);
215
+ },
182
216
  });
183
217
  }
@@ -1,7 +1,60 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
3
  import { ApiError, api } from "../api-client.js";
4
- import { wrapExecute } from "../tool-utils.js";
4
+ import { makeProgressCallRenderer, makeProgressResultRenderer, wrapExecute } from "../tool-utils.js";
5
+ import { Text } from "@mariozechner/pi-tui";
6
+
7
+ const TRANSCRIPT_TEXT_PREVIEW_CHARS = 1200;
8
+ const TRANSCRIPT_VTT_PREVIEW_CHARS = 800;
9
+
10
+ function truncateText(value: string | null | undefined, maxChars: number) {
11
+ if (typeof value !== "string" || !value.trim()) return null;
12
+ if (value.length <= maxChars) return value;
13
+ return `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars]`;
14
+ }
15
+
16
+ function shapeAssetTranscriptFields(asset: any, includeFullText: boolean) {
17
+ const transcriptRaw = typeof asset?.transcript_raw === "string" ? asset.transcript_raw : null;
18
+ const transcriptEdited = typeof asset?.transcript_edited === "string" ? asset.transcript_edited : null;
19
+ const transcriptVtt = typeof asset?.transcript_vtt === "string" ? asset.transcript_vtt : null;
20
+ const effectiveText = transcriptEdited ?? transcriptRaw;
21
+
22
+ return {
23
+ has_transcript_text: !!effectiveText?.trim(),
24
+ has_transcript_vtt: !!transcriptVtt?.trim(),
25
+ transcript_text_length: effectiveText?.length ?? 0,
26
+ transcript_vtt_length: transcriptVtt?.length ?? 0,
27
+ ...(includeFullText
28
+ ? {
29
+ transcript_edited: transcriptEdited,
30
+ transcript_raw: transcriptRaw,
31
+ transcript_vtt: transcriptVtt,
32
+ }
33
+ : {
34
+ transcript_text_preview: truncateText(effectiveText, TRANSCRIPT_TEXT_PREVIEW_CHARS),
35
+ transcript_vtt_preview: truncateText(transcriptVtt, TRANSCRIPT_VTT_PREVIEW_CHARS),
36
+ }),
37
+ };
38
+ }
39
+
40
+ function shapeMediaAssetRow(row: any, includeFullText: boolean) {
41
+ if (!row || typeof row !== "object") return row;
42
+ const asset = row.asset ?? row;
43
+ const shapedAsset = {
44
+ ...asset,
45
+ ...shapeAssetTranscriptFields(asset, includeFullText),
46
+ };
47
+ if (!includeFullText) {
48
+ delete shapedAsset.transcript_edited;
49
+ delete shapedAsset.transcript_raw;
50
+ delete shapedAsset.transcript_vtt;
51
+ }
52
+
53
+ if (row.asset) {
54
+ return { ...row, asset: shapedAsset };
55
+ }
56
+ return shapedAsset;
57
+ }
5
58
 
6
59
  async function listProgramMediaAssets(args: {
7
60
  programSlug: string;
@@ -13,9 +66,11 @@ async function listProgramMediaAssets(args: {
13
66
  partyId?: string;
14
67
  visibility?: string;
15
68
  workflowStatus?: string;
69
+ includeFullText?: boolean;
16
70
  }) {
17
71
  try {
18
- return await api.get<any>(`/programs/${args.programSlug}/media/assets`, {
72
+ const includeFullText = args.includeFullText === true;
73
+ const response = await api.get<any>(`/programs/${args.programSlug}/media/assets`, {
19
74
  asset_kind: args.assetKind,
20
75
  event_date: args.eventDate,
21
76
  limit: args.limit,
@@ -25,15 +80,24 @@ async function listProgramMediaAssets(args: {
25
80
  visibility: args.visibility,
26
81
  workflow_status: args.workflowStatus,
27
82
  });
83
+ return {
84
+ ...response,
85
+ data: Array.isArray(response?.data) ? response.data.map((row: any) => shapeMediaAssetRow(row, includeFullText)) : [],
86
+ };
28
87
  } catch (error) {
29
88
  if (error instanceof ApiError) return { error: error.message, status: error.status };
30
89
  throw error;
31
90
  }
32
91
  }
33
92
 
34
- async function getProgramMediaAsset(args: { programSlug: string; assetId: string }) {
93
+ async function getProgramMediaAsset(args: { programSlug: string; assetId: string; includeFullText?: boolean }) {
35
94
  try {
36
- return await api.get<any>(`/programs/${args.programSlug}/media/assets/${args.assetId}`);
95
+ const includeFullText = args.includeFullText === true;
96
+ const response = await api.get<any>(`/programs/${args.programSlug}/media/assets/${args.assetId}`);
97
+ return {
98
+ ...response,
99
+ asset: shapeMediaAssetRow(response?.asset, includeFullText),
100
+ };
37
101
  } catch (error) {
38
102
  if (error instanceof ApiError) return { error: error.message, status: error.status };
39
103
  throw error;
@@ -124,7 +188,7 @@ export function registerMediaTools(pi: ExtensionAPI) {
124
188
  name: "seedclub_list_program_media_assets",
125
189
  label: "List Program Media Assets",
126
190
  description:
127
- "List media assets for a specific Seed Club program. Use assetKind=full_conversation when the user asks for full conversations, because those asset rows can include both transcript fields and the media file URL. Do not use this as the default source for broad transcript inventory requests; use seedclub_list_meeting_transcripts first for requests like 'all transcripts on 11am'. Use assetKind=clip when the user asks for published clips. Read the response total field for count questions, and use limit plus offset when the user wants paging or more results.",
191
+ "List media assets for a specific Seed Club program. Use assetKind=full_conversation when the user asks for full conversations, because those asset rows can include transcript fields and media URLs. By default this tool returns compact transcript metadata/previews (not full transcript text) to keep payloads small; set includeFullText=true only when full transcript fields are explicitly needed. Do not use this as the default source for broad transcript inventory requests; use seedclub_list_meeting_transcripts first for requests like 'all transcripts on 11am'. Use assetKind=clip when the user asks for published clips. Read the response total field for count questions, and use limit plus offset when the user wants paging or more results.",
128
192
  parameters: Type.Object({
129
193
  programSlug: Type.String({ description: "Program slug" }),
130
194
  assetKind: Type.Optional(
@@ -147,20 +211,62 @@ export function registerMediaTools(pi: ExtensionAPI) {
147
211
  partyId: Type.Optional(Type.String({ description: "Optional party id filter" })),
148
212
  visibility: Type.Optional(Type.String({ description: "Optional visibility filter" })),
149
213
  workflowStatus: Type.Optional(Type.String({ description: "Optional workflow status filter" })),
214
+ includeFullText: Type.Optional(
215
+ Type.Boolean({
216
+ description: "Set true only when full transcript text/VTT fields are required. Defaults to compact previews.",
217
+ }),
218
+ ),
150
219
  }),
151
220
  execute: wrapExecute(listProgramMediaAssets),
221
+ renderCall: makeProgressCallRenderer("Checking media assets", (args) => args?.programSlug || undefined),
222
+ renderResult(result: any, { expanded }: any, theme: any) {
223
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
224
+ const rows = Array.isArray(result.details?.data) ? result.details.data : [];
225
+ const program = result.details?.program?.slug ?? result.details?.program?.name ?? "program";
226
+ if (!rows.length) return new Text(theme.fg("dim", `No media assets for ${program}`), 0, 0);
227
+
228
+ let text = theme.fg("muted", `${rows.length} media asset${rows.length === 1 ? "" : "s"} for ${program}`);
229
+ for (const row of rows.slice(0, expanded ? 12 : 6)) {
230
+ const asset = row?.asset ?? row;
231
+ const date = asset?.event_date ?? "unknown date";
232
+ const title = asset?.title ?? asset?.file_name ?? "untitled";
233
+ const kind = asset?.asset_kind ?? "asset";
234
+ const hasText = asset?.has_transcript_text ? "text" : "no-text";
235
+ const hasVtt = asset?.has_transcript_vtt ? "vtt" : "no-vtt";
236
+ text += `\n ${date} - ${title} [${kind}; ${hasText}; ${hasVtt}]`;
237
+ }
238
+ if (!expanded && rows.length > 6) text += theme.fg("dim", `\n ...and ${rows.length - 6} more`);
239
+ return new Text(text, 0, 0);
240
+ },
152
241
  });
153
242
 
154
243
  pi.registerTool({
155
244
  name: "seedclub_get_program_media_asset",
156
245
  label: "Get Program Media Asset",
157
246
  description:
158
- "Load one program-scoped media asset detail by asset id. Use this after listing full_conversation assets when the user wants the chosen full conversation's transcript fields and media file URL.",
247
+ "Load one program-scoped media asset detail by asset id. Returns compact transcript metadata/previews by default; set includeFullText=true when full transcript text or VTT is explicitly needed.",
159
248
  parameters: Type.Object({
160
249
  programSlug: Type.String({ description: "Program slug" }),
161
250
  assetId: Type.String({ description: "Asset id" }),
251
+ includeFullText: Type.Optional(
252
+ Type.Boolean({
253
+ description: "Set true only when full transcript text/VTT fields are required. Defaults to compact previews.",
254
+ }),
255
+ ),
162
256
  }),
163
257
  execute: wrapExecute(getProgramMediaAsset),
258
+ renderCall: makeProgressCallRenderer("Loading media asset details", (args) => args?.assetId || undefined),
259
+ renderResult(result: any, _args: any, theme: any) {
260
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
261
+ const asset = result.details?.asset ?? null;
262
+ if (!asset) return new Text(theme.fg("dim", "No media asset found"), 0, 0);
263
+ const date = asset?.event_date ?? "unknown date";
264
+ const title = asset?.title ?? asset?.file_name ?? "untitled";
265
+ const kind = asset?.asset_kind ?? "asset";
266
+ const hasText = asset?.has_transcript_text ? "text" : "no-text";
267
+ const hasVtt = asset?.has_transcript_vtt ? "vtt" : "no-vtt";
268
+ return new Text(theme.fg("muted", `${date} - ${title} [${kind}; ${hasText}; ${hasVtt}]`), 0, 0);
269
+ },
164
270
  });
165
271
 
166
272
  pi.registerTool({
@@ -173,6 +279,11 @@ export function registerMediaTools(pi: ExtensionAPI) {
173
279
  slot: Type.Optional(Type.Union([Type.String(), Type.Null()])),
174
280
  }),
175
281
  execute: wrapExecute(listProgramHeadlines),
282
+ renderCall: makeProgressCallRenderer("Loading program headlines", (args) => args?.date || undefined),
283
+ renderResult: makeProgressResultRenderer("Program headlines loaded", (details) => {
284
+ const count = Array.isArray(details?.data) ? details.data.length : undefined;
285
+ return typeof count === "number" ? `${count} headline${count === 1 ? "" : "s"}` : undefined;
286
+ }),
176
287
  });
177
288
 
178
289
  pi.registerTool({
@@ -192,6 +303,8 @@ export function registerMediaTools(pi: ExtensionAPI) {
192
303
  isLive: Type.Optional(Type.Boolean({ description: "Optional live flag" })),
193
304
  }),
194
305
  execute: wrapExecute(createProgramHeadline),
306
+ renderCall: makeProgressCallRenderer("Saving headline", (args) => args?.date || undefined),
307
+ renderResult: makeProgressResultRenderer("Headline saved"),
195
308
  });
196
309
 
197
310
  pi.registerTool({
@@ -213,5 +326,7 @@ export function registerMediaTools(pi: ExtensionAPI) {
213
326
  isLive: Type.Optional(Type.Boolean({ description: "Optional live flag" })),
214
327
  }),
215
328
  execute: wrapExecute(updateProgramHeadline),
329
+ renderCall: makeProgressCallRenderer("Updating headline", (args) => args?.headlineId || undefined),
330
+ renderResult: makeProgressResultRenderer("Headline updated"),
216
331
  });
217
332
  }
@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
2
2
  import { Text } from "@mariozechner/pi-tui";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  import { ApiError, api } from "../api-client.js";
5
- import { wrapExecute } from "../tool-utils.js";
5
+ import { makeProgressCallRenderer, makeProgressResultRenderer, wrapExecute } from "../tool-utils.js";
6
6
 
7
7
  const DEFAULT_MEETINGS_LIMIT = 10;
8
8
  const MAX_MEETINGS_LIMIT = 25;
@@ -1072,6 +1072,11 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1072
1072
  limit: Type.Optional(Type.Number({ description: "Optional max meetings to return. Defaults to 10, max 25." })),
1073
1073
  }),
1074
1074
  execute: wrapExecute(listMeetings),
1075
+ renderCall: makeProgressCallRenderer("Checking meeting schedule", (args) => args?.programSlug || undefined),
1076
+ renderResult: makeProgressResultRenderer("Meeting schedule loaded", (details) => {
1077
+ const count = Array.isArray(details?.data) ? details.data.length : undefined;
1078
+ return typeof count === "number" ? `${count} meeting${count === 1 ? "" : "s"}` : undefined;
1079
+ }),
1075
1080
  });
1076
1081
 
1077
1082
  pi.registerTool({
@@ -1092,6 +1097,11 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1092
1097
  ),
1093
1098
  }),
1094
1099
  execute: wrapExecute(listShowGuests),
1100
+ renderCall: makeProgressCallRenderer("Looking up show guests", (args) => args?.programSlug || undefined),
1101
+ renderResult: makeProgressResultRenderer("Show guests loaded", (details) => {
1102
+ const count = Array.isArray(details?.data) ? details.data.length : undefined;
1103
+ return typeof count === "number" ? `${count} guest row${count === 1 ? "" : "s"}` : undefined;
1104
+ }),
1095
1105
  });
1096
1106
 
1097
1107
  pi.registerTool({
@@ -1118,6 +1128,11 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1118
1128
  ),
1119
1129
  }),
1120
1130
  execute: wrapExecute(listGuestRoster),
1131
+ renderCall: makeProgressCallRenderer("Building guest roster", (args) => args?.programSlug || undefined),
1132
+ renderResult: makeProgressResultRenderer("Guest roster ready", (details) => {
1133
+ const count = Array.isArray(details?.rows) ? details.rows.length : undefined;
1134
+ return typeof count === "number" ? `${count} guest${count === 1 ? "" : "s"}` : undefined;
1135
+ }),
1121
1136
  });
1122
1137
 
1123
1138
  pi.registerTool({
@@ -1138,6 +1153,8 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1138
1153
  ),
1139
1154
  }),
1140
1155
  execute: wrapExecute(getGuestProfile),
1156
+ renderCall: makeProgressCallRenderer("Resolving guest profile", (args) => args?.search || args?.email || args?.partyId || undefined),
1157
+ renderResult: makeProgressResultRenderer("Guest profile loaded", (details) => details?.contact?.displayName || undefined),
1141
1158
  });
1142
1159
 
1143
1160
  pi.registerTool({
@@ -1165,6 +1182,7 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1165
1182
  ),
1166
1183
  }),
1167
1184
  execute: wrapExecute(findLatestGuestTranscript),
1185
+ renderCall: makeProgressCallRenderer("Finding latest guest transcript", (args) => args?.guest || undefined),
1168
1186
  renderResult(result: any, _args: any, theme: any) {
1169
1187
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
1170
1188
  const found = result.details?.found === true;
@@ -1192,6 +1210,34 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1192
1210
  ),
1193
1211
  }),
1194
1212
  execute: wrapExecute(prepareClipPacket),
1213
+ renderCall: makeProgressCallRenderer("Checking clip readiness", (args) => args?.meetingId || undefined),
1214
+ renderResult(result: any, _args: any, theme: any) {
1215
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
1216
+
1217
+ const details = result.details ?? {};
1218
+ const meeting = details.meeting ?? {};
1219
+ const guestName =
1220
+ details?.guestProfile?.contact?.displayName ??
1221
+ details?.guestProfile?.query?.search ??
1222
+ details?.guestProfile?.query?.email ??
1223
+ "Unknown guest";
1224
+ const eventDate = meeting?.eventDate ?? "unknown date";
1225
+ const hasTranscript = details?.transcript?.available === true;
1226
+ const hasRecording = !!details?.recordings?.matchedMeetingRecording?.recording;
1227
+ const ready = details?.clipReadiness?.ready === true;
1228
+ const reasons = Array.isArray(details?.clipReadiness?.reasons)
1229
+ ? details.clipReadiness.reasons.filter((reason: any) => typeof reason === "string")
1230
+ : [];
1231
+
1232
+ let text = theme.fg(
1233
+ "muted",
1234
+ `${eventDate} · ${guestName} · transcript:${hasTranscript ? "yes" : "no"} · recording:${hasRecording ? "yes" : "no"} · ready:${ready ? "yes" : "no"}`,
1235
+ );
1236
+ if (!ready && reasons.length) {
1237
+ text += theme.fg("dim", `\n blockers: ${reasons.join(", ")}`);
1238
+ }
1239
+ return new Text(text, 0, 0);
1240
+ },
1195
1241
  });
1196
1242
 
1197
1243
  pi.registerTool({
@@ -1214,6 +1260,11 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1214
1260
  ),
1215
1261
  }),
1216
1262
  execute: wrapExecute(listShowRecordings),
1263
+ renderCall: makeProgressCallRenderer("Checking show recordings", (args) => args?.programSlug || undefined),
1264
+ renderResult: makeProgressResultRenderer("Show recordings loaded", (details) => {
1265
+ const count = Array.isArray(details?.data) ? details.data.length : undefined;
1266
+ return typeof count === "number" ? `${count} recording${count === 1 ? "" : "s"}` : undefined;
1267
+ }),
1217
1268
  });
1218
1269
 
1219
1270
  pi.registerTool({
@@ -1240,22 +1291,34 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1240
1291
  ),
1241
1292
  }),
1242
1293
  execute: wrapExecute(listMeetingTranscripts),
1243
- renderResult(result: any, { expanded }: any, theme: any) {
1294
+ renderCall: makeProgressCallRenderer("Checking transcript inventory", (args) => args?.programSlug || undefined),
1295
+ renderResult(result: any, renderArgs: any, theme: any) {
1244
1296
  if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
1297
+ const expanded = renderArgs?.expanded === true;
1298
+ const requestedLimit = Number.isFinite(renderArgs?.limit) ? Math.trunc(renderArgs.limit) : DEFAULT_TRANSCRIPT_LIMIT;
1245
1299
  const rows = Array.isArray(result.details?.data) ? result.details.data : [];
1246
1300
  const program = result.details?.program?.slug ?? result.details?.program?.name ?? "program";
1247
1301
  if (!rows.length) return new Text(theme.fg("dim", `No transcripts found for ${program}`), 0, 0);
1248
1302
 
1249
- let text = theme.fg("muted", `${rows.length} transcript${rows.length === 1 ? "" : "s"} for ${program}`);
1250
- for (const row of rows.slice(0, expanded ? 20 : 8)) {
1303
+ const visibleCount = expanded ? 20 : 8;
1304
+ const shown = Math.min(visibleCount, rows.length);
1305
+ let text = theme.fg("muted", `${rows.length} transcript${rows.length === 1 ? "" : "s"} loaded for ${program}`);
1306
+ for (const row of rows.slice(0, shown)) {
1251
1307
  const date = row?.transcript?.event_date ?? "unknown date";
1252
1308
  const name = row?.transcript_for ?? "Unknown";
1253
1309
  const status = row?.transcript?.status ?? "unknown";
1254
1310
  text += `\n ${date} - ${name} [${status}]`;
1255
1311
  }
1256
- if (!expanded && rows.length > 8) {
1257
- text += theme.fg("dim", `\n ...and ${rows.length - 8} more`);
1312
+ if (!expanded && rows.length > shown) {
1313
+ text += theme.fg("dim", `\n ...and ${rows.length - shown} more in this response`);
1258
1314
  }
1315
+ const mightHaveMore = rows.length >= requestedLimit;
1316
+ text += theme.fg(
1317
+ "dim",
1318
+ `\n Showing ${shown} of ${rows.length} loaded transcript row${rows.length === 1 ? "" : "s"}.${
1319
+ mightHaveMore ? " There may be more available (increase limit up to 20)." : ""
1320
+ }`,
1321
+ );
1259
1322
  return new Text(text, 0, 0);
1260
1323
  },
1261
1324
  });
@@ -1274,6 +1337,11 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1274
1337
  ),
1275
1338
  }),
1276
1339
  execute: wrapExecute(getMeetingTranscript),
1340
+ renderCall: makeProgressCallRenderer("Loading meeting transcript", (args) => args?.meetingId || undefined),
1341
+ renderResult: makeProgressResultRenderer("Meeting transcript loaded", (details) => {
1342
+ const date = details?.transcript?.event_date;
1343
+ return typeof date === "string" ? date : undefined;
1344
+ }),
1277
1345
  });
1278
1346
 
1279
1347
  pi.registerTool({
@@ -1285,6 +1353,8 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1285
1353
  meetingId: Type.String({ description: "Meeting id" }),
1286
1354
  }),
1287
1355
  execute: wrapExecute(getMeeting),
1356
+ renderCall: makeProgressCallRenderer("Loading meeting details", (args) => args?.meetingId || undefined),
1357
+ renderResult: makeProgressResultRenderer("Meeting details loaded", (details) => details?.meeting?.id || undefined),
1288
1358
  });
1289
1359
 
1290
1360
  pi.registerTool({
@@ -1299,6 +1369,8 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1299
1369
  producerFraming: Type.Optional(Type.Union([Type.String(), Type.Null()])),
1300
1370
  }),
1301
1371
  execute: wrapExecute(updateMeeting),
1372
+ renderCall: makeProgressCallRenderer("Updating meeting", (args) => args?.meetingId || undefined),
1373
+ renderResult: makeProgressResultRenderer("Meeting updated"),
1302
1374
  });
1303
1375
 
1304
1376
  pi.registerTool({
@@ -1311,5 +1383,7 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1311
1383
  syncPendingEmailToPerson: Type.Optional(Type.Boolean({ description: "Whether to sync pending email to the person" })),
1312
1384
  }),
1313
1385
  execute: wrapExecute(assignMeeting),
1386
+ renderCall: makeProgressCallRenderer("Assigning meeting", (args) => args?.meetingId || undefined),
1387
+ renderResult: makeProgressResultRenderer("Meeting assigned"),
1314
1388
  });
1315
1389
  }
@@ -1,7 +1,7 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
3
  import { ApiError, api } from "../api-client.js";
4
- import { wrapExecute } from "../tool-utils.js";
4
+ import { makeProgressCallRenderer, makeProgressResultRenderer, wrapExecute } from "../tool-utils.js";
5
5
 
6
6
  export async function getSessionContext() {
7
7
  try {
@@ -98,6 +98,8 @@ export function registerUtilityTools(pi: ExtensionAPI) {
98
98
  "Get the current Seed Club session, workspace, and accessible program context. Use only for auth, workspace, or program access discovery; do not use for transcript, guest, media, clip, or show-content retrieval.",
99
99
  parameters: Type.Object({}),
100
100
  execute: wrapExecute(getSessionContext),
101
+ renderCall: makeProgressCallRenderer("Loading workspace session context"),
102
+ renderResult: makeProgressResultRenderer("Workspace context loaded"),
101
103
  });
102
104
 
103
105
  pi.registerTool({
@@ -106,6 +108,8 @@ export function registerUtilityTools(pi: ExtensionAPI) {
106
108
  description: "Get information about the currently authenticated Seed Club user and their accessible program count.",
107
109
  parameters: Type.Object({}),
108
110
  execute: wrapExecute(getCurrentUser),
111
+ renderCall: makeProgressCallRenderer("Loading current user"),
112
+ renderResult: makeProgressResultRenderer("Current user loaded", (details) => details?.name || undefined),
109
113
  });
110
114
 
111
115
  pi.registerTool({
@@ -114,6 +118,11 @@ export function registerUtilityTools(pi: ExtensionAPI) {
114
118
  description: "List the Seed Club programs accessible to the current user.",
115
119
  parameters: Type.Object({}),
116
120
  execute: wrapExecute(listAccessiblePrograms),
121
+ renderCall: makeProgressCallRenderer("Loading accessible programs"),
122
+ renderResult: makeProgressResultRenderer("Accessible programs loaded", (details) => {
123
+ const count = Array.isArray(details?.programs) ? details.programs.length : 0;
124
+ return `${count} program${count === 1 ? "" : "s"}`;
125
+ }),
117
126
  });
118
127
 
119
128
  pi.registerTool({
@@ -124,5 +133,7 @@ export function registerUtilityTools(pi: ExtensionAPI) {
124
133
  programSlug: Type.String({ description: "Program slug" }),
125
134
  }),
126
135
  execute: wrapExecute(getProgramAccess),
136
+ renderCall: makeProgressCallRenderer("Checking program access", (args) => args?.programSlug || undefined),
137
+ renderResult: makeProgressResultRenderer("Program access loaded", (details) => details?.program?.slug || undefined),
127
138
  });
128
139
  }
@@ -14,12 +14,13 @@ class SurfaceEditor extends CustomEditor {
14
14
  private readonly getPalette: () => Palette;
15
15
  private static readonly RESET = "\x1b[0m";
16
16
  private static readonly INSET = "";
17
+ private static readonly CONTENT_INSET = 2;
17
18
  private static readonly PLACEHOLDER = "/ for menu";
18
19
 
19
20
  constructor(tui: TUI, theme: EditorTheme, kb: KeybindingsManager, getPalette: () => Palette) {
20
21
  super(tui, theme, kb);
21
22
  this.getPalette = getPalette;
22
- this.setPaddingX(2);
23
+ super.setPaddingX(SurfaceEditor.CONTENT_INSET);
23
24
  }
24
25
 
25
26
  render(width: number): string[] {
@@ -47,6 +48,10 @@ class SurfaceEditor extends CustomEditor {
47
48
  super.invalidate();
48
49
  }
49
50
 
51
+ override setPaddingX(_padding: number): void {
52
+ super.setPaddingX(SurfaceEditor.CONTENT_INSET);
53
+ }
54
+
50
55
  override handleInput(data: string): void {
51
56
  if (!uiState.ready) {
52
57
  // Keep app-level interrupts available while startup UI is loading.
@@ -8,10 +8,12 @@ import editorExtension from "./editor.js";
8
8
  import footerExtension from "./footer.js";
9
9
  import updateExtension from "./update.js";
10
10
  import welcomeExtension from "./welcome.js";
11
+ import toolProgressExtension from "./tool-progress.js";
11
12
 
12
13
  export default function (pi: ExtensionAPI) {
13
14
  editorExtension(pi);
14
15
  footerExtension(pi);
15
16
  updateExtension(pi);
17
+ toolProgressExtension(pi);
16
18
  welcomeExtension(pi, { enableFrame: false });
17
19
  }