diff --git a/src/commands/history.tsx b/src/commands/history.tsx new file mode 100644 index 0000000..3809131 --- /dev/null +++ b/src/commands/history.tsx @@ -0,0 +1,225 @@ +import React, { useEffect, useState } from 'react' +import zod from 'zod' +import { Box, Text, useApp, useStdout } from 'ink' +import { Spinner } from '../components/Spinner.js' +import { HistoryBrowser } from '../components/HistoryBrowser.js' +import type { HistoryItem } from '../components/HistoryBrowser.js' +import { gql } from '../lib/client.js' +import { getFeedRender, getFeedWidth } from '../lib/config.js' +import { TweetCard, FeedTable } from '../components/TweetCard.js' +import type { FeedTweet } from '../components/TweetCard.js' + +export const description = 'Browse previously triaged suggestions' + +export const options = zod.object({ + status: zod + .string() + .optional() + .describe('Filter by status: archived|saved|read|skipped|replied (default: all non-inbox)'), + limit: zod.number().optional().describe('Result limit per page (default: 20)'), + render: zod.string().optional().describe('Output layout: card|table'), + width: zod.number().optional().describe('Card width in columns'), + json: zod.boolean().default(false).describe('Raw JSON output'), + interactive: zod + .boolean() + .default(true) + .describe('Interactive browser mode (default: on, use --no-interactive to disable)'), +}) + +type Props = { options: zod.infer } + +// Matches the shape returned by the suggestions GraphQL query +interface SuggestionResponse { + suggestionId: string + score: number + status: string + tweet: HistoryItem['tweet'] +} + +const HISTORY_QUERY = ` + query History($status: SuggestionStatus, $limit: Int, $offset: Int) { + suggestions(status: $status, limit: $limit, offset: $offset) { + suggestionId score status + tweet { + id xid text createdAt likeCount retweetCount replyCount + user { displayName username followersCount followingCount } + } + } + suggestionCounts { archived later read skipped replied total inbox } + } +` + +const STATUS_MAP: Record = { + archived: 'ARCHIVED', + saved: 'LATER', + later: 'LATER', + read: 'READ', + skipped: 'SKIPPED', + replied: 'REPLIED', +} + +function resolveStatus(input?: string): string | undefined { + if (!input) return undefined + const key = input.toLowerCase() + return STATUS_MAP[key] ?? input.toUpperCase() +} + +function countForStatus( + counts: { archived: number; later: number; read: number; skipped: number; replied: number; total: number; inbox: number }, + status?: string, +): number { + if (!status) return counts.total - counts.inbox + switch (status) { + case 'ARCHIVED': return counts.archived + case 'LATER': return counts.later + case 'READ': return counts.read + case 'SKIPPED': return counts.skipped + case 'REPLIED': return counts.replied + default: return counts.total - counts.inbox + } +} + +export default function History({ options: flags }: Props) { + const { exit } = useApp() + const [items, setItems] = useState(null) + const [total, setTotal] = useState(0) + const [error, setError] = useState(null) + + const { stdout } = useStdout() + const termWidth = stdout.columns ?? 100 + const cardWidth = getFeedWidth(flags.width) + const render = getFeedRender(flags.render) + const statusFilter = resolveStatus(flags.status) + + useEffect(() => { + async function load() { + try { + const limit = flags.limit ?? 20 + const vars: Record = { limit, offset: 0 } + if (statusFilter) vars.status = statusFilter + + const res = await gql<{ + suggestions: SuggestionResponse[] + suggestionCounts: { + archived: number; later: number; read: number + skipped: number; replied: number; total: number; inbox: number + } + }>(HISTORY_QUERY, vars) + + // When no status filter, exclude INBOX items from results + const filtered = statusFilter + ? res.suggestions + : res.suggestions.filter(s => s.status !== 'INBOX') + + const historyTotal = countForStatus(res.suggestionCounts, statusFilter) + + const mapped: HistoryItem[] = filtered.map(s => ({ + key: s.tweet.xid, + score: s.score, + suggestionId: s.suggestionId, + status: s.status, + matchedKeywords: [], + tweet: s.tweet, + })) + + if (flags.json) { + process.stdout.write(JSON.stringify(mapped, null, 2) + '\n') + exit() + return + } + + setItems(mapped) + setTotal(historyTotal) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + load() + }, [flags.status, flags.limit, flags.json]) + + if (error) return Error: {error} + if (!items) return + + if (items.length === 0) { + return ( + + No history yet. + Triage some suggestions first with sonar + + ) + } + + if (flags.interactive) { + const pageSize = flags.limit ?? 20 + const fetchMore = async (offset: number): Promise => { + const vars: Record = { limit: pageSize, offset } + if (statusFilter) vars.status = statusFilter + + const res = await gql<{ suggestions: SuggestionResponse[] }>(HISTORY_QUERY, vars) + const filtered = statusFilter + ? res.suggestions + : res.suggestions.filter(s => s.status !== 'INBOX') + + return filtered.map(s => ({ + key: s.tweet.xid, + score: s.score, + suggestionId: s.suggestionId, + status: s.status, + matchedKeywords: [], + tweet: s.tweet, + })) + } + return + } + + // Non-interactive: render all items + const statusLabel = flags.status ? flags.status : 'all' + + if (render === 'table') { + const tableData: FeedTweet[] = items.map(i => ({ + score: i.score, + matchedKeywords: i.matchedKeywords, + tweet: i.tweet, + })) + return ( + + + History + · {statusLabel} ({items.length}) + + + + ) + } + + return ( + + + + History + · {statusLabel} + ({items.length}) + + {'─'.repeat(Math.min(termWidth - 2, 72))} + + + + {items.map((item, i) => ( + + + + + status: {item.status.toLowerCase()} + + + + ))} + + + ) +} diff --git a/src/components/HistoryBrowser.tsx b/src/components/HistoryBrowser.tsx new file mode 100644 index 0000000..fb932aa --- /dev/null +++ b/src/components/HistoryBrowser.tsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react' +import { Box, Text, useApp, useInput, useStdout } from 'ink' +import { TweetCard } from './TweetCard.js' +import { getFeedWidth } from '../lib/config.js' +import { openUrl } from '../lib/open.js' +import type { TriageItem } from './InteractiveSession.js' + +export interface HistoryItem extends TriageItem { + status: string +} + +interface HistoryBrowserProps { + items: HistoryItem[] + total: number + fetchMore?: (offset: number) => Promise +} + +const STATUS_COLORS: Record = { + ARCHIVED: 'gray', + LATER: 'yellow', + READ: 'blue', + SKIPPED: 'red', + REPLIED: 'green', +} + +function statusLabel(status: string): string { + return status.toLowerCase() +} + +function Divider({ width }: { width: number }) { + return {'─'.repeat(Math.min(width - 2, 72))} +} + +export function HistoryBrowser({ items: initialItems, total: initialTotal, fetchMore }: HistoryBrowserProps) { + const { exit } = useApp() + const { stdout } = useStdout() + const termWidth = stdout.columns ?? 100 + const cardWidth = getFeedWidth() + + const [items, setItems] = useState(initialItems) + const [total, setTotal] = useState(initialTotal) + const [index, setIndex] = useState(0) + const [loading, setLoading] = useState(false) + + // Fetch next page when 3 items from the end + useEffect(() => { + if (!fetchMore || loading) return + if (index >= items.length - 3 && items.length < total) { + setLoading(true) + fetchMore(items.length) + .then(more => { + if (more.length > 0) { + setItems(prev => [...prev, ...more]) + } + }) + .catch(() => {}) + .finally(() => setLoading(false)) + } + }, [index, items.length, total, loading]) + + const current = items[index] + + useInput((input, key) => { + if (input === 'q') { + exit() + } else if (input === 'n' || key.return || input === ' ' || key.downArrow || key.rightArrow) { + if (index < items.length - 1 || items.length < total) { + setIndex(i => Math.min(i + 1, items.length - 1)) + } + } else if (input === 'b' || key.upArrow || key.leftArrow) { + setIndex(i => Math.max(0, i - 1)) + } else if (input === 'o' && current) { + const handle = current.tweet.user.username ?? current.tweet.user.displayName + const url = `https://x.com/${handle}/status/${current.tweet.id}` + openUrl(url) + } + }) + + if (!current) { + return ( + + No history items found. + Triage some suggestions first with sonar + + ) + } + + const statusColor = STATUS_COLORS[current.status] ?? 'white' + + return ( + + + {index + 1} / {total} + {statusLabel(current.status)} + + + + + + + + b back + n next + o open + q quit + + + + ) +} diff --git a/src/lib/open.ts b/src/lib/open.ts new file mode 100644 index 0000000..e5f30ac --- /dev/null +++ b/src/lib/open.ts @@ -0,0 +1,13 @@ +import { execSync } from 'child_process' +import { platform } from 'os' + +export function openUrl(url: string): void { + const cmd = + platform() === 'darwin' ? 'open' + : platform() === 'win32' ? 'start' + : 'xdg-open' + + try { + execSync(`${cmd} "${url}"`, { stdio: 'ignore' }) + } catch {} +}