From 0f4038d1431dd994ff8012dd4e20189ffd8f1c3e Mon Sep 17 00:00:00 2001 From: ccage-simp Date: Mon, 6 Apr 2026 18:44:45 -0500 Subject: [PATCH 1/2] feat: add interactive 'ft browse' command using fzf with live previews --- CLAUDE.md | 4 +-- README.md | 9 ++++-- src/bookmarks-db.ts | 73 +++++++++++++++++++++++++++++++++++++++++++++ src/cli.ts | 60 +++++++++++++++++++++++++++++++++---- 4 files changed, 137 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2daa100..bea9042 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,13 +38,13 @@ Chrome cookies → GraphQL API → JSONL cache → SQLite FTS5 index ↓ Regex classification ↓ - Search / List / Viz + Search / List / Viz / Browse (fzf) ``` ### Dependencies -All pure JavaScript/WASM — no native bindings: - `commander` — CLI framework - `sql.js` + `sql.js-fts5` — SQLite in WebAssembly - `zod` — schema validation - `dotenv` — .env file loading +- `fzf` — required for `ft browse` (external binary) diff --git a/README.md b/README.md index 9cc234b..c5eff01 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,13 @@ Requires Node.js 20+. A Chrome-family browser or Firefox is recommended for sess # 1. Sync your bookmarks (needs a supported browser logged into X) ft sync -# 2. Search them +# 2. Browse them interactively (needs fzf) +ft browse + +# 3. Search them ft search "distributed systems" -# 3. Explore +# 4. Explore trends ft viz ft categories ft stats @@ -52,9 +55,11 @@ On first run, `ft sync` extracts your X session from your browser and downloads | Command | Description | |---------|-------------| | `ft search ` | Full-text search with BM25 ranking | +| `ft browse` | Interactive browser with live preview (needs `fzf`) | | `ft list` | Filter by author, date, category, domain, or folder | | `ft list --folder ` | Show bookmarks in an X bookmark folder | | `ft show ` | Show one bookmark in detail | +| `ft show --open` | Show details and open in your default browser | | `ft sample ` | Random sample from a category | | `ft stats` | Top authors, languages, date range | | `ft viz` | Terminal dashboard with sparklines, categories, and domains | diff --git a/src/bookmarks-db.ts b/src/bookmarks-db.ts index cfc336b..45eeb4d 100644 --- a/src/bookmarks-db.ts +++ b/src/bookmarks-db.ts @@ -773,6 +773,79 @@ export async function exportBookmarksForSyncSeed(): Promise { } } +export async function getFzfList(): Promise { + const dbPath = twitterBookmarksIndexPath(); + const db = await openDb(dbPath); + ensureMigrations(db); + + try { + const sql = ` + SELECT + posted_at, + bookmarked_at, + author_handle, + text, + id, + tweet_id + FROM bookmarks + `; + const rows = db.exec(sql); + if (!rows.length) return []; + + const items = rows[0].values.map((row) => { + const postedAtStr = row[0] as string | null; + const bookmarkedAtStr = row[1] as string | null; + const authorHandle = row[2] as string | null; + const text = row[3] as string | null; + const id = row[4] as string; + const tweetId = row[5] as string; + + // Parse timestamp + let timestamp = 0; + if (bookmarkedAtStr && /^\d{4}-/.test(bookmarkedAtStr)) { + timestamp = new Date(bookmarkedAtStr).getTime(); + } else if (postedAtStr) { + timestamp = new Date(postedAtStr).getTime(); + } + + // Fallback to decoding the Snowflake ID + if (isNaN(timestamp) || timestamp === 0) { + try { + timestamp = Number(BigInt(tweetId) >> 22n) + 1288834974657; + } catch { + timestamp = 0; + } + } + + const cleanText = String(text ?? '') + .replace(/\s+/g, ' ') // Collapse all whitespace including newlines + .trim() + .slice(0, 300); + + return { + date: new Date(timestamp), + author: String(authorHandle ?? 'unknown').padEnd(15), + text: cleanText, + id, + timestamp + }; + }); + + // Sort newest to oldest + items.sort((a, b) => b.timestamp - a.timestamp); + + return items.map((item) => { + const yyyy = item.date.getFullYear(); + const mm = String(item.date.getMonth() + 1).padStart(2, '0'); + const dd = String(item.date.getDate()).padStart(2, '0'); + const shortDate = isNaN(yyyy) ? '????-??-??' : `${yyyy}-${mm}-${dd}`; + return `[${shortDate}] @${item.author} ${item.text} ${item.id}`; + }); + } finally { + db.close(); + } +} + export async function getBookmarkById(id: string): Promise { const dbPath = twitterBookmarksIndexPath(); const db = await openDb(dbPath); diff --git a/src/cli.ts b/src/cli.ts index 35de09f..2689c2b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { getFolderCounts, listBookmarks, getBookmarkById, + getFzfList, } from './bookmarks-db.js'; import { formatClassificationSummary } from './bookmark-classify.js'; import { classifyWithLlm, classifyDomainsWithLlm } from './bookmark-classify-llm.js'; @@ -41,11 +42,17 @@ import { getPathReport } from './field-status.js'; import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; +import { execSync, spawnSync } from 'node:child_process'; configureHttpProxyFromEnv(); // ── Helpers ───────────────────────────────────────────────────────────────── +/** Create a clickable terminal hyperlink (OSC 8) */ +function link(url: string, text?: string): string { + return `\x1b]8;;${url}\x1b\\${text ?? url}\x1b]8;;\x1b\\`; +} + const SPINNER = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']; let spinnerIdx = 0; @@ -381,7 +388,7 @@ export async function showDashboard(): Promise { console.log(` \x1b[2mSync now:\x1b[0m ft sync \x1b[2mSearch:\x1b[0m ft search "query" - \x1b[2mExplore:\x1b[0m ft viz + \x1b[2mExplore:\x1b[0m ft viz, ft browse \x1b[2mAll commands:\x1b[0m ft --help `); } catch { @@ -1036,6 +1043,7 @@ export function buildCli() { .description('Show one bookmark in detail') .argument('', 'Bookmark id') .option('--json', 'JSON output') + .option('--open', 'Open the bookmark URL in your default browser') .action(safe(async (id: string, options) => { if (!requireIndex()) return; const item = await getBookmarkById(String(id)); @@ -1044,19 +1052,61 @@ export function buildCli() { process.exitCode = 1; return; } + if (options.open) { + try { + execSync(`open "${item.url}"`); + } catch { + console.error(` Error: Could not open browser for ${item.url}`); + } + } if (options.json) { console.log(JSON.stringify(item, null, 2)); return; } console.log(`${item.id} \u00b7 ${item.authorHandle ? `@${item.authorHandle}` : '@?'}`); - console.log(item.url); + console.log(link(item.url)); console.log(item.text); if (item.quotedTweet) { console.log(formatQuotedTweetLines(item.quotedTweet).join('\n')); } - if (item.links.length) console.log(`links: ${item.links.join(', ')}`); - if (item.categories) console.log(`categories: ${item.categories}`); - if (item.domains) console.log(`domains: ${item.domains}`); + if (item.links.length) console.log(`links: ${item.links.map(l => link(l)).join(', ')}`); + if (item.categories.length) console.log(`categories: ${item.categories.join(', ')}`); + if (item.domains.length) console.log(`domains: ${item.domains.join(', ')}`); + })); + + // ── browse ────────────────────────────────────────────────────────────── + + program + .command('browse') + .description('Interactive browser for your bookmarks using fzf') + .action(safe(async () => { + if (!requireIndex()) return; + const list = await getFzfList(); + if (!list.length) { + console.log(' No bookmarks to browse. Run: ft sync'); + return; + } + + // We use a temporary command that fzf can call for the preview. + // The current binary is globally linked as 'ft'. + const fzf = spawnSync('fzf', [ + '--layout=reverse', + '--header', 'Enter: View & Open | Ctrl-/: Toggle Preview | Ctrl-W: Resize | ESC: Quit', + '--preview', 'ft show {-1}', + '--preview-window', 'right:50%:wrap', + '--ansi', + '--bind', 'ctrl-/:change-preview-window(hidden|)', + '--bind', 'ctrl-w:change-preview-window(right,60%|right,70%|right,40%|right,50%)', + '--bind', 'enter:execute(ft show {-1} --open)+abort', + ], { + input: list.join('\n'), + stdio: ['pipe', 'inherit', 'inherit'], + encoding: 'utf-8', + }); + + const selected = fzf.stdout?.trim(); + // fzf's execute() binding will naturally print the output of `ft show` + // before exiting, so we don't need to manually re-parse and print it here. })); // ── stats ─────────────────────────────────────────────────────────────── From e2c4e5d02db9de1cfa185f47eea94fc488351f15 Mon Sep 17 00:00:00 2001 From: Chris Cage Date: Sun, 3 May 2026 12:15:22 -0500 Subject: [PATCH 2/2] make ft browse report error when fzf not installed --- package-lock.json | 4 +-- src/cli.ts | 11 ++++++++ tests/cli.test.ts | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7830e37..eeeb97d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fieldtheory", - "version": "1.3.17", + "version": "1.3.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fieldtheory", - "version": "1.3.17", + "version": "1.3.18", "license": "MIT", "dependencies": { "commander": "^14.0.3", diff --git a/src/cli.ts b/src/cli.ts index 2689c2b..3a01e30 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1104,6 +1104,17 @@ export function buildCli() { encoding: 'utf-8', }); + if (fzf.error) { + const err = fzf.error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + throw new Error('ft browse requires fzf, but it was not found on PATH. Install fzf, then retry: ft browse'); + } + throw new Error(`fzf failed to start: ${err.message}`); + } + if (fzf.status === 2) { + throw new Error('fzf exited with an error while browsing bookmarks.'); + } + const selected = fzf.stdout?.trim(); // fzf's execute() binding will naturally print the output of `ft show` // before exiting, so we don't need to manually re-parse and print it here. diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a9c1b56..a4df32a 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { compareVersions, runWithSpinner, buildCli, parseCookieOption } from '../src/cli.js'; +import { buildIndex } from '../src/bookmarks-db.js'; import { dataDir } from '../src/paths.js'; import { skillWithFrontmatter } from '../src/skill.js'; @@ -26,6 +27,25 @@ async function captureStdout(fn: () => Promise): Promise { return chunks.join(''); } +async function captureStderr(fn: () => Promise): Promise { + const chunks: string[] = []; + const origWrite = process.stderr.write; + process.stderr.write = ((chunk: any, encodingOrCb?: any, cb?: any) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk)); + if (typeof encodingOrCb === 'function') encodingOrCb(); + if (typeof cb === 'function') cb(); + return true; + }) as typeof process.stderr.write; + + try { + await fn(); + } finally { + process.stderr.write = origWrite; + } + + return chunks.join(''); +} + test('showDashboard: prints update notice when cache is newer than local', async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-dashboard-')); const origEnv = process.env.FT_DATA_DIR; @@ -55,6 +75,51 @@ test('showDashboard: prints update notice when cache is newer than local', async ); }); +test('ft browse: reports missing fzf instead of silently exiting', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ft-browse-')); + const emptyPathDir = path.join(tmpDir, 'bin'); + fs.mkdirSync(emptyPathDir); + fs.writeFileSync(path.join(tmpDir, 'bookmarks.jsonl'), `${JSON.stringify({ + id: '1', + tweetId: '1', + url: 'https://x.com/alice/status/1', + text: 'A bookmark for browsing', + authorHandle: 'alice', + authorName: 'Alice', + syncedAt: '2026-01-01T00:00:00Z', + postedAt: '2026-01-01T12:00:00Z', + links: [], + tags: [], + ingestedVia: 'graphql', + })}\n`); + + const origEnv = { + FT_DATA_DIR: process.env.FT_DATA_DIR, + PATH: process.env.PATH, + }; + const origExitCode = process.exitCode; + process.env.FT_DATA_DIR = tmpDir; + process.env.PATH = emptyPathDir; + process.exitCode = undefined; + + try { + await buildIndex(); + const output = await captureStderr(async () => { + await buildCli().parseAsync(['node', 'ft', 'browse']); + }); + assert.equal(process.exitCode, 1); + assert.match(output, /ft browse requires fzf/); + assert.match(output, /not found on PATH/); + } finally { + if (origEnv.FT_DATA_DIR === undefined) delete process.env.FT_DATA_DIR; + else process.env.FT_DATA_DIR = origEnv.FT_DATA_DIR; + if (origEnv.PATH === undefined) delete process.env.PATH; + else process.env.PATH = origEnv.PATH; + process.exitCode = origExitCode; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + test('ft wiki: --engine option is registered', () => { const program = buildCli(); const wikiCmd = program.commands.find((c: any) => c.name() === 'wiki');