@quillmeetings/cli 0.1.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.
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/bin/quill.js +20 -0
- package/package.json +46 -0
- package/src/browser.js +695 -0
- package/src/cli.js +841 -0
- package/src/config.js +132 -0
- package/src/doctor.js +190 -0
- package/src/format.js +275 -0
- package/src/mcp-client.js +326 -0
- package/src/tool-router.js +104 -0
package/src/browser.js
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
|
|
4
|
+
import { extractToolResult } from "./mcp-client.js";
|
|
5
|
+
import { buildArgs, findTool } from "./tool-router.js";
|
|
6
|
+
|
|
7
|
+
const h = React.createElement;
|
|
8
|
+
|
|
9
|
+
const ACTIONS_PROMPT = "Extract action items from this meeting. Return only concrete tasks. For each item include owner if mentioned, due date if mentioned, status or uncertainty, and brief source context. If there are no clear action items, say so explicitly.";
|
|
10
|
+
const FOLLOWUP_PROMPT = "Draft a concise follow-up note for this meeting. Include a short recap, decisions, open questions, action items, and a friendly next-step section. Keep it practical and ready to send.";
|
|
11
|
+
|
|
12
|
+
export function runMeetingBrowser(client, tools, meetings, options, pickerOptions = {}, helpers = {}) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const app = render(h(MeetingBrowser, {
|
|
15
|
+
client,
|
|
16
|
+
tools,
|
|
17
|
+
meetings,
|
|
18
|
+
options,
|
|
19
|
+
pickerOptions,
|
|
20
|
+
helpers,
|
|
21
|
+
onDone: (value) => {
|
|
22
|
+
resolve(value);
|
|
23
|
+
app.unmount();
|
|
24
|
+
},
|
|
25
|
+
}), {
|
|
26
|
+
alternateScreen: true,
|
|
27
|
+
exitOnCtrlC: false,
|
|
28
|
+
stdin: process.stdin,
|
|
29
|
+
stdout: process.stdout,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function MeetingBrowser({ client, tools, meetings, options, pickerOptions, helpers, onDone }) {
|
|
35
|
+
const { exit } = useApp();
|
|
36
|
+
const { height, width } = useWindowSize();
|
|
37
|
+
const screenHeight = Number.isFinite(height) && height > 0 ? height : 24;
|
|
38
|
+
const screenWidth = Number.isFinite(width) && width > 0 ? width : process.stdout.columns || 100;
|
|
39
|
+
const doneRef = useRef(false);
|
|
40
|
+
const initialMeetings = useRef(meetings);
|
|
41
|
+
const [currentMeetings, setCurrentMeetings] = useState(meetings);
|
|
42
|
+
const [selected, setSelected] = useState(0);
|
|
43
|
+
const [query, setQuery] = useState("");
|
|
44
|
+
const [serverQuery, setServerQuery] = useState("");
|
|
45
|
+
const [mode, setMode] = useState("list");
|
|
46
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
47
|
+
const [panel, setPanel] = useState(null);
|
|
48
|
+
const [pendingAction, setPendingAction] = useState(null);
|
|
49
|
+
const [searching, setSearching] = useState(false);
|
|
50
|
+
const [searchError, setSearchError] = useState("");
|
|
51
|
+
const [panelScroll, setPanelScroll] = useState(0);
|
|
52
|
+
const [copyStatus, setCopyStatus] = useState("");
|
|
53
|
+
|
|
54
|
+
const filtered = useMemo(() => {
|
|
55
|
+
const normalized = query.trim().toLowerCase();
|
|
56
|
+
if (!normalized) return currentMeetings;
|
|
57
|
+
return currentMeetings.filter((meeting) => [
|
|
58
|
+
meeting.title,
|
|
59
|
+
meeting.date,
|
|
60
|
+
meeting.duration,
|
|
61
|
+
meeting.participants,
|
|
62
|
+
meeting.tags,
|
|
63
|
+
].filter(Boolean).join(" ").toLowerCase().includes(normalized));
|
|
64
|
+
}, [currentMeetings, query]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
setSelected((value) => Math.min(value, Math.max(filtered.length - 1, 0)));
|
|
68
|
+
}, [filtered.length]);
|
|
69
|
+
|
|
70
|
+
const finish = (value) => {
|
|
71
|
+
if (doneRef.current) return;
|
|
72
|
+
doneRef.current = true;
|
|
73
|
+
exit();
|
|
74
|
+
onDone(value);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const runServerSearch = async (searchText) => {
|
|
78
|
+
if (!helpers.searchMeetings) {
|
|
79
|
+
setSearchError("Server search is not available in this context.");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setSearching(true);
|
|
83
|
+
setSearchError("");
|
|
84
|
+
try {
|
|
85
|
+
const results = await helpers.searchMeetings(searchText);
|
|
86
|
+
setCurrentMeetings(Array.isArray(results) ? results : []);
|
|
87
|
+
setServerQuery(searchText);
|
|
88
|
+
setQuery("");
|
|
89
|
+
setSelected(0);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
setSearchError(`${error.code || "search_failed"}: ${error.message || String(error)}`);
|
|
92
|
+
} finally {
|
|
93
|
+
setSearching(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const loadPanel = async (action) => {
|
|
98
|
+
const meeting = filtered[selected];
|
|
99
|
+
if (!meeting) return;
|
|
100
|
+
if (pickerOptions.selectOnly) return finish({ meeting, action });
|
|
101
|
+
|
|
102
|
+
setPanel({
|
|
103
|
+
title: `${actionLabel(action)}: ${meeting.title || "(untitled)"}`,
|
|
104
|
+
body: "Loading...",
|
|
105
|
+
kind: "loading",
|
|
106
|
+
});
|
|
107
|
+
setPanelScroll(0);
|
|
108
|
+
setCopyStatus("");
|
|
109
|
+
setMode("panel");
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const result = action === "view"
|
|
113
|
+
? await loadMeetingOverview(client, tools, meeting)
|
|
114
|
+
: await callPanelTool(client, tools, action, meeting);
|
|
115
|
+
setPanel({
|
|
116
|
+
title: `${actionLabel(action)}: ${meeting.title || "(untitled)"}`,
|
|
117
|
+
body: formatBrowsePanel(action, meeting, result),
|
|
118
|
+
kind: action,
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
setPanel({
|
|
122
|
+
title: "Error",
|
|
123
|
+
body: `${error.code || "error"}: ${error.message}`,
|
|
124
|
+
kind: "error",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const confirmMutation = (action) => {
|
|
130
|
+
setPendingAction(action);
|
|
131
|
+
setMode("confirm");
|
|
132
|
+
setShowHelp(false);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const copyPanel = async () => {
|
|
136
|
+
if (!panel?.body || panel.kind === "loading") {
|
|
137
|
+
setCopyStatus("Nothing to copy yet.");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
await copyToClipboard(panel.body);
|
|
142
|
+
setCopyStatus(`Copied ${copyLabel(panel.kind)} to clipboard.`);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
setCopyStatus(`${error.code || "copy_failed"}: ${error.message || String(error)}`);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const exitPanel = () => {
|
|
149
|
+
setMode("list");
|
|
150
|
+
setShowHelp(false);
|
|
151
|
+
setPanel(null);
|
|
152
|
+
setPanelScroll(0);
|
|
153
|
+
setCopyStatus("");
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const exitConfirm = () => {
|
|
157
|
+
setPendingAction(null);
|
|
158
|
+
setMode(panel ? "panel" : "list");
|
|
159
|
+
setShowHelp(false);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const exitFilter = () => {
|
|
163
|
+
setQuery("");
|
|
164
|
+
setMode("list");
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const clearServerSearch = () => {
|
|
168
|
+
setCurrentMeetings(initialMeetings.current);
|
|
169
|
+
setServerQuery("");
|
|
170
|
+
setSelected(0);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
useInput((input, key = {}) => {
|
|
174
|
+
if (key.ctrl && input === "c") return finish(null);
|
|
175
|
+
if (searching) return;
|
|
176
|
+
|
|
177
|
+
if (mode === "filter") {
|
|
178
|
+
if (key.escape) return exitFilter();
|
|
179
|
+
if (key.return) {
|
|
180
|
+
setMode("list");
|
|
181
|
+
const trimmed = query.trim();
|
|
182
|
+
if (!trimmed) {
|
|
183
|
+
if (serverQuery) clearServerSearch();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
return runServerSearch(trimmed);
|
|
187
|
+
}
|
|
188
|
+
if (key.backspace || key.delete) return setQuery((value) => value.slice(0, -1));
|
|
189
|
+
if (input && !key.ctrl && !key.meta && input >= " ") return setQuery((value) => value + input);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (mode === "panel") {
|
|
194
|
+
if (key.escape || input === "b") return exitPanel();
|
|
195
|
+
if (input === "q") return finish(null);
|
|
196
|
+
if (input === "?") return setShowHelp((value) => !value);
|
|
197
|
+
if (input === "c") return copyPanel();
|
|
198
|
+
if (input === "n") return loadPanel("notes");
|
|
199
|
+
if (input === "t") return loadPanel("transcript");
|
|
200
|
+
if (input === "a") return confirmMutation("actions");
|
|
201
|
+
if (input === "f") return confirmMutation("followup");
|
|
202
|
+
if (key.return) return loadPanel("view");
|
|
203
|
+
if (isDownKey(input, key)) return setPanelScroll((value) => value + 1);
|
|
204
|
+
if (isUpKey(input, key)) return setPanelScroll((value) => Math.max(value - 1, 0));
|
|
205
|
+
if (isPageDownKey(key)) return setPanelScroll((value) => value + 8);
|
|
206
|
+
if (isPageUpKey(key)) return setPanelScroll((value) => Math.max(value - 8, 0));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (mode === "confirm") {
|
|
211
|
+
if (key.escape || input === "n" || input === "b") return exitConfirm();
|
|
212
|
+
if (input === "q") return finish(null);
|
|
213
|
+
if (input === "?") return setShowHelp((value) => !value);
|
|
214
|
+
if (input === "y") {
|
|
215
|
+
const action = pendingAction;
|
|
216
|
+
setPendingAction(null);
|
|
217
|
+
return loadPanel(action);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// List mode. ESC pops one layer at a time, then quits.
|
|
223
|
+
if (key.escape) {
|
|
224
|
+
if (showHelp) return setShowHelp(false);
|
|
225
|
+
if (searchError) return setSearchError("");
|
|
226
|
+
if (query) return setQuery("");
|
|
227
|
+
if (serverQuery) return clearServerSearch();
|
|
228
|
+
return finish(null);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (input === "q") return finish(null);
|
|
232
|
+
if (isDownKey(input, key)) return setSelected((value) => Math.min(value + 1, filtered.length - 1));
|
|
233
|
+
if (isUpKey(input, key)) return setSelected((value) => Math.max(value - 1, 0));
|
|
234
|
+
if (input === "/") {
|
|
235
|
+
setMode("filter");
|
|
236
|
+
setShowHelp(false);
|
|
237
|
+
setSearchError("");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (input === "?") return setShowHelp((value) => !value);
|
|
241
|
+
if (key.return && filtered[selected]) return loadPanel("view");
|
|
242
|
+
if (input === "n" && filtered[selected]) return loadPanel("notes");
|
|
243
|
+
if (input === "t" && filtered[selected]) return loadPanel("transcript");
|
|
244
|
+
if (input === "a" && filtered[selected]) return confirmMutation("actions");
|
|
245
|
+
if (input === "f" && filtered[selected]) return confirmMutation("followup");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (mode === "confirm") {
|
|
249
|
+
return h(ConfirmView, {
|
|
250
|
+
action: pendingAction,
|
|
251
|
+
meeting: filtered[selected],
|
|
252
|
+
showHelp,
|
|
253
|
+
screenHeight,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (mode === "panel") {
|
|
258
|
+
return h(PanelView, {
|
|
259
|
+
panel,
|
|
260
|
+
showHelp,
|
|
261
|
+
panelScroll,
|
|
262
|
+
copyStatus,
|
|
263
|
+
maxBodyLines: Math.max(4, screenHeight - (showHelp ? 16 : 5)),
|
|
264
|
+
truncate: options.browsePanelTruncate,
|
|
265
|
+
screenHeight,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return h(ListView, {
|
|
270
|
+
filtered,
|
|
271
|
+
selected,
|
|
272
|
+
query,
|
|
273
|
+
serverQuery,
|
|
274
|
+
mode,
|
|
275
|
+
showHelp,
|
|
276
|
+
searching,
|
|
277
|
+
searchError,
|
|
278
|
+
maxRows: Math.max(5, screenHeight - (showHelp ? 17 : 5)),
|
|
279
|
+
width: screenWidth,
|
|
280
|
+
screenHeight,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function ListView({ filtered, selected, query, serverQuery, mode, showHelp, searching, searchError, maxRows, width, screenHeight }) {
|
|
285
|
+
const windowed = windowRows(filtered, selected, maxRows);
|
|
286
|
+
const columns = listColumns(width);
|
|
287
|
+
const countLabel = filtered.length > maxRows
|
|
288
|
+
? `${windowed.start + 1}-${windowed.end} of ${filtered.length} meetings`
|
|
289
|
+
: `${filtered.length} meeting${filtered.length === 1 ? "" : "s"}`;
|
|
290
|
+
return h(Box, { flexDirection: "column", height: screenHeight },
|
|
291
|
+
h(Header, { title: "Quill meetings", hint: mode === "filter"
|
|
292
|
+
? "type=filter live Enter=server search Esc=cancel"
|
|
293
|
+
: "Enter=view n=notes t=transcript a=actions f=follow-up /=search ? help q=quit" }),
|
|
294
|
+
mode === "filter"
|
|
295
|
+
? h(Text, null, h(Text, { color: "cyan" }, "search: "), query, h(Text, { color: "cyan" }, "█"))
|
|
296
|
+
: null,
|
|
297
|
+
searchError ? h(Text, { color: "red" }, searchError) : null,
|
|
298
|
+
showHelp ? h(HelpView) : null,
|
|
299
|
+
searching ? h(Text, { color: "cyan" }, `Searching for "${query || serverQuery}"...`) : null,
|
|
300
|
+
!searching && filtered.length === 0 ? h(Text, { color: "yellow" }, `No meetings${query || serverQuery ? ` for "${query || serverQuery}"` : ""}.`) : null,
|
|
301
|
+
!searching && filtered.length > 0 ? h(Box, { flexDirection: "column", marginTop: 1 },
|
|
302
|
+
...windowed.rows.map(({ meeting, index }) => h(MeetingRow, {
|
|
303
|
+
key: meeting.id || index,
|
|
304
|
+
meeting,
|
|
305
|
+
selected: index === selected,
|
|
306
|
+
query,
|
|
307
|
+
columns,
|
|
308
|
+
})),
|
|
309
|
+
) : null,
|
|
310
|
+
h(Box, { flexGrow: 1 }),
|
|
311
|
+
h(Box, { flexDirection: "column" },
|
|
312
|
+
serverQuery
|
|
313
|
+
? h(Text, null, h(Text, { color: "cyan" }, `server results for "${serverQuery}"`), h(Text, { dimColor: true }, " (Esc to clear)"))
|
|
314
|
+
: null,
|
|
315
|
+
mode !== "filter" && !searching ? h(Text, { dimColor: true }, countLabel) : null,
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function MeetingRow({ meeting, selected, query, columns }) {
|
|
321
|
+
const marker = selected ? ">" : " ";
|
|
322
|
+
const title = truncateInline(meeting.title || "(untitled)", columns.title - 1);
|
|
323
|
+
const tags = truncateInline(meeting.tags || "", columns.tags);
|
|
324
|
+
return h(Box, { flexDirection: "row" },
|
|
325
|
+
h(Box, { width: 2 },
|
|
326
|
+
h(Text, { color: selected ? "cyan" : undefined, bold: selected }, `${marker} `),
|
|
327
|
+
),
|
|
328
|
+
h(Box, { width: columns.title },
|
|
329
|
+
h(Text, { color: selected ? "cyan" : undefined, bold: selected }, h(HighlightedText, { text: title, query })),
|
|
330
|
+
),
|
|
331
|
+
h(Box, { width: columns.date },
|
|
332
|
+
h(Text, { dimColor: true }, formatShortDate(meeting.date)),
|
|
333
|
+
),
|
|
334
|
+
h(Box, { width: columns.duration },
|
|
335
|
+
h(Text, { dimColor: true }, String(meeting.duration || "")),
|
|
336
|
+
),
|
|
337
|
+
columns.tags > 0 ? h(Box, { width: columns.tags },
|
|
338
|
+
h(Text, { color: selected ? "green" : "gray" }, h(HighlightedText, { text: tags, query })),
|
|
339
|
+
) : null,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function PanelView({ panel, showHelp, panelScroll, copyStatus, maxBodyLines, truncate, screenHeight }) {
|
|
344
|
+
const body = truncatePanel(panel?.body || "", truncate);
|
|
345
|
+
const lines = body.split("\n");
|
|
346
|
+
const maxScroll = Math.max(lines.length - maxBodyLines, 0);
|
|
347
|
+
const scroll = Math.min(panelScroll, maxScroll);
|
|
348
|
+
const visibleLines = lines.slice(scroll, scroll + maxBodyLines);
|
|
349
|
+
return h(Box, { flexDirection: "column", height: screenHeight },
|
|
350
|
+
h(Header, {
|
|
351
|
+
title: panel?.title || "Meeting",
|
|
352
|
+
hint: "b/Esc=list c=copy n=notes t=transcript a=actions f=follow-up arrows/j/k=scroll ? help q=quit",
|
|
353
|
+
tone: panel?.kind === "error" ? "error" : "normal",
|
|
354
|
+
}),
|
|
355
|
+
showHelp ? h(HelpView, { panel: true }) : null,
|
|
356
|
+
h(Box, { borderStyle: "round", borderColor: panel?.kind === "error" ? "red" : "cyan", paddingX: 1, flexDirection: "column" },
|
|
357
|
+
...visibleLines.map((line, index) => h(Text, { key: `${scroll}-${index}` }, line || " ")),
|
|
358
|
+
),
|
|
359
|
+
h(Box, { flexGrow: 1 }),
|
|
360
|
+
copyStatus ? h(Text, { color: copyStatus.startsWith("Copied") ? "green" : "yellow" }, copyStatus) : null,
|
|
361
|
+
h(PanelFooter, { scroll, maxScroll, maxBodyLines, totalLines: lines.length }),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function ConfirmView({ action, meeting, showHelp, screenHeight }) {
|
|
366
|
+
return h(Box, { flexDirection: "column", height: screenHeight },
|
|
367
|
+
h(Header, { title: actionLabel(action), hint: "y=create n/b/Esc=cancel ? help q=quit", tone: "warning" }),
|
|
368
|
+
showHelp ? h(HelpView) : null,
|
|
369
|
+
h(Box, { flexGrow: 1 }),
|
|
370
|
+
h(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column" },
|
|
371
|
+
h(Text, null, "Create a generated ", actionLabel(action).toLowerCase(), " note for:"),
|
|
372
|
+
h(Text, { bold: true }, meeting?.title || "(untitled)"),
|
|
373
|
+
h(Text, null, " "),
|
|
374
|
+
h(Text, { color: "yellow" }, "This will add a new note to the meeting in Quill."),
|
|
375
|
+
),
|
|
376
|
+
h(Box, { flexGrow: 1 }),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function Header({ title, hint, tone = "normal" }) {
|
|
381
|
+
const color = tone === "warning" ? "yellow" : tone === "error" ? "red" : "cyan";
|
|
382
|
+
return h(Box, { flexDirection: "column" },
|
|
383
|
+
h(Text, { color, bold: true }, title),
|
|
384
|
+
h(Text, { dimColor: true }, hint),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function HelpView() {
|
|
389
|
+
return h(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "column", marginBottom: 1 },
|
|
390
|
+
h(Text, { bold: true }, "Browse keys"),
|
|
391
|
+
h(HelpLine, { keys: "↑/↓, j/k", text: "Move selection or scroll a panel" }),
|
|
392
|
+
h(HelpLine, { keys: "Enter", text: "View selected meeting without leaving browse" }),
|
|
393
|
+
h(HelpLine, { keys: "n", text: "Open notes/minutes" }),
|
|
394
|
+
h(HelpLine, { keys: "t", text: "Open transcript" }),
|
|
395
|
+
h(HelpLine, { keys: "a", text: "Generate action-item note after confirmation" }),
|
|
396
|
+
h(HelpLine, { keys: "f", text: "Generate follow-up note after confirmation" }),
|
|
397
|
+
h(HelpLine, { keys: "c", text: "Copy the current panel content to clipboard" }),
|
|
398
|
+
h(HelpLine, { keys: "/", text: "Search locally while typing, Enter fetches from server" }),
|
|
399
|
+
h(HelpLine, { keys: "b/Esc", text: "Back or clear active state" }),
|
|
400
|
+
h(HelpLine, { keys: "q", text: "Quit" }),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function PanelFooter({ scroll, maxScroll, maxBodyLines, totalLines }) {
|
|
405
|
+
if (maxScroll <= 0) return h(Text, { dimColor: true }, "End of content");
|
|
406
|
+
const end = Math.min(scroll + maxBodyLines, totalLines);
|
|
407
|
+
const canScrollUp = scroll > 0;
|
|
408
|
+
const canScrollDown = scroll < maxScroll;
|
|
409
|
+
return h(Box, { flexDirection: "column", marginTop: 1 },
|
|
410
|
+
h(Text, { color: "yellow" },
|
|
411
|
+
canScrollUp ? "↑ more above" : "top",
|
|
412
|
+
" ",
|
|
413
|
+
`lines ${scroll + 1}-${end} of ${totalLines}`,
|
|
414
|
+
" ",
|
|
415
|
+
canScrollDown ? "↓ more below" : "end",
|
|
416
|
+
),
|
|
417
|
+
h(Text, { dimColor: true }, "Scroll with ↑/↓ or j/k. Use PgUp/PgDn for larger jumps. Press b or Esc to return to the list."),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function HelpLine({ keys, text }) {
|
|
422
|
+
return h(Text, null, h(Text, { color: "cyan" }, keys.padEnd(13)), " ", text);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function HighlightedText({ text, query }) {
|
|
426
|
+
const needle = query.trim();
|
|
427
|
+
if (!needle) return text;
|
|
428
|
+
const index = text.toLowerCase().indexOf(needle.toLowerCase());
|
|
429
|
+
if (index === -1) return text;
|
|
430
|
+
return h(React.Fragment, null,
|
|
431
|
+
text.slice(0, index),
|
|
432
|
+
h(Text, { inverse: true }, text.slice(index, index + needle.length)),
|
|
433
|
+
text.slice(index + needle.length),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function windowRows(rows, selected, maxRows) {
|
|
438
|
+
if (rows.length <= maxRows) return { start: 0, end: rows.length, rows: rows.map((meeting, index) => ({ meeting, index })) };
|
|
439
|
+
const half = Math.floor(maxRows / 2);
|
|
440
|
+
const start = Math.max(0, Math.min(selected - half, rows.length - maxRows));
|
|
441
|
+
const end = Math.min(rows.length, start + maxRows);
|
|
442
|
+
return {
|
|
443
|
+
start,
|
|
444
|
+
end,
|
|
445
|
+
rows: rows.slice(start, end).map((meeting, offset) => ({ meeting, index: start + offset })),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function listColumns(width) {
|
|
450
|
+
const usable = Math.max(60, width - 2);
|
|
451
|
+
const date = 9;
|
|
452
|
+
const duration = 7;
|
|
453
|
+
const title = Math.max(24, Math.min(44, Math.floor(usable * 0.52)));
|
|
454
|
+
const tags = Math.max(0, usable - 2 - title - date - duration);
|
|
455
|
+
return { title, date, duration, tags };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function isDownKey(input, key) {
|
|
459
|
+
return input === "j" || key.downArrow || key.down || key.name === "down";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function isUpKey(input, key) {
|
|
463
|
+
return input === "k" || key.upArrow || key.up || key.name === "up";
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isPageDownKey(key) {
|
|
467
|
+
return key.pageDown || key.name === "pagedown";
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isPageUpKey(key) {
|
|
471
|
+
return key.pageUp || key.name === "pageup";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function copyLabel(kind) {
|
|
475
|
+
if (kind === "notes") return "notes";
|
|
476
|
+
if (kind === "transcript") return "transcript";
|
|
477
|
+
if (kind === "view") return "meeting overview";
|
|
478
|
+
if (kind === "actions") return "action-item note";
|
|
479
|
+
if (kind === "followup") return "follow-up note";
|
|
480
|
+
return "panel";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function copyToClipboard(text) {
|
|
484
|
+
const command = clipboardCommand();
|
|
485
|
+
if (!command) {
|
|
486
|
+
const error = new Error("No clipboard command found. Install pbcopy, wl-copy, xclip, or xsel.");
|
|
487
|
+
error.code = "clipboard_unavailable";
|
|
488
|
+
return Promise.reject(error);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return new Promise((resolve, reject) => {
|
|
492
|
+
const child = spawn(command.command, command.args, { stdio: ["pipe", "ignore", "pipe"] });
|
|
493
|
+
let stderr = "";
|
|
494
|
+
child.stderr.on("data", (chunk) => {
|
|
495
|
+
stderr += chunk.toString("utf8");
|
|
496
|
+
});
|
|
497
|
+
child.on("error", (error) => {
|
|
498
|
+
error.code = "clipboard_unavailable";
|
|
499
|
+
reject(error);
|
|
500
|
+
});
|
|
501
|
+
child.on("close", (code) => {
|
|
502
|
+
if (code === 0) {
|
|
503
|
+
resolve();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const error = new Error(stderr.trim() || `Clipboard command exited with ${code}`);
|
|
507
|
+
error.code = "copy_failed";
|
|
508
|
+
reject(error);
|
|
509
|
+
});
|
|
510
|
+
child.stdin.end(text);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function clipboardCommand() {
|
|
515
|
+
if (process.platform === "darwin") return { command: "pbcopy", args: [] };
|
|
516
|
+
if (process.platform === "win32") {
|
|
517
|
+
return { command: "powershell.exe", args: ["-NoProfile", "-Command", "Set-Clipboard"] };
|
|
518
|
+
}
|
|
519
|
+
if (process.env.WAYLAND_DISPLAY) return { command: "wl-copy", args: [] };
|
|
520
|
+
if (process.env.DISPLAY) return { command: "xclip", args: ["-selection", "clipboard"] };
|
|
521
|
+
return { command: "xsel", args: ["--clipboard", "--input"] };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function actionLabel(action) {
|
|
525
|
+
if (action === "notes") return "Notes";
|
|
526
|
+
if (action === "transcript") return "Transcript";
|
|
527
|
+
if (action === "actions") return "Action items";
|
|
528
|
+
if (action === "followup") return "Follow-up";
|
|
529
|
+
return "Meeting";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function formatBrowsePanel(action, meeting, result) {
|
|
533
|
+
if (action === "view") return formatMeetingOverviewPanel(result, meeting);
|
|
534
|
+
if (action === "notes") return formatTextPanel(result, "No notes found for this meeting.");
|
|
535
|
+
if (action === "transcript") return formatTextPanel(result, "No transcript found for this meeting.");
|
|
536
|
+
if (action === "actions") return formatTextPanel(result, "Action-item note generation completed.");
|
|
537
|
+
if (action === "followup") return formatTextPanel(result, "Follow-up note generation completed.");
|
|
538
|
+
return formatTextPanel(result, "");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function loadMeetingOverview(client, tools, meeting) {
|
|
542
|
+
const meetingResult = await callPanelTool(client, tools, "view", meeting);
|
|
543
|
+
let minutesResult = null;
|
|
544
|
+
try {
|
|
545
|
+
minutesResult = await callPanelTool(client, tools, "notes", meeting);
|
|
546
|
+
} catch {
|
|
547
|
+
minutesResult = null;
|
|
548
|
+
}
|
|
549
|
+
return { meeting: meetingResult, minutes: minutesResult };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function callPanelTool(client, tools, action, meeting) {
|
|
553
|
+
const { tool, args } = buildPanelCall(tools, action, meeting);
|
|
554
|
+
return extractToolResult(await client.callTool(tool.name, args));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function buildPanelCall(tools, action, meeting) {
|
|
558
|
+
const route = action === "notes"
|
|
559
|
+
? "getNotes"
|
|
560
|
+
: action === "transcript"
|
|
561
|
+
? "getTranscript"
|
|
562
|
+
: action === "actions" || action === "followup"
|
|
563
|
+
? "createNote"
|
|
564
|
+
: "getMeeting";
|
|
565
|
+
const tool = findTool(tools, route);
|
|
566
|
+
if (!tool) throw browserError("tool_route_unavailable", `Could not find a Quill MCP tool for ${route}`);
|
|
567
|
+
const values = action === "actions"
|
|
568
|
+
? { meetingId: meeting.id, prompt: ACTIONS_PROMPT }
|
|
569
|
+
: action === "followup"
|
|
570
|
+
? { meetingId: meeting.id, prompt: FOLLOWUP_PROMPT }
|
|
571
|
+
: { id: meeting.id };
|
|
572
|
+
return { tool, args: buildArgs(tool, values) };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function formatMeetingOverviewPanel(result, fallbackMeeting) {
|
|
576
|
+
const meeting = result?.meeting?.meetings?.[0] || fallbackMeeting || {};
|
|
577
|
+
const metadata = formatMeetingPanel(result?.meeting, fallbackMeeting);
|
|
578
|
+
const summary = formatTextPanel(result?.minutes, "");
|
|
579
|
+
const existingSummary = cleanText(meeting.summary || meeting.blurb || "");
|
|
580
|
+
const sections = [
|
|
581
|
+
metadata,
|
|
582
|
+
summary
|
|
583
|
+
? `\nSummary\n-------\n${summary}`
|
|
584
|
+
: existingSummary
|
|
585
|
+
? `\nSummary\n-------\n${existingSummary}`
|
|
586
|
+
: "\nSummary\n-------\nNo minutes found. Press n for notes, t for transcript, or f to generate a follow-up note.",
|
|
587
|
+
"\nActions\n-------\nn notes/minutes t transcript a action items f follow-up b back",
|
|
588
|
+
];
|
|
589
|
+
return sections.filter(Boolean).join("\n");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function formatMeetingPanel(result, fallbackMeeting) {
|
|
593
|
+
const meeting = result?.meetings?.[0] || fallbackMeeting || {};
|
|
594
|
+
const rows = [
|
|
595
|
+
["Title", meeting.title || "(untitled)"],
|
|
596
|
+
["Date", formatLongDate(meeting.date)],
|
|
597
|
+
["Duration", meeting.duration],
|
|
598
|
+
["Participants", splitList(meeting.participants).join(", ")],
|
|
599
|
+
["Tags", splitList(meeting.tags).join(", ")],
|
|
600
|
+
["ID", meeting.id],
|
|
601
|
+
["URL", meeting.url],
|
|
602
|
+
].filter(([, value]) => value);
|
|
603
|
+
|
|
604
|
+
return rows.map(([label, value]) => `${label.padEnd(12)} ${value}`).join("\n");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function formatTextPanel(result, emptyMessage) {
|
|
608
|
+
if (!result) return emptyMessage;
|
|
609
|
+
if (typeof result === "string") return cleanText(result) || emptyMessage;
|
|
610
|
+
if (typeof result.message === "string") return cleanText(result.message) || emptyMessage;
|
|
611
|
+
if (typeof result.text === "string") return cleanText(result.text) || emptyMessage;
|
|
612
|
+
|
|
613
|
+
for (const key of ["notes", "transcripts", "items"]) {
|
|
614
|
+
if (Array.isArray(result[key])) {
|
|
615
|
+
const rendered = result[key].map((item) => renderRecord(item)).filter(Boolean).join("\n\n");
|
|
616
|
+
return rendered || emptyMessage;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return renderRecord(result) || emptyMessage;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function renderRecord(record) {
|
|
624
|
+
if (!record || typeof record !== "object") return cleanText(String(record || ""));
|
|
625
|
+
const body = record.body || record.text || record.content || record.markdown || record.message;
|
|
626
|
+
const title = record.title || record.name;
|
|
627
|
+
if (body) return [title, cleanText(body)].filter(Boolean).join("\n\n");
|
|
628
|
+
return Object.entries(record)
|
|
629
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== "")
|
|
630
|
+
.map(([key, value]) => `${humanLabel(key).padEnd(12)} ${Array.isArray(value) ? value.join(", ") : value}`)
|
|
631
|
+
.join("\n");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function cleanText(value) {
|
|
635
|
+
return String(value)
|
|
636
|
+
.replace(/^<ToolResponse>\s*/s, "")
|
|
637
|
+
.replace(/\s*<\/ToolResponse>$/s, "")
|
|
638
|
+
.replace(/<system-instruction>[\s\S]*?<\/system-instruction>/g, "")
|
|
639
|
+
.replace(/<[^>]+>/g, "")
|
|
640
|
+
.replace(/\\n/g, "\n")
|
|
641
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
642
|
+
.replaceAll(""", "\"")
|
|
643
|
+
.replaceAll("'", "'")
|
|
644
|
+
.replaceAll("<", "<")
|
|
645
|
+
.replaceAll(">", ">")
|
|
646
|
+
.replaceAll("&", "&")
|
|
647
|
+
.trim();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function truncatePanel(value, limit = 5000) {
|
|
651
|
+
if (value.length <= limit) return value;
|
|
652
|
+
return `${value.slice(0, limit)}\n... (truncated in browse view; use command with --full for complete output)`;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function truncateInline(value, max) {
|
|
656
|
+
const text = String(value).replace(/\s+/g, " ");
|
|
657
|
+
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function formatShortDate(value) {
|
|
661
|
+
const date = new Date(value);
|
|
662
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
663
|
+
return date.toLocaleDateString(undefined, {
|
|
664
|
+
month: "short",
|
|
665
|
+
day: "numeric",
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function formatLongDate(value) {
|
|
670
|
+
const date = new Date(value);
|
|
671
|
+
if (Number.isNaN(date.getTime())) return value || "";
|
|
672
|
+
return date.toLocaleString(undefined, {
|
|
673
|
+
year: "numeric",
|
|
674
|
+
month: "short",
|
|
675
|
+
day: "numeric",
|
|
676
|
+
hour: "numeric",
|
|
677
|
+
minute: "2-digit",
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function splitList(value) {
|
|
682
|
+
if (!value) return [];
|
|
683
|
+
if (Array.isArray(value)) return value;
|
|
684
|
+
return String(value).split(",").map((item) => item.trim()).filter(Boolean);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function humanLabel(value) {
|
|
688
|
+
return String(value).replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function browserError(code, message) {
|
|
692
|
+
const error = new Error(message);
|
|
693
|
+
error.code = code;
|
|
694
|
+
return error;
|
|
695
|
+
}
|