granola-toolkit 0.32.0 → 0.33.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 +3 -1
  2. package/dist/cli.js +141 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -319,6 +319,7 @@ That keeps the current single-package repo simple, while making a future split i
319
319
 
320
320
  The initial terminal workspace includes:
321
321
 
322
+ - a folder scope inside the navigation pane, including an explicit All meetings view
322
323
  - a meeting list pane with keyboard navigation
323
324
  - a detail pane with notes, transcript, metadata, and raw views
324
325
  - an auth session overlay for import, refresh, source switching, and sign-out
@@ -327,7 +328,8 @@ The initial terminal workspace includes:
327
328
 
328
329
  The main keyboard controls are:
329
330
 
330
- - `j` / `k` or arrow keys to move between meetings
331
+ - `h` / `l`, left / right, or `Tab` to switch between folders and meetings
332
+ - `j` / `k` or arrow keys to move within the active folder or meeting list
331
333
  - `/` or `Ctrl+P` to open quick open
332
334
  - `a` to open auth session actions
333
335
  - `1`-`4` to switch detail tabs
package/dist/cli.js CHANGED
@@ -1345,6 +1345,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1345
1345
  `ID: ${summary.id}`,
1346
1346
  `Created: ${summary.createdAt}`,
1347
1347
  `Updated: ${summary.updatedAt}`,
1348
+ `Folders: ${summary.folders.length > 0 ? summary.folders.map((folder) => folder.name).join(", ") : "none"}`,
1348
1349
  `Tags: ${summary.tags.length > 0 ? summary.tags.join(", ") : "none"}`,
1349
1350
  `Notes source: ${summary.noteContentSource}`,
1350
1351
  `Transcript loaded: ${summary.transcriptLoaded ? "yes" : "no"}`,
@@ -1360,7 +1361,7 @@ function renderGranolaTuiMeetingTab(bundle, tab) {
1360
1361
  }
1361
1362
  }
1362
1363
  function buildGranolaTuiSummary(state, meetingSource) {
1363
- 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}`;
1364
+ return `auth ${state.auth.mode === "stored-session" ? "stored" : "supabase"} | ${state.documents.loaded ? `${state.documents.count} docs` : "docs pending"} | ${state.folders.loaded ? `${state.folders.count} folders` : "folders 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}`;
1364
1365
  }
1365
1366
  //#endregion
1366
1367
  //#region src/tui/theme.ts
@@ -1657,9 +1658,13 @@ var GranolaTuiWorkspace = class {
1657
1658
  focused = false;
1658
1659
  #maxMeetings;
1659
1660
  #appState;
1661
+ #activePane = "meetings";
1660
1662
  #detailError = "";
1661
1663
  #detailScroll = 0;
1662
1664
  #detailToken = 0;
1665
+ #folderError = "";
1666
+ #folderToken = 0;
1667
+ #folders = [];
1663
1668
  #listError = "";
1664
1669
  #listToken = 0;
1665
1670
  #loadingDetail = false;
@@ -1667,6 +1672,7 @@ var GranolaTuiWorkspace = class {
1667
1672
  #meetingSource = "live";
1668
1673
  #meetings = [];
1669
1674
  #overlay;
1675
+ #selectedFolderId;
1670
1676
  #selectedMeeting;
1671
1677
  #selectedMeetingId;
1672
1678
  #statusMessage = "Loading meetings…";
@@ -1679,11 +1685,13 @@ var GranolaTuiWorkspace = class {
1679
1685
  this.options = options;
1680
1686
  this.#appState = app.getState();
1681
1687
  this.#maxMeetings = options.maxMeetings ?? 200;
1688
+ this.#selectedFolderId = this.#appState.ui.selectedFolderId;
1682
1689
  }
1683
1690
  async initialise() {
1684
1691
  this.#unsubscribe = this.app.subscribe((event) => {
1685
1692
  this.handleAppUpdate(event);
1686
1693
  });
1694
+ await this.loadFolders({ setStatus: false });
1687
1695
  await this.loadMeetings({
1688
1696
  preferredMeetingId: this.options.initialMeetingId,
1689
1697
  setStatus: true
@@ -1699,7 +1707,12 @@ var GranolaTuiWorkspace = class {
1699
1707
  handleAppUpdate(event) {
1700
1708
  const previousDocumentsLoadedAt = this.#appState.documents.loadedAt;
1701
1709
  this.#appState = event.state;
1702
- if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
1710
+ this.#selectedFolderId = event.state.ui.selectedFolderId;
1711
+ this.#selectedMeetingId = event.state.ui.selectedMeetingId ?? this.#selectedMeetingId;
1712
+ if (this.#meetingSource === "index" && event.state.documents.loadedAt && event.state.documents.loadedAt !== previousDocumentsLoadedAt && !this.#loadingMeetings) (async () => {
1713
+ await this.loadFolders({ setStatus: false });
1714
+ await this.loadMeetings({ preferredMeetingId: this.#selectedMeetingId });
1715
+ })();
1703
1716
  this.tui.requestRender();
1704
1717
  }
1705
1718
  setStatus(message, tone = "info") {
@@ -1712,6 +1725,11 @@ var GranolaTuiWorkspace = class {
1712
1725
  const selectedIndex = this.#selectedMeetingId ? this.#meetings.findIndex((meeting) => meeting.id === this.#selectedMeetingId) : -1;
1713
1726
  return selectedIndex >= 0 ? selectedIndex : 0;
1714
1727
  }
1728
+ normaliseSelectedFolderIndex() {
1729
+ if (!this.#selectedFolderId) return 0;
1730
+ const selectedIndex = this.#folders.findIndex((folder) => folder.id === this.#selectedFolderId);
1731
+ return selectedIndex >= 0 ? selectedIndex + 1 : 0;
1732
+ }
1715
1733
  ensureMeetingVisible(meeting) {
1716
1734
  const existingIndex = this.#meetings.findIndex((item) => item.id === meeting.id);
1717
1735
  if (existingIndex >= 0) this.#meetings[existingIndex] = meeting;
@@ -1721,6 +1739,30 @@ var GranolaTuiWorkspace = class {
1721
1739
  return left.title.localeCompare(right.title);
1722
1740
  });
1723
1741
  }
1742
+ async loadFolders(options = {}) {
1743
+ const token = ++this.#folderToken;
1744
+ this.#folderError = "";
1745
+ if (options.setStatus) this.setStatus(options.forceRefresh ? "Refreshing folders…" : "Loading folders…");
1746
+ try {
1747
+ const result = await this.app.listFolders({
1748
+ forceRefresh: options.forceRefresh,
1749
+ limit: 100
1750
+ });
1751
+ if (token !== this.#folderToken) return;
1752
+ this.#folders = result.folders;
1753
+ if (this.#selectedFolderId && !this.#folders.some((folder) => folder.id === this.#selectedFolderId)) this.#selectedFolderId = void 0;
1754
+ this.#folderError = "";
1755
+ } catch (error) {
1756
+ if (token !== this.#folderToken) return;
1757
+ const message = error instanceof Error ? error.message : String(error);
1758
+ this.#folderError = message;
1759
+ this.#folders = [];
1760
+ this.#selectedFolderId = void 0;
1761
+ this.setStatus(message, "error");
1762
+ } finally {
1763
+ if (token === this.#folderToken) this.tui.requestRender();
1764
+ }
1765
+ }
1724
1766
  async loadMeetings(options = {}) {
1725
1767
  const token = ++this.#listToken;
1726
1768
  this.#loadingMeetings = true;
@@ -1728,6 +1770,7 @@ var GranolaTuiWorkspace = class {
1728
1770
  if (options.setStatus !== false) this.setStatus(options.forceRefresh ? "Refreshing meetings…" : "Loading meetings…");
1729
1771
  try {
1730
1772
  const result = await this.app.listMeetings({
1773
+ folderId: this.#selectedFolderId,
1731
1774
  forceRefresh: options.forceRefresh,
1732
1775
  limit: this.#maxMeetings,
1733
1776
  preferIndex: true
@@ -1736,8 +1779,13 @@ var GranolaTuiWorkspace = class {
1736
1779
  this.#meetings = result.meetings;
1737
1780
  this.#meetingSource = result.source;
1738
1781
  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;
1782
+ if (!this.#selectedMeetingId) {
1783
+ this.#selectedMeeting = void 0;
1784
+ this.#detailError = "";
1785
+ this.#detailScroll = 0;
1786
+ }
1739
1787
  this.#listError = "";
1740
- this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : "Connected to Granola");
1788
+ this.setStatus(result.source === "index" ? "Loaded meetings from the local index" : this.#selectedFolderId ? "Connected to Granola (folder scope)" : "Connected to Granola");
1741
1789
  } catch (error) {
1742
1790
  if (token !== this.#listToken) return;
1743
1791
  const message = error instanceof Error ? error.message : String(error);
@@ -1780,6 +1828,10 @@ var GranolaTuiWorkspace = class {
1780
1828
  }
1781
1829
  async refresh(forceRefresh) {
1782
1830
  try {
1831
+ await this.loadFolders({
1832
+ forceRefresh,
1833
+ setStatus: false
1834
+ });
1783
1835
  await this.loadMeetings({
1784
1836
  forceRefresh,
1785
1837
  preferredMeetingId: this.#selectedMeetingId
@@ -1787,7 +1839,7 @@ var GranolaTuiWorkspace = class {
1787
1839
  if (this.#selectedMeetingId) await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1788
1840
  } catch {}
1789
1841
  }
1790
- async moveSelection(delta) {
1842
+ async moveMeetingSelection(delta) {
1791
1843
  if (this.#meetings.length === 0) return;
1792
1844
  const currentIndex = this.normaliseSelectedIndex();
1793
1845
  const nextIndex = Math.max(0, Math.min(this.#meetings.length - 1, currentIndex + delta));
@@ -1795,6 +1847,33 @@ var GranolaTuiWorkspace = class {
1795
1847
  if (!nextMeeting || nextMeeting.id === this.#selectedMeetingId) return;
1796
1848
  await this.loadMeeting(nextMeeting.id);
1797
1849
  }
1850
+ async moveFolderSelection(delta) {
1851
+ const total = this.#folders.length + 1;
1852
+ const currentIndex = this.normaliseSelectedFolderIndex();
1853
+ const nextIndex = Math.max(0, Math.min(total - 1, currentIndex + delta));
1854
+ const nextFolderId = nextIndex === 0 ? void 0 : this.#folders[nextIndex - 1]?.id;
1855
+ if (nextFolderId === this.#selectedFolderId) return;
1856
+ this.#selectedFolderId = nextFolderId;
1857
+ this.#selectedMeeting = void 0;
1858
+ this.#detailError = "";
1859
+ this.#detailScroll = 0;
1860
+ await this.loadMeetings({
1861
+ preferredMeetingId: this.#selectedMeetingId,
1862
+ setStatus: false
1863
+ });
1864
+ if (this.#selectedMeetingId) {
1865
+ await this.loadMeeting(this.#selectedMeetingId, { ensureMeetingVisible: true });
1866
+ return;
1867
+ }
1868
+ this.tui.requestRender();
1869
+ }
1870
+ async moveSelection(delta) {
1871
+ if (this.#activePane === "folders") {
1872
+ await this.moveFolderSelection(delta);
1873
+ return;
1874
+ }
1875
+ await this.moveMeetingSelection(delta);
1876
+ }
1798
1877
  currentDetailBody(width) {
1799
1878
  if (this.#detailError) return wrapBlock(this.#detailError, width);
1800
1879
  if (this.#loadingDetail && !this.#selectedMeeting) return wrapBlock("Loading meeting details…", width);
@@ -1832,6 +1911,10 @@ var GranolaTuiWorkspace = class {
1832
1911
  async reloadAfterAuthChange() {
1833
1912
  const preferredMeetingId = this.#selectedMeeting?.document.id ?? this.#selectedMeetingId;
1834
1913
  try {
1914
+ await this.loadFolders({
1915
+ forceRefresh: true,
1916
+ setStatus: false
1917
+ });
1835
1918
  await this.loadMeetings({
1836
1919
  forceRefresh: true,
1837
1920
  preferredMeetingId,
@@ -1956,6 +2039,21 @@ var GranolaTuiWorkspace = class {
1956
2039
  this.openAuthPanel();
1957
2040
  return;
1958
2041
  }
2042
+ if (matchesKey(data, "tab")) {
2043
+ this.#activePane = this.#activePane === "folders" ? "meetings" : "folders";
2044
+ this.tui.requestRender();
2045
+ return;
2046
+ }
2047
+ if (matchesKey(data, "left") || matchesKey(data, "h")) {
2048
+ this.#activePane = "folders";
2049
+ this.tui.requestRender();
2050
+ return;
2051
+ }
2052
+ if (matchesKey(data, "right") || matchesKey(data, "l")) {
2053
+ this.#activePane = "meetings";
2054
+ this.tui.requestRender();
2055
+ return;
2056
+ }
1959
2057
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
1960
2058
  this.moveSelection(-1);
1961
2059
  return;
@@ -2021,22 +2119,53 @@ var GranolaTuiWorkspace = class {
2021
2119
  renderListPane(width, height) {
2022
2120
  const lines = [];
2023
2121
  const innerWidth = Math.max(1, width - 2);
2024
- const header = `${granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
2025
- lines.push(padLine(header, innerWidth));
2122
+ const folderEntries = [{
2123
+ id: void 0,
2124
+ label: "All meetings",
2125
+ meta: this.#folders.length > 0 ? `${this.#folders.length} folders` : "global scope"
2126
+ }, ...this.#folders.map((folder) => ({
2127
+ id: folder.id,
2128
+ label: `${folder.isFavourite ? "★ " : ""}${folder.name || folder.id}`,
2129
+ meta: `${folder.documentCount} meetings`
2130
+ }))];
2131
+ const availableRows = Math.max(2, height - 3);
2132
+ const folderWindowSize = Math.min(Math.max(3, Math.min(8, Math.floor(availableRows * .35))), Math.max(1, availableRows - 1));
2133
+ const meetingWindowSize = Math.max(1, availableRows - folderWindowSize);
2134
+ const folderHeader = `${this.#activePane === "folders" ? granolaTuiTheme.accent("Folders") : granolaTuiTheme.strong("Folders")} ${granolaTuiTheme.dim(`(${this.#folders.length})`)}`;
2135
+ lines.push(padLine(folderHeader, innerWidth));
2136
+ if (this.#folderError) {
2137
+ lines.push(...wrapBlock(granolaTuiTheme.error(this.#folderError), innerWidth).slice(0, folderWindowSize));
2138
+ while (lines.length < 1 + folderWindowSize) lines.push(" ".repeat(innerWidth));
2139
+ } else {
2140
+ const selectedFolderIndex = this.normaliseSelectedFolderIndex();
2141
+ const folderStartIndex = Math.max(0, Math.min(selectedFolderIndex - Math.floor(folderWindowSize / 2), folderEntries.length - folderWindowSize));
2142
+ const visibleFolders = folderEntries.slice(folderStartIndex, folderStartIndex + folderWindowSize);
2143
+ for (const [offset, folder] of visibleFolders.entries()) {
2144
+ const selected = folderStartIndex + offset === selectedFolderIndex;
2145
+ const prefix = selected ? "> " : " ";
2146
+ const maxLabelWidth = Math.max(6, innerWidth - visibleWidth(prefix) - visibleWidth(folder.meta) - 1);
2147
+ const labelBlock = `${prefix}${truncateToWidth(folder.label, maxLabelWidth, "")}`;
2148
+ const line = `${labelBlock}${" ".repeat(Math.max(1, innerWidth - visibleWidth(labelBlock) - visibleWidth(folder.meta)))}${granolaTuiTheme.dim(folder.meta)}`;
2149
+ lines.push(selected ? padLine(granolaTuiTheme.selected(line), innerWidth) : padLine(line, innerWidth));
2150
+ }
2151
+ while (lines.length < 1 + folderWindowSize) lines.push(" ".repeat(innerWidth));
2152
+ }
2153
+ lines.push(padLine(granolaTuiTheme.dim(""), innerWidth));
2154
+ const meetingsHeader = `${this.#activePane === "meetings" ? granolaTuiTheme.accent("Meetings") : granolaTuiTheme.strong("Meetings")} ${granolaTuiTheme.dim(`(${this.#meetings.length})`)}`;
2155
+ lines.push(padLine(meetingsHeader, innerWidth));
2026
2156
  if (this.#listError) {
2027
- lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, height - 1));
2157
+ lines.push(...wrapBlock(granolaTuiTheme.error(this.#listError), innerWidth).slice(0, meetingWindowSize));
2028
2158
  while (lines.length < height) lines.push(" ".repeat(innerWidth));
2029
2159
  return lines;
2030
2160
  }
2031
2161
  if (this.#meetings.length === 0) {
2032
- lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, height - 1));
2162
+ lines.push(...wrapBlock("No meetings available yet.", innerWidth).slice(0, meetingWindowSize));
2033
2163
  while (lines.length < height) lines.push(" ".repeat(innerWidth));
2034
2164
  return lines;
2035
2165
  }
2036
2166
  const selectedIndex = this.normaliseSelectedIndex();
2037
- const windowSize = Math.max(1, height - 1);
2038
- const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(windowSize / 2), this.#meetings.length - windowSize));
2039
- const visibleMeetings = this.#meetings.slice(startIndex, startIndex + windowSize);
2167
+ const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(meetingWindowSize / 2), this.#meetings.length - meetingWindowSize));
2168
+ const visibleMeetings = this.#meetings.slice(startIndex, startIndex + meetingWindowSize);
2040
2169
  for (const [offset, meeting] of visibleMeetings.entries()) {
2041
2170
  const selected = startIndex + offset === selectedIndex;
2042
2171
  const dateLabel = meeting.updatedAt.slice(0, 10);
@@ -2094,7 +2223,7 @@ var GranolaTuiWorkspace = class {
2094
2223
  const bodyLines = [];
2095
2224
  for (let index = 0; index < bodyHeight; index += 1) bodyLines.push(`${padLine(listLines[index] ?? "", listWidth)} | ${padLine(detailLines[index] ?? "", detailWidth)}`);
2096
2225
  const footerStatus = padLine(toneText(this.#statusTone, this.#statusMessage), width);
2097
- const footerHints = padLine(granolaTuiTheme.dim("/ quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2226
+ const footerHints = padLine(granolaTuiTheme.dim("h/l or Tab pane j/k move / quick open a auth r refresh 1-4 tabs PgUp/PgDn scroll q quit"), width);
2098
2227
  return [
2099
2228
  headerTitle,
2100
2229
  headerSummary,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granola-toolkit",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Toolkit for exporting and working with Granola meetings, notes, and transcripts",
5
5
  "keywords": [
6
6
  "cli",