granola-toolkit 0.25.0 → 0.26.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/README.md +25 -0
- package/dist/cli.js +645 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ granola exports --help
|
|
|
37
37
|
granola meeting --help
|
|
38
38
|
granola notes --help
|
|
39
39
|
granola serve --help
|
|
40
|
+
granola tui --help
|
|
40
41
|
granola transcripts --help
|
|
41
42
|
granola web --help
|
|
42
43
|
```
|
|
@@ -52,6 +53,7 @@ node dist/cli.js exports --help
|
|
|
52
53
|
node dist/cli.js meeting --help
|
|
53
54
|
node dist/cli.js notes --help
|
|
54
55
|
node dist/cli.js serve --help
|
|
56
|
+
node dist/cli.js tui --help
|
|
55
57
|
node dist/cli.js transcripts --help
|
|
56
58
|
node dist/cli.js web --help
|
|
57
59
|
```
|
|
@@ -62,6 +64,7 @@ You can also use the package scripts:
|
|
|
62
64
|
npm run build
|
|
63
65
|
npm run start -- meeting --help
|
|
64
66
|
npm run notes -- --help
|
|
67
|
+
npm run tui -- --help
|
|
65
68
|
npm run transcripts -- --help
|
|
66
69
|
```
|
|
67
70
|
|
|
@@ -95,6 +98,8 @@ granola meeting view 1234abcd
|
|
|
95
98
|
granola meeting notes 1234abcd
|
|
96
99
|
granola meeting transcript 1234abcd --format json
|
|
97
100
|
granola meeting export 1234abcd --format yaml
|
|
101
|
+
granola tui
|
|
102
|
+
granola tui --meeting 1234abcd
|
|
98
103
|
```
|
|
99
104
|
|
|
100
105
|
Run the local API server:
|
|
@@ -242,6 +247,26 @@ The initial browser client includes:
|
|
|
242
247
|
- stronger empty and error states for list/detail failures
|
|
243
248
|
- a server-access panel that can unlock or lock a password-protected local server
|
|
244
249
|
|
|
250
|
+
### TUI
|
|
251
|
+
|
|
252
|
+
`tui` starts a full-screen terminal workspace on the shared app core, without requiring the local server or browser client.
|
|
253
|
+
|
|
254
|
+
The initial terminal workspace includes:
|
|
255
|
+
|
|
256
|
+
- a meeting list pane with keyboard navigation
|
|
257
|
+
- a detail pane with notes, transcript, metadata, and raw views
|
|
258
|
+
- a footer with app state and key hints
|
|
259
|
+
- a quick-open overlay for jumping by title, id, or tag
|
|
260
|
+
|
|
261
|
+
The main keyboard controls are:
|
|
262
|
+
|
|
263
|
+
- `j` / `k` or arrow keys to move between meetings
|
|
264
|
+
- `/` or `Ctrl+P` to open quick open
|
|
265
|
+
- `1`-`4` to switch detail tabs
|
|
266
|
+
- `PageUp` / `PageDown` to scroll the detail pane
|
|
267
|
+
- `r` to refresh from live Granola data
|
|
268
|
+
- `q` to quit
|
|
269
|
+
|
|
245
270
|
### Local Meeting Index
|
|
246
271
|
|
|
247
272
|
Interactive meeting browsing now keeps a local index of meeting summaries and metadata.
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { execFile } from "node:child_process";
|
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
|
+
import { Input, ProcessTerminal, TUI, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
11
12
|
//#region src/utils.ts
|
|
12
13
|
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
|
13
14
|
const CONTROL_CHARACTERS = /\p{Cc}/gu;
|
|
@@ -4789,6 +4790,649 @@ const serveCommand = {
|
|
|
4789
4790
|
}
|
|
4790
4791
|
};
|
|
4791
4792
|
//#endregion
|
|
4793
|
+
//#region src/tui/helpers.ts
|
|
4794
|
+
function splitQuery(query) {
|
|
4795
|
+
return query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
4796
|
+
}
|
|
4797
|
+
function scoreMeetingTerm(meeting, term) {
|
|
4798
|
+
const title = meeting.title.toLowerCase();
|
|
4799
|
+
const id = meeting.id.toLowerCase();
|
|
4800
|
+
const tags = meeting.tags.map((tag) => tag.toLowerCase());
|
|
4801
|
+
if (title === term || id === term) return 0;
|
|
4802
|
+
if (title.startsWith(term)) return 1;
|
|
4803
|
+
if (id.startsWith(term)) return 2;
|
|
4804
|
+
if (title.includes(term)) return 3;
|
|
4805
|
+
if (id.includes(term)) return 4;
|
|
4806
|
+
if (tags.some((tag) => tag.includes(term))) return 5;
|
|
4807
|
+
}
|
|
4808
|
+
function buildGranolaTuiQuickOpenItems(meetings, query) {
|
|
4809
|
+
const terms = splitQuery(query);
|
|
4810
|
+
return meetings.map((meeting) => {
|
|
4811
|
+
const score = terms.reduce((current, term) => {
|
|
4812
|
+
const termScore = scoreMeetingTerm(meeting, term);
|
|
4813
|
+
if (termScore === void 0) return;
|
|
4814
|
+
return (current ?? 0) + termScore;
|
|
4815
|
+
}, 0);
|
|
4816
|
+
if (terms.length > 0 && score === void 0) return;
|
|
4817
|
+
const tags = meeting.tags.length > 0 ? meeting.tags.map((tag) => `#${tag}`).join(" ") : "untagged";
|
|
4818
|
+
return {
|
|
4819
|
+
description: `${meeting.updatedAt.slice(0, 10)} | ${tags} | ${meeting.id}`,
|
|
4820
|
+
id: meeting.id,
|
|
4821
|
+
label: meeting.title || meeting.id,
|
|
4822
|
+
score: score ?? 99
|
|
4823
|
+
};
|
|
4824
|
+
}).filter((item) => item !== void 0).sort((left, right) => {
|
|
4825
|
+
if (left.score !== right.score) return left.score - right.score;
|
|
4826
|
+
if (left.description !== right.description) return right.description.localeCompare(left.description);
|
|
4827
|
+
return left.label.localeCompare(right.label);
|
|
4828
|
+
});
|
|
4829
|
+
}
|
|
4830
|
+
function renderGranolaTuiMeetingTab(bundle, tab) {
|
|
4831
|
+
const summary = bundle.meeting.meeting;
|
|
4832
|
+
switch (tab) {
|
|
4833
|
+
case "metadata": return [
|
|
4834
|
+
`Title: ${summary.title || summary.id}`,
|
|
4835
|
+
`ID: ${summary.id}`,
|
|
4836
|
+
`Created: ${summary.createdAt}`,
|
|
4837
|
+
`Updated: ${summary.updatedAt}`,
|
|
4838
|
+
`Tags: ${summary.tags.length > 0 ? summary.tags.join(", ") : "none"}`,
|
|
4839
|
+
`Notes source: ${summary.noteContentSource}`,
|
|
4840
|
+
`Transcript loaded: ${summary.transcriptLoaded ? "yes" : "no"}`,
|
|
4841
|
+
`Transcript segments: ${summary.transcriptSegmentCount}`
|
|
4842
|
+
].join("\n");
|
|
4843
|
+
case "raw": return JSON.stringify(bundle, null, 2);
|
|
4844
|
+
case "transcript": {
|
|
4845
|
+
const transcript = renderMeetingTranscript(bundle.document, bundle.cacheData, "text").trim();
|
|
4846
|
+
if (transcript) return transcript;
|
|
4847
|
+
return bundle.cacheData ? "(Transcript unavailable)" : "(Granola cache not loaded)";
|
|
4848
|
+
}
|
|
4849
|
+
default: return renderMeetingNotes(bundle.document, "markdown").trim();
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
function buildGranolaTuiSummary(state, meetingSource) {
|
|
4853
|
+
return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.cache.loaded ? `${state.cache.transcriptCount} transcript sets` : state.cache.configured ? "cache configured" : "cache missing"} | ${state.index.loaded ? `${state.index.meetingCount} indexed` : "index pending"} | list ${meetingSource}`;
|
|
4854
|
+
}
|
|
4855
|
+
//#endregion
|
|
4856
|
+
//#region src/tui/theme.ts
|
|
4857
|
+
const RESET = "\x1B[0m";
|
|
4858
|
+
function colour(code, text) {
|
|
4859
|
+
return `\x1b[${code}m${text}${RESET}`;
|
|
4860
|
+
}
|
|
4861
|
+
const granolaTuiTheme = {
|
|
4862
|
+
accent(text) {
|
|
4863
|
+
return colour("36", text);
|
|
4864
|
+
},
|
|
4865
|
+
dim(text) {
|
|
4866
|
+
return colour("2", text);
|
|
4867
|
+
},
|
|
4868
|
+
error(text) {
|
|
4869
|
+
return colour("31", text);
|
|
4870
|
+
},
|
|
4871
|
+
info(text) {
|
|
4872
|
+
return colour("32", text);
|
|
4873
|
+
},
|
|
4874
|
+
selected(text) {
|
|
4875
|
+
return colour("7", text);
|
|
4876
|
+
},
|
|
4877
|
+
strong(text) {
|
|
4878
|
+
return colour("1", text);
|
|
4879
|
+
},
|
|
4880
|
+
warning(text) {
|
|
4881
|
+
return colour("33", text);
|
|
4882
|
+
}
|
|
4883
|
+
};
|
|
4884
|
+
//#endregion
|
|
4885
|
+
//#region src/tui/palette.ts
|
|
4886
|
+
function padLine$1(text, width) {
|
|
4887
|
+
const clipped = truncateToWidth(text, width, "");
|
|
4888
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
4889
|
+
}
|
|
4890
|
+
function frameLine(text, width) {
|
|
4891
|
+
return `| ${padLine$1(text, Math.max(1, width - 4))} |`;
|
|
4892
|
+
}
|
|
4893
|
+
var GranolaTuiQuickOpenPalette = class {
|
|
4894
|
+
focused = false;
|
|
4895
|
+
#input = new Input();
|
|
4896
|
+
#matches;
|
|
4897
|
+
#selectedIndex = 0;
|
|
4898
|
+
constructor(options) {
|
|
4899
|
+
this.options = options;
|
|
4900
|
+
this.#matches = buildGranolaTuiQuickOpenItems(this.options.meetings, "");
|
|
4901
|
+
this.#input.onEscape = () => {
|
|
4902
|
+
this.options.onCancel();
|
|
4903
|
+
};
|
|
4904
|
+
this.#input.onSubmit = () => {
|
|
4905
|
+
this.chooseSelection();
|
|
4906
|
+
};
|
|
4907
|
+
}
|
|
4908
|
+
get query() {
|
|
4909
|
+
return this.#input.getValue();
|
|
4910
|
+
}
|
|
4911
|
+
updateMatches() {
|
|
4912
|
+
this.#matches = buildGranolaTuiQuickOpenItems(this.options.meetings, this.query);
|
|
4913
|
+
this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#matches.length - 1));
|
|
4914
|
+
}
|
|
4915
|
+
async chooseSelection() {
|
|
4916
|
+
const selected = this.#matches[this.#selectedIndex];
|
|
4917
|
+
if (selected) {
|
|
4918
|
+
await this.options.onPick(selected.id);
|
|
4919
|
+
return;
|
|
4920
|
+
}
|
|
4921
|
+
if (this.query.trim()) {
|
|
4922
|
+
await this.options.onResolveQuery(this.query.trim());
|
|
4923
|
+
return;
|
|
4924
|
+
}
|
|
4925
|
+
this.options.onCancel();
|
|
4926
|
+
}
|
|
4927
|
+
invalidate() {}
|
|
4928
|
+
handleInput(data) {
|
|
4929
|
+
if (matchesKey(data, "up")) {
|
|
4930
|
+
this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
|
|
4931
|
+
return;
|
|
4932
|
+
}
|
|
4933
|
+
if (matchesKey(data, "down")) {
|
|
4934
|
+
this.#selectedIndex = Math.min(this.#matches.length - 1, this.#selectedIndex + 1);
|
|
4935
|
+
return;
|
|
4936
|
+
}
|
|
4937
|
+
if (matchesKey(data, "pageUp")) {
|
|
4938
|
+
this.#selectedIndex = Math.max(0, this.#selectedIndex - 5);
|
|
4939
|
+
return;
|
|
4940
|
+
}
|
|
4941
|
+
if (matchesKey(data, "pageDown")) {
|
|
4942
|
+
this.#selectedIndex = Math.min(this.#matches.length - 1, this.#selectedIndex + 5);
|
|
4943
|
+
return;
|
|
4944
|
+
}
|
|
4945
|
+
const before = this.query;
|
|
4946
|
+
this.#input.focused = this.focused;
|
|
4947
|
+
this.#input.handleInput(data);
|
|
4948
|
+
if (before !== this.query) {
|
|
4949
|
+
this.#selectedIndex = 0;
|
|
4950
|
+
this.updateMatches();
|
|
4951
|
+
}
|
|
4952
|
+
}
|
|
4953
|
+
render(width) {
|
|
4954
|
+
const lines = [];
|
|
4955
|
+
const bodyWidth = Math.max(32, width);
|
|
4956
|
+
const visibleMatches = this.#matches.slice(0, 8);
|
|
4957
|
+
lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
|
|
4958
|
+
lines.push(frameLine(granolaTuiTheme.strong("Quick Open") + granolaTuiTheme.dim(" title, id, or tag"), bodyWidth));
|
|
4959
|
+
lines.push(frameLine("", bodyWidth));
|
|
4960
|
+
for (const inputLine of this.#input.render(Math.max(1, bodyWidth - 4))) lines.push(frameLine(inputLine, bodyWidth));
|
|
4961
|
+
for (const hintLine of wrapTextWithAnsi(granolaTuiTheme.dim("Enter to open, Esc to cancel, arrows to move"), Math.max(1, bodyWidth - 4))) lines.push(frameLine(hintLine, bodyWidth));
|
|
4962
|
+
lines.push(frameLine("", bodyWidth));
|
|
4963
|
+
if (visibleMatches.length === 0) lines.push(frameLine(granolaTuiTheme.warning("No matching meetings"), bodyWidth));
|
|
4964
|
+
else for (const [index, item] of visibleMatches.entries()) {
|
|
4965
|
+
const selected = index === this.#selectedIndex;
|
|
4966
|
+
const title = `${selected ? "> " : " "}${item.label}`;
|
|
4967
|
+
const titleLine = selected ? granolaTuiTheme.selected(title) : title;
|
|
4968
|
+
const detailLine = granolaTuiTheme.dim(` ${item.description}`);
|
|
4969
|
+
lines.push(frameLine(titleLine, bodyWidth));
|
|
4970
|
+
lines.push(frameLine(detailLine, bodyWidth));
|
|
4971
|
+
}
|
|
4972
|
+
lines.push(`+${"-".repeat(bodyWidth - 2)}+`);
|
|
4973
|
+
return lines;
|
|
4974
|
+
}
|
|
4975
|
+
};
|
|
4976
|
+
//#endregion
|
|
4977
|
+
//#region src/tui/workspace.ts
|
|
4978
|
+
function padLine(text, width) {
|
|
4979
|
+
const clipped = truncateToWidth(text, width, "");
|
|
4980
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
4981
|
+
}
|
|
4982
|
+
function wrapBlock(text, width) {
|
|
4983
|
+
const lines = [];
|
|
4984
|
+
for (const line of text.split("\n")) {
|
|
4985
|
+
const wrapped = wrapTextWithAnsi(line, Math.max(1, width));
|
|
4986
|
+
if (wrapped.length === 0) {
|
|
4987
|
+
lines.push("");
|
|
4988
|
+
continue;
|
|
4989
|
+
}
|
|
4990
|
+
lines.push(...wrapped);
|
|
4991
|
+
}
|
|
4992
|
+
return lines;
|
|
4993
|
+
}
|
|
4994
|
+
function toneText(tone, text) {
|
|
4995
|
+
switch (tone) {
|
|
4996
|
+
case "error": return granolaTuiTheme.error(text);
|
|
4997
|
+
case "warning": return granolaTuiTheme.warning(text);
|
|
4998
|
+
default: return granolaTuiTheme.info(text);
|
|
4999
|
+
}
|
|
5000
|
+
}
|
|
5001
|
+
var GranolaTuiWorkspace = class {
|
|
5002
|
+
focused = false;
|
|
5003
|
+
#maxMeetings;
|
|
5004
|
+
#appState;
|
|
5005
|
+
#detailError = "";
|
|
5006
|
+
#detailScroll = 0;
|
|
5007
|
+
#detailToken = 0;
|
|
5008
|
+
#listError = "";
|
|
5009
|
+
#listToken = 0;
|
|
5010
|
+
#loadingDetail = false;
|
|
5011
|
+
#loadingMeetings = false;
|
|
5012
|
+
#meetingSource = "live";
|
|
5013
|
+
#meetings = [];
|
|
5014
|
+
#overlay;
|
|
5015
|
+
#selectedMeeting;
|
|
5016
|
+
#selectedMeetingId;
|
|
5017
|
+
#statusMessage = "Loading meetings…";
|
|
5018
|
+
#statusTone = "info";
|
|
5019
|
+
#tab = "notes";
|
|
5020
|
+
#unsubscribe;
|
|
5021
|
+
constructor(tui, app, options) {
|
|
5022
|
+
this.tui = tui;
|
|
5023
|
+
this.app = app;
|
|
5024
|
+
this.options = options;
|
|
5025
|
+
this.#appState = app.getState();
|
|
5026
|
+
this.#maxMeetings = options.maxMeetings ?? 200;
|
|
5027
|
+
}
|
|
5028
|
+
async initialise() {
|
|
5029
|
+
this.#unsubscribe = this.app.subscribe((event) => {
|
|
5030
|
+
this.handleAppUpdate(event);
|
|
5031
|
+
});
|
|
5032
|
+
await this.loadMeetings({
|
|
5033
|
+
preferredMeetingId: this.options.initialMeetingId,
|
|
5034
|
+
setStatus: true
|
|
5035
|
+
});
|
|
5036
|
+
if (this.options.initialMeetingId) await this.loadMeeting(this.options.initialMeetingId, { ensureMeetingVisible: true });
|
|
5037
|
+
else if (this.#selectedMeetingId) this.loadMeeting(this.#selectedMeetingId);
|
|
5038
|
+
}
|
|
5039
|
+
dispose() {
|
|
5040
|
+
this.#unsubscribe?.();
|
|
5041
|
+
this.#unsubscribe = void 0;
|
|
5042
|
+
}
|
|
5043
|
+
invalidate() {}
|
|
5044
|
+
handleAppUpdate(event) {
|
|
5045
|
+
const previousDocumentsLoadedAt = this.#appState.documents.loadedAt;
|
|
5046
|
+
this.#appState = event.state;
|
|
5047
|
+
if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
|
|
5048
|
+
this.tui.requestRender();
|
|
5049
|
+
}
|
|
5050
|
+
setStatus(message, tone = "info") {
|
|
5051
|
+
this.#statusMessage = message;
|
|
5052
|
+
this.#statusTone = tone;
|
|
5053
|
+
this.tui.requestRender();
|
|
5054
|
+
}
|
|
5055
|
+
normaliseSelectedIndex() {
|
|
5056
|
+
if (this.#meetings.length === 0) return -1;
|
|
5057
|
+
const selectedIndex = this.#selectedMeetingId ? this.#meetings.findIndex((meeting) => meeting.id === this.#selectedMeetingId) : -1;
|
|
5058
|
+
return selectedIndex >= 0 ? selectedIndex : 0;
|
|
5059
|
+
}
|
|
5060
|
+
ensureMeetingVisible(meeting) {
|
|
5061
|
+
const existingIndex = this.#meetings.findIndex((item) => item.id === meeting.id);
|
|
5062
|
+
if (existingIndex >= 0) this.#meetings[existingIndex] = meeting;
|
|
5063
|
+
else this.#meetings.push(meeting);
|
|
5064
|
+
this.#meetings.sort((left, right) => {
|
|
5065
|
+
if (left.updatedAt !== right.updatedAt) return right.updatedAt.localeCompare(left.updatedAt);
|
|
5066
|
+
return left.title.localeCompare(right.title);
|
|
5067
|
+
});
|
|
5068
|
+
}
|
|
5069
|
+
async loadMeetings(options = {}) {
|
|
5070
|
+
const token = ++this.#listToken;
|
|
5071
|
+
this.#loadingMeetings = true;
|
|
5072
|
+
this.#listError = "";
|
|
5073
|
+
if (options.setStatus !== false) this.setStatus(options.forceRefresh ? "Refreshing meetings…" : "Loading meetings…");
|
|
5074
|
+
try {
|
|
5075
|
+
const result = await this.app.listMeetings({
|
|
5076
|
+
forceRefresh: options.forceRefresh,
|
|
5077
|
+
limit: this.#maxMeetings,
|
|
5078
|
+
preferIndex: true
|
|
5079
|
+
});
|
|
5080
|
+
if (token !== this.#listToken) return;
|
|
5081
|
+
this.#meetings = result.meetings;
|
|
5082
|
+
this.#meetingSource = result.source;
|
|
5083
|
+
this.#selectedMeetingId = options.preferredMeetingId && this.#meetings.some((meeting) => meeting.id === options.preferredMeetingId) ? options.preferredMeetingId : this.#selectedMeetingId && this.#meetings.some((meeting) => meeting.id === this.#selectedMeetingId) ? this.#selectedMeetingId : this.#meetings[0]?.id;
|
|
5084
|
+
this.#listError = "";
|
|
5085
|
+
this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : "Connected to Granola");
|
|
5086
|
+
} catch (error) {
|
|
5087
|
+
if (token !== this.#listToken) return;
|
|
5088
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5089
|
+
this.#listError = message;
|
|
5090
|
+
this.setStatus(message, "error");
|
|
5091
|
+
throw error;
|
|
5092
|
+
} finally {
|
|
5093
|
+
if (token === this.#listToken) {
|
|
5094
|
+
this.#loadingMeetings = false;
|
|
5095
|
+
this.tui.requestRender();
|
|
5096
|
+
}
|
|
5097
|
+
}
|
|
5098
|
+
}
|
|
5099
|
+
async loadMeeting(meetingId, options = {}) {
|
|
5100
|
+
const token = ++this.#detailToken;
|
|
5101
|
+
this.#loadingDetail = true;
|
|
5102
|
+
this.#detailError = "";
|
|
5103
|
+
this.#selectedMeetingId = meetingId;
|
|
5104
|
+
this.#detailScroll = 0;
|
|
5105
|
+
this.setStatus(`Opening ${meetingId}…`);
|
|
5106
|
+
try {
|
|
5107
|
+
const bundle = options.resolveQuery ? await this.app.findMeeting(meetingId) : await this.app.getMeeting(meetingId);
|
|
5108
|
+
if (token !== this.#detailToken) return;
|
|
5109
|
+
this.#selectedMeeting = bundle;
|
|
5110
|
+
this.#selectedMeetingId = bundle.document.id;
|
|
5111
|
+
if (options.ensureMeetingVisible) this.ensureMeetingVisible(bundle.meeting.meeting);
|
|
5112
|
+
this.setStatus(`Opened ${bundle.meeting.meeting.title || bundle.meeting.meeting.id}`);
|
|
5113
|
+
} catch (error) {
|
|
5114
|
+
if (token !== this.#detailToken) return;
|
|
5115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5116
|
+
this.#selectedMeeting = void 0;
|
|
5117
|
+
this.#detailError = message;
|
|
5118
|
+
this.setStatus(message, "error");
|
|
5119
|
+
} finally {
|
|
5120
|
+
if (token === this.#detailToken) {
|
|
5121
|
+
this.#loadingDetail = false;
|
|
5122
|
+
this.tui.requestRender();
|
|
5123
|
+
}
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5126
|
+
async refresh(forceRefresh) {
|
|
5127
|
+
try {
|
|
5128
|
+
await this.loadMeetings({
|
|
5129
|
+
forceRefresh,
|
|
5130
|
+
preferredMeetingId: this.#selectedMeetingId
|
|
5131
|
+
});
|
|
5132
|
+
if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
|
|
5133
|
+
} catch {}
|
|
5134
|
+
}
|
|
5135
|
+
async moveSelection(delta) {
|
|
5136
|
+
if (this.#meetings.length === 0) return;
|
|
5137
|
+
const currentIndex = this.normaliseSelectedIndex();
|
|
5138
|
+
const nextIndex = Math.max(0, Math.min(this.#meetings.length - 1, currentIndex + delta));
|
|
5139
|
+
const nextMeeting = this.#meetings[nextIndex];
|
|
5140
|
+
if (!nextMeeting || nextMeeting.id === this.#selectedMeetingId) return;
|
|
5141
|
+
await this.loadMeeting(nextMeeting.id);
|
|
5142
|
+
}
|
|
5143
|
+
currentDetailBody(width) {
|
|
5144
|
+
if (this.#detailError) return wrapBlock(this.#detailError, width);
|
|
5145
|
+
if (this.#loadingDetail && !this.#selectedMeeting) return wrapBlock("Loading meeting details…", width);
|
|
5146
|
+
if (!this.#selectedMeeting) return wrapBlock("Select a meeting to inspect its notes, transcript, and metadata.", width);
|
|
5147
|
+
return wrapBlock(renderGranolaTuiMeetingTab(this.#selectedMeeting, this.#tab), width);
|
|
5148
|
+
}
|
|
5149
|
+
detailScrollStep(width, height) {
|
|
5150
|
+
const bodyHeight = Math.max(1, height - 2);
|
|
5151
|
+
const totalLines = this.currentDetailBody(width).length;
|
|
5152
|
+
if (totalLines <= bodyHeight) return 0;
|
|
5153
|
+
return Math.max(1, Math.min(bodyHeight - 1, totalLines - bodyHeight));
|
|
5154
|
+
}
|
|
5155
|
+
scrollDetail(delta) {
|
|
5156
|
+
const totalWidth = this.tui.terminal.columns;
|
|
5157
|
+
const totalHeight = this.tui.terminal.rows;
|
|
5158
|
+
const { detailWidth } = this.resolveLayout(totalWidth);
|
|
5159
|
+
const bodyHeight = Math.max(1, totalHeight - 6);
|
|
5160
|
+
const detailLines = this.currentDetailBody(Math.max(1, detailWidth - 2));
|
|
5161
|
+
const visibleBodyLines = Math.max(1, bodyHeight - 2);
|
|
5162
|
+
const maxScroll = Math.max(0, detailLines.length - visibleBodyLines);
|
|
5163
|
+
this.#detailScroll = Math.max(0, Math.min(maxScroll, this.#detailScroll + delta));
|
|
5164
|
+
this.tui.requestRender();
|
|
5165
|
+
}
|
|
5166
|
+
cycleTab(delta) {
|
|
5167
|
+
const tabs = [
|
|
5168
|
+
"notes",
|
|
5169
|
+
"transcript",
|
|
5170
|
+
"metadata",
|
|
5171
|
+
"raw"
|
|
5172
|
+
];
|
|
5173
|
+
this.#tab = tabs[(tabs.indexOf(this.#tab) + delta + tabs.length) % tabs.length] ?? "notes";
|
|
5174
|
+
this.#detailScroll = 0;
|
|
5175
|
+
this.tui.requestRender();
|
|
5176
|
+
}
|
|
5177
|
+
openQuickOpen() {
|
|
5178
|
+
if (this.#overlay) return;
|
|
5179
|
+
const closeOverlay = () => {
|
|
5180
|
+
this.#overlay?.hide();
|
|
5181
|
+
this.#overlay = void 0;
|
|
5182
|
+
this.tui.setFocus(this);
|
|
5183
|
+
this.tui.requestRender();
|
|
5184
|
+
};
|
|
5185
|
+
const palette = new GranolaTuiQuickOpenPalette({
|
|
5186
|
+
meetings: this.#meetings,
|
|
5187
|
+
onCancel: closeOverlay,
|
|
5188
|
+
onPick: async (meetingId) => {
|
|
5189
|
+
closeOverlay();
|
|
5190
|
+
await this.loadMeeting(meetingId, { ensureMeetingVisible: true });
|
|
5191
|
+
},
|
|
5192
|
+
onResolveQuery: async (query) => {
|
|
5193
|
+
closeOverlay();
|
|
5194
|
+
await this.loadMeeting(query, {
|
|
5195
|
+
ensureMeetingVisible: true,
|
|
5196
|
+
resolveQuery: true
|
|
5197
|
+
});
|
|
5198
|
+
}
|
|
5199
|
+
});
|
|
5200
|
+
this.#overlay = this.tui.showOverlay(palette, {
|
|
5201
|
+
anchor: "center",
|
|
5202
|
+
maxHeight: "60%",
|
|
5203
|
+
minWidth: 48,
|
|
5204
|
+
width: "70%"
|
|
5205
|
+
});
|
|
5206
|
+
this.setStatus("Quick open");
|
|
5207
|
+
}
|
|
5208
|
+
handleInput(data) {
|
|
5209
|
+
if (matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
|
|
5210
|
+
this.options.onExit();
|
|
5211
|
+
return;
|
|
5212
|
+
}
|
|
5213
|
+
if (matchesKey(data, "r")) {
|
|
5214
|
+
this.refresh(true);
|
|
5215
|
+
return;
|
|
5216
|
+
}
|
|
5217
|
+
if (matchesKey(data, "/") || matchesKey(data, "ctrl+p")) {
|
|
5218
|
+
this.openQuickOpen();
|
|
5219
|
+
return;
|
|
5220
|
+
}
|
|
5221
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
5222
|
+
this.moveSelection(-1);
|
|
5223
|
+
return;
|
|
5224
|
+
}
|
|
5225
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
5226
|
+
this.moveSelection(1);
|
|
5227
|
+
return;
|
|
5228
|
+
}
|
|
5229
|
+
if (matchesKey(data, "pageUp")) {
|
|
5230
|
+
this.scrollDetail(-Math.max(1, this.detailScrollStep(this.tui.terminal.columns, this.tui.terminal.rows)));
|
|
5231
|
+
return;
|
|
5232
|
+
}
|
|
5233
|
+
if (matchesKey(data, "pageDown")) {
|
|
5234
|
+
this.scrollDetail(this.detailScrollStep(this.tui.terminal.columns, this.tui.terminal.rows));
|
|
5235
|
+
return;
|
|
5236
|
+
}
|
|
5237
|
+
if (matchesKey(data, "1")) {
|
|
5238
|
+
this.#tab = "notes";
|
|
5239
|
+
this.#detailScroll = 0;
|
|
5240
|
+
this.tui.requestRender();
|
|
5241
|
+
return;
|
|
5242
|
+
}
|
|
5243
|
+
if (matchesKey(data, "2")) {
|
|
5244
|
+
this.#tab = "transcript";
|
|
5245
|
+
this.#detailScroll = 0;
|
|
5246
|
+
this.tui.requestRender();
|
|
5247
|
+
return;
|
|
5248
|
+
}
|
|
5249
|
+
if (matchesKey(data, "3")) {
|
|
5250
|
+
this.#tab = "metadata";
|
|
5251
|
+
this.#detailScroll = 0;
|
|
5252
|
+
this.tui.requestRender();
|
|
5253
|
+
return;
|
|
5254
|
+
}
|
|
5255
|
+
if (matchesKey(data, "4")) {
|
|
5256
|
+
this.#tab = "raw";
|
|
5257
|
+
this.#detailScroll = 0;
|
|
5258
|
+
this.tui.requestRender();
|
|
5259
|
+
return;
|
|
5260
|
+
}
|
|
5261
|
+
if (matchesKey(data, "]")) {
|
|
5262
|
+
this.cycleTab(1);
|
|
5263
|
+
return;
|
|
5264
|
+
}
|
|
5265
|
+
if (matchesKey(data, "[")) this.cycleTab(-1);
|
|
5266
|
+
}
|
|
5267
|
+
resolveLayout(width) {
|
|
5268
|
+
const minimumDetailWidth = 24;
|
|
5269
|
+
const minimumListWidth = 24;
|
|
5270
|
+
const available = Math.max(1, width - 3);
|
|
5271
|
+
let listWidth = Math.max(minimumListWidth, Math.min(42, Math.floor(available * .34)));
|
|
5272
|
+
let detailWidth = available - listWidth;
|
|
5273
|
+
if (detailWidth < minimumDetailWidth) {
|
|
5274
|
+
detailWidth = minimumDetailWidth;
|
|
5275
|
+
listWidth = Math.max(minimumListWidth, available - detailWidth);
|
|
5276
|
+
}
|
|
5277
|
+
if (listWidth + detailWidth > available) detailWidth = Math.max(minimumDetailWidth, available - listWidth);
|
|
5278
|
+
return {
|
|
5279
|
+
detailWidth,
|
|
5280
|
+
listWidth
|
|
5281
|
+
};
|
|
5282
|
+
}
|
|
5283
|
+
renderListPane(width, height) {
|
|
5284
|
+
const lines = [];
|
|
5285
|
+
const innerWidth = Math.max(1, width - 2);
|
|
5286
|
+
const header = `${granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
|
|
5287
|
+
lines.push(padLine(header, innerWidth));
|
|
5288
|
+
if (this.#listError) {
|
|
5289
|
+
lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, height - 1));
|
|
5290
|
+
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
5291
|
+
return lines;
|
|
5292
|
+
}
|
|
5293
|
+
if (this.#meetings.length === 0) {
|
|
5294
|
+
lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, height - 1));
|
|
5295
|
+
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
5296
|
+
return lines;
|
|
5297
|
+
}
|
|
5298
|
+
const selectedIndex = this.normaliseSelectedIndex();
|
|
5299
|
+
const windowSize = Math.max(1, height - 1);
|
|
5300
|
+
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(windowSize / 2), this.#meetings.length - windowSize));
|
|
5301
|
+
const visibleMeetings = this.#meetings.slice(startIndex, startIndex + windowSize);
|
|
5302
|
+
for (const [offset, meeting] of visibleMeetings.entries()) {
|
|
5303
|
+
const selected = startIndex + offset === selectedIndex;
|
|
5304
|
+
const dateLabel = meeting.updatedAt.slice(0, 10);
|
|
5305
|
+
const prefix = selected ? "> " : " ";
|
|
5306
|
+
const maxTitleWidth = Math.max(6, innerWidth - visibleWidth(prefix) - dateLabel.length - 1);
|
|
5307
|
+
const titleBlock = `${prefix}${truncateToWidth(meeting.title || meeting.id, maxTitleWidth, "")}`;
|
|
5308
|
+
const line = `${titleBlock}${" ".repeat(Math.max(1, innerWidth - visibleWidth(titleBlock) - visibleWidth(dateLabel)))}${granolaTuiTheme.dim(dateLabel)}`;
|
|
5309
|
+
lines.push(selected ? padLine(granolaTuiTheme.selected(line), innerWidth) : padLine(line, innerWidth));
|
|
5310
|
+
}
|
|
5311
|
+
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
5312
|
+
return lines;
|
|
5313
|
+
}
|
|
5314
|
+
renderDetailPane(width, height) {
|
|
5315
|
+
const lines = [];
|
|
5316
|
+
const innerWidth = Math.max(1, width - 2);
|
|
5317
|
+
const tabs = [
|
|
5318
|
+
{
|
|
5319
|
+
id: "notes",
|
|
5320
|
+
label: "1 Notes"
|
|
5321
|
+
},
|
|
5322
|
+
{
|
|
5323
|
+
id: "transcript",
|
|
5324
|
+
label: "2 Transcript"
|
|
5325
|
+
},
|
|
5326
|
+
{
|
|
5327
|
+
id: "metadata",
|
|
5328
|
+
label: "3 Metadata"
|
|
5329
|
+
},
|
|
5330
|
+
{
|
|
5331
|
+
id: "raw",
|
|
5332
|
+
label: "4 Raw"
|
|
5333
|
+
}
|
|
5334
|
+
];
|
|
5335
|
+
const title = this.#selectedMeeting?.meeting.meeting.title || this.#selectedMeetingId || "Meeting";
|
|
5336
|
+
const titleLine = `${granolaTuiTheme.strong(title)} ${granolaTuiTheme.dim(this.#selectedMeeting ? this.#selectedMeeting.meeting.meeting.id : "")}`.trim();
|
|
5337
|
+
lines.push(padLine(titleLine, innerWidth));
|
|
5338
|
+
const tabLine = tabs.map((tab) => tab.id === this.#tab ? granolaTuiTheme.selected(` ${tab.label} `) : ` ${tab.label} `).join(" ");
|
|
5339
|
+
lines.push(padLine(tabLine, innerWidth));
|
|
5340
|
+
const bodyLines = this.currentDetailBody(innerWidth);
|
|
5341
|
+
const bodyHeight = Math.max(1, height - 2);
|
|
5342
|
+
const visibleBody = bodyLines.slice(this.#detailScroll, this.#detailScroll + bodyHeight);
|
|
5343
|
+
lines.push(...visibleBody.map((line) => padLine(line, innerWidth)));
|
|
5344
|
+
while (lines.length < height) lines.push(" ".repeat(innerWidth));
|
|
5345
|
+
return lines;
|
|
5346
|
+
}
|
|
5347
|
+
render(width) {
|
|
5348
|
+
const totalHeight = Math.max(12, this.tui.terminal.rows);
|
|
5349
|
+
const { detailWidth, listWidth } = this.resolveLayout(width);
|
|
5350
|
+
const bodyHeight = Math.max(6, totalHeight - 2 - 2);
|
|
5351
|
+
const selectedLabel = this.#selectedMeeting?.meeting.meeting.title || this.#selectedMeetingId || "none";
|
|
5352
|
+
const headerTitle = padLine(`${granolaTuiTheme.accent("Granola Toolkit TUI")} ${granolaTuiTheme.dim(this.#loadingMeetings ? "loading…" : selectedLabel)}`, width);
|
|
5353
|
+
const headerSummary = padLine(granolaTuiTheme.dim(buildGranolaTuiSummary(this.#appState, this.#meetingSource)), width);
|
|
5354
|
+
const listLines = this.renderListPane(listWidth, bodyHeight);
|
|
5355
|
+
const detailLines = this.renderDetailPane(detailWidth, bodyHeight);
|
|
5356
|
+
const bodyLines = [];
|
|
5357
|
+
for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
|
|
5358
|
+
const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
|
|
5359
|
+
const footerHints = padLine(granolaTuiTheme.dim("/ quick open r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
|
|
5360
|
+
return [
|
|
5361
|
+
headerTitle,
|
|
5362
|
+
headerSummary,
|
|
5363
|
+
...bodyLines,
|
|
5364
|
+
footerStatus,
|
|
5365
|
+
footerHints
|
|
5366
|
+
];
|
|
5367
|
+
}
|
|
5368
|
+
};
|
|
5369
|
+
async function runGranolaTui(app, options = {}) {
|
|
5370
|
+
const tui = new TUI(new ProcessTerminal());
|
|
5371
|
+
return await new Promise((resolve, reject) => {
|
|
5372
|
+
const workspace = new GranolaTuiWorkspace(tui, app, {
|
|
5373
|
+
initialMeetingId: options.initialMeetingId,
|
|
5374
|
+
onExit: () => {
|
|
5375
|
+
workspace.dispose();
|
|
5376
|
+
tui.stop();
|
|
5377
|
+
resolve(0);
|
|
5378
|
+
}
|
|
5379
|
+
});
|
|
5380
|
+
(async () => {
|
|
5381
|
+
try {
|
|
5382
|
+
await workspace.initialise();
|
|
5383
|
+
} catch (error) {
|
|
5384
|
+
workspace.dispose();
|
|
5385
|
+
reject(error);
|
|
5386
|
+
return;
|
|
5387
|
+
}
|
|
5388
|
+
tui.addChild(workspace);
|
|
5389
|
+
tui.setFocus(workspace);
|
|
5390
|
+
tui.start();
|
|
5391
|
+
tui.requestRender(true);
|
|
5392
|
+
})();
|
|
5393
|
+
});
|
|
5394
|
+
}
|
|
5395
|
+
//#endregion
|
|
5396
|
+
//#region src/commands/tui.ts
|
|
5397
|
+
function tuiHelp() {
|
|
5398
|
+
return `Granola tui
|
|
5399
|
+
|
|
5400
|
+
Usage:
|
|
5401
|
+
granola tui [options]
|
|
5402
|
+
|
|
5403
|
+
Options:
|
|
5404
|
+
--meeting <id> Open the workspace focused on a specific meeting
|
|
5405
|
+
--cache <path> Path to Granola cache JSON
|
|
5406
|
+
--timeout <value> Request timeout, e.g. 2m, 30s, 120000 (default: 2m)
|
|
5407
|
+
--supabase <path> Path to supabase.json
|
|
5408
|
+
--debug Enable debug logging
|
|
5409
|
+
--config <path> Path to .granola.toml
|
|
5410
|
+
-h, --help Show help
|
|
5411
|
+
`;
|
|
5412
|
+
}
|
|
5413
|
+
const tuiCommand = {
|
|
5414
|
+
description: "Start the Granola Toolkit terminal workspace",
|
|
5415
|
+
flags: {
|
|
5416
|
+
cache: { type: "string" },
|
|
5417
|
+
help: { type: "boolean" },
|
|
5418
|
+
meeting: { type: "string" },
|
|
5419
|
+
timeout: { type: "string" }
|
|
5420
|
+
},
|
|
5421
|
+
help: tuiHelp,
|
|
5422
|
+
name: "tui",
|
|
5423
|
+
async run({ commandFlags, globalFlags }) {
|
|
5424
|
+
const config = await loadConfig({
|
|
5425
|
+
globalFlags,
|
|
5426
|
+
subcommandFlags: commandFlags
|
|
5427
|
+
});
|
|
5428
|
+
debug(config.debug, "using config", config.configFileUsed ?? "(none)");
|
|
5429
|
+
debug(config.debug, "supabase", config.supabase);
|
|
5430
|
+
debug(config.debug, "cacheFile", config.transcripts.cacheFile || "(none)");
|
|
5431
|
+
debug(config.debug, "timeoutMs", config.notes.timeoutMs);
|
|
5432
|
+
return await runGranolaTui(await createGranolaApp(config, { surface: "tui" }), { initialMeetingId: typeof commandFlags.meeting === "string" && commandFlags.meeting.trim() ? commandFlags.meeting.trim() : void 0 });
|
|
5433
|
+
}
|
|
5434
|
+
};
|
|
5435
|
+
//#endregion
|
|
4792
5436
|
//#region src/commands/transcripts.ts
|
|
4793
5437
|
function transcriptsHelp() {
|
|
4794
5438
|
return `Granola transcripts
|
|
@@ -4905,6 +5549,7 @@ const commands = [
|
|
|
4905
5549
|
meetingCommand,
|
|
4906
5550
|
notesCommand,
|
|
4907
5551
|
serveCommand,
|
|
5552
|
+
tuiCommand,
|
|
4908
5553
|
transcriptsCommand,
|
|
4909
5554
|
{
|
|
4910
5555
|
description: "Start the Granola Toolkit web workspace",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granola-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -45,12 +45,14 @@
|
|
|
45
45
|
"release:patch": "node scripts/release.mjs patch",
|
|
46
46
|
"start": "node dist/cli.js",
|
|
47
47
|
"notes": "node dist/cli.js notes",
|
|
48
|
+
"tui": "node dist/cli.js tui",
|
|
48
49
|
"transcripts": "node dist/cli.js transcripts",
|
|
49
50
|
"test": "vp test",
|
|
50
51
|
"typecheck": "vp exec tsc --noEmit",
|
|
51
52
|
"prepare": "vp config"
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
55
|
+
"@mariozechner/pi-tui": "^0.65.0",
|
|
54
56
|
"node-html-markdown": "^2.0.0"
|
|
55
57
|
},
|
|
56
58
|
"devDependencies": {
|