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.
Files changed (3) hide show
  1. package/README.md +25 -0
  2. package/dist/cli.js +645 -0
  3. 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.25.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": {