From 8b7b308bf77691c48d019bc7a5be49c8db0a0ec5 Mon Sep 17 00:00:00 2001 From: Prime <263221252+cosmicallycooked@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:11:04 +0000 Subject: [PATCH 1/2] feat: add `sonar history` command to browse previously triaged suggestions Adds a new `sonar history` command that lets users browse suggestions they've already triaged (archived, saved, read, skipped, replied). Includes an interactive browser with back/forward navigation (b/n keys, arrow keys) and supports filtering by status (--status archived|saved|read|skipped|replied). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/history.tsx | 236 ++++++++++++++++++++++++++++++ src/components/HistoryBrowser.tsx | 135 +++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 src/commands/history.tsx create mode 100644 src/components/HistoryBrowser.tsx diff --git a/src/commands/history.tsx b/src/commands/history.tsx new file mode 100644 index 0000000..65839a4 --- /dev/null +++ b/src/commands/history.tsx @@ -0,0 +1,236 @@ +import React, { useEffect, useState } from 'react' +import zod from 'zod' +import { Box, Text, 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 } + +interface SuggestionItem { + suggestionId: string + score: number + status: string + tweet: { + id: string + xid: string + text: string + createdAt: string + likeCount: number + retweetCount: number + replyCount: number + user: { + displayName: string + username: string | null + followersCount: number | null + followingCount: number | null + } + } +} + +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 [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: SuggestionItem[] + 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') + process.exit(0) + } + + 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: SuggestionItem[] }>(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..333fbff --- /dev/null +++ b/src/components/HistoryBrowser.tsx @@ -0,0 +1,135 @@ +import React, { useState, useCallback, useEffect } from 'react' +import { Box, Text, useInput, useStdout } from 'ink' +import { gql } from '../lib/client.js' +import { relativeTime, TweetCard } from './TweetCard.js' +import { getFeedWidth } from '../lib/config.js' +import { execSync } from 'child_process' + +export interface HistoryItem { + key: string + score: number + suggestionId: string + status: string + matchedKeywords: string[] + tweet: { + id: string + xid: string + text: string + createdAt: string + likeCount: number + retweetCount: number + replyCount: number + user: { + displayName: string + username: string | null + followersCount: number | null + followingCount: number | null + } + } +} + +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 { 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] + const atStart = index === 0 + const atEnd = index >= items.length - 1 && items.length >= total + + useInput((input, key) => { + if (input === 'q') { + process.exit(0) + } 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}` + try { execSync(`open "${url}"`) } catch {} + } + }) + + 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 + + + + ) +} From 99d2ca0cbf6829d64732172ea7129aca633dfa05 Mon Sep 17 00:00:00 2001 From: Prime <263221252+cosmicallycooked@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:05:51 +0000 Subject: [PATCH 2/2] fix: address PR review feedback for history command - Remove unused imports (gql, relativeTime) from HistoryBrowser - Use useApp().exit() instead of process.exit(0) for clean Ink teardown - Reuse TriageItem type via extension instead of duplicating the interface - Add cross-platform openUrl helper (macOS/Linux/Windows) in src/lib/open.ts - STATUS_MAP verified to match all triage actions (read/saved/archived/skipped/replied) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/history.tsx | 29 ++++++++--------------- src/components/HistoryBrowser.tsx | 38 ++++++++----------------------- src/lib/open.ts | 13 +++++++++++ 3 files changed, 31 insertions(+), 49 deletions(-) create mode 100644 src/lib/open.ts diff --git a/src/commands/history.tsx b/src/commands/history.tsx index 65839a4..3809131 100644 --- a/src/commands/history.tsx +++ b/src/commands/history.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import zod from 'zod' -import { Box, Text, useStdout } from 'ink' +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' @@ -28,25 +28,12 @@ export const options = zod.object({ type Props = { options: zod.infer } -interface SuggestionItem { +// Matches the shape returned by the suggestions GraphQL query +interface SuggestionResponse { suggestionId: string score: number status: string - tweet: { - id: string - xid: string - text: string - createdAt: string - likeCount: number - retweetCount: number - replyCount: number - user: { - displayName: string - username: string | null - followersCount: number | null - followingCount: number | null - } - } + tweet: HistoryItem['tweet'] } const HISTORY_QUERY = ` @@ -93,6 +80,7 @@ function countForStatus( } 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) @@ -111,7 +99,7 @@ export default function History({ options: flags }: Props) { if (statusFilter) vars.status = statusFilter const res = await gql<{ - suggestions: SuggestionItem[] + suggestions: SuggestionResponse[] suggestionCounts: { archived: number; later: number; read: number skipped: number; replied: number; total: number; inbox: number @@ -136,7 +124,8 @@ export default function History({ options: flags }: Props) { if (flags.json) { process.stdout.write(JSON.stringify(mapped, null, 2) + '\n') - process.exit(0) + exit() + return } setItems(mapped) @@ -166,7 +155,7 @@ export default function History({ options: flags }: Props) { const vars: Record = { limit: pageSize, offset } if (statusFilter) vars.status = statusFilter - const res = await gql<{ suggestions: SuggestionItem[] }>(HISTORY_QUERY, vars) + const res = await gql<{ suggestions: SuggestionResponse[] }>(HISTORY_QUERY, vars) const filtered = statusFilter ? res.suggestions : res.suggestions.filter(s => s.status !== 'INBOX') diff --git a/src/components/HistoryBrowser.tsx b/src/components/HistoryBrowser.tsx index 333fbff..fb932aa 100644 --- a/src/components/HistoryBrowser.tsx +++ b/src/components/HistoryBrowser.tsx @@ -1,31 +1,12 @@ -import React, { useState, useCallback, useEffect } from 'react' -import { Box, Text, useInput, useStdout } from 'ink' -import { gql } from '../lib/client.js' -import { relativeTime, TweetCard } from './TweetCard.js' +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 { execSync } from 'child_process' +import { openUrl } from '../lib/open.js' +import type { TriageItem } from './InteractiveSession.js' -export interface HistoryItem { - key: string - score: number - suggestionId: string +export interface HistoryItem extends TriageItem { status: string - matchedKeywords: string[] - tweet: { - id: string - xid: string - text: string - createdAt: string - likeCount: number - retweetCount: number - replyCount: number - user: { - displayName: string - username: string | null - followersCount: number | null - followingCount: number | null - } - } } interface HistoryBrowserProps { @@ -51,6 +32,7 @@ function Divider({ width }: { width: number }) { } export function HistoryBrowser({ items: initialItems, total: initialTotal, fetchMore }: HistoryBrowserProps) { + const { exit } = useApp() const { stdout } = useStdout() const termWidth = stdout.columns ?? 100 const cardWidth = getFeedWidth() @@ -77,12 +59,10 @@ export function HistoryBrowser({ items: initialItems, total: initialTotal, fetch }, [index, items.length, total, loading]) const current = items[index] - const atStart = index === 0 - const atEnd = index >= items.length - 1 && items.length >= total useInput((input, key) => { if (input === 'q') { - process.exit(0) + 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)) @@ -92,7 +72,7 @@ export function HistoryBrowser({ items: initialItems, total: initialTotal, fetch } 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}` - try { execSync(`open "${url}"`) } catch {} + openUrl(url) } }) 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 {} +}