-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add sonar history command #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof options> } | ||
|
|
||
| // 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<string, string> = { | ||
| 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<HistoryItem[] | null>(null) | ||
| const [total, setTotal] = useState(0) | ||
| const [error, setError] = useState<string | null>(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<string, unknown> = { 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 <Text color="red">Error: {error}</Text> | ||
| if (!items) return <Spinner label="Loading history..." /> | ||
|
|
||
| if (items.length === 0) { | ||
| return ( | ||
| <Box flexDirection="column" gap={1}> | ||
| <Text color="yellow">No history yet.</Text> | ||
| <Text dimColor>Triage some suggestions first with <Text color="cyan">sonar</Text></Text> | ||
| </Box> | ||
| ) | ||
| } | ||
|
|
||
| if (flags.interactive) { | ||
| const pageSize = flags.limit ?? 20 | ||
| const fetchMore = async (offset: number): Promise<HistoryItem[]> => { | ||
| const vars: Record<string, unknown> = { 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 <HistoryBrowser items={items} total={total} fetchMore={fetchMore} /> | ||
| } | ||
|
|
||
| // 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 ( | ||
| <Box flexDirection="column"> | ||
| <Box marginBottom={1}> | ||
| <Text bold color="white">History</Text> | ||
| <Text dimColor> · {statusLabel} ({items.length})</Text> | ||
| </Box> | ||
| <FeedTable data={tableData} /> | ||
| </Box> | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <Box flexDirection="column"> | ||
| <Box flexDirection="column" marginBottom={1}> | ||
| <Box> | ||
| <Text bold color="white">History</Text> | ||
| <Text dimColor> · {statusLabel}</Text> | ||
| <Text dimColor> ({items.length})</Text> | ||
| </Box> | ||
| <Text dimColor>{'─'.repeat(Math.min(termWidth - 2, 72))}</Text> | ||
| </Box> | ||
|
|
||
| <Box flexDirection="column"> | ||
| {items.map((item, i) => ( | ||
| <Box key={item.key} flexDirection="column"> | ||
| <TweetCard | ||
| item={{ score: item.score, matchedKeywords: item.matchedKeywords, tweet: item.tweet }} | ||
| termWidth={termWidth} | ||
| cardWidth={cardWidth} | ||
| isLast={i === items.length - 1} | ||
| /> | ||
| <Box marginLeft={2} marginBottom={i === items.length - 1 ? 0 : 1}> | ||
| <Text dimColor> | ||
| status: <Text color={item.status === 'ARCHIVED' ? 'gray' : item.status === 'LATER' ? 'yellow' : item.status === 'SKIPPED' ? 'red' : 'blue'}>{item.status.toLowerCase()}</Text> | ||
| </Text> | ||
|
Comment on lines
+215
to
+218
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This ternary never handles 🤖 Prompt for AI Agents |
||
| </Box> | ||
| </Box> | ||
| ))} | ||
| </Box> | ||
| </Box> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HistoryItem[]> | ||
| } | ||
|
|
||
| const STATUS_COLORS: Record<string, string> = { | ||
| ARCHIVED: 'gray', | ||
| LATER: 'yellow', | ||
| READ: 'blue', | ||
| SKIPPED: 'red', | ||
| REPLIED: 'green', | ||
| } | ||
|
|
||
| function statusLabel(status: string): string { | ||
| return status.toLowerCase() | ||
| } | ||
|
|
||
| function Divider({ width }: { width: number }) { | ||
| return <Text dimColor>{'─'.repeat(Math.min(width - 2, 72))}</Text> | ||
| } | ||
|
|
||
| export function HistoryBrowser({ items: initialItems, total: initialTotal, fetchMore }: HistoryBrowserProps) { | ||
| const { exit } = useApp() | ||
| const { stdout } = useStdout() | ||
| const termWidth = stdout.columns ?? 100 | ||
| const cardWidth = getFeedWidth() | ||
|
Comment on lines
+34
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interactive mode drops the
🤖 Prompt for AI Agents |
||
|
|
||
| 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]) | ||
|
Comment on lines
+45
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The pagination cursor is based on filtered item count.
🤖 Prompt for AI Agents |
||
|
|
||
| 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) | ||
|
Comment on lines
+73
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle missing usernames when building X URLs. Line 73 falls back to 🔧 Proposed fix- const handle = current.tweet.user.username ?? current.tweet.user.displayName
- const url = `https://x.com/${handle}/status/${current.tweet.id}`
+ const username = current.tweet.user.username?.trim()
+ const tweetId = encodeURIComponent(current.tweet.id)
+ const url = username
+ ? `https://x.com/${encodeURIComponent(username)}/status/${tweetId}`
+ : `https://x.com/i/web/status/${tweetId}`
openUrl(url)🤖 Prompt for AI Agents |
||
| } | ||
| }) | ||
|
|
||
| if (!current) { | ||
| return ( | ||
| <Box flexDirection="column" gap={1} marginTop={1}> | ||
| <Text color="yellow">No history items found.</Text> | ||
| <Text dimColor>Triage some suggestions first with <Text color="cyan">sonar</Text></Text> | ||
| </Box> | ||
| ) | ||
| } | ||
|
|
||
| const statusColor = STATUS_COLORS[current.status] ?? 'white' | ||
|
|
||
| return ( | ||
| <Box flexDirection="column"> | ||
| <Box marginBottom={1} gap={3}> | ||
| <Text dimColor>{index + 1} / {total}</Text> | ||
| <Text color={statusColor}>{statusLabel(current.status)}</Text> | ||
| </Box> | ||
|
|
||
| <TweetCard | ||
| item={{ score: current.score, matchedKeywords: current.matchedKeywords, tweet: current.tweet }} | ||
| termWidth={termWidth} | ||
| cardWidth={cardWidth} | ||
| isLast={true} | ||
| /> | ||
|
|
||
| <Box flexDirection="column" marginTop={1}> | ||
| <Divider width={termWidth} /> | ||
| <Box marginTop={1} gap={3}> | ||
| <Text dimColor><Text color="white">b</Text> back</Text> | ||
| <Text dimColor><Text color="white">n</Text> next</Text> | ||
| <Text dimColor><Text color="white">o</Text> open</Text> | ||
| <Text dimColor><Text color="white">q</Text> quit</Text> | ||
| </Box> | ||
| </Box> | ||
| </Box> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 {} | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n src/commands/history.tsx | head -80Repository: 1a35e1/sonar-cli
Length of output: 3073
Constrain
--statuswith Zod enum to match the advertised values.The
statusoption currently accepts any string value via.string().optional(). Invalid values like--status fooare accepted locally, thenresolveStatus()uppercases them toFOO, and the request fails later when GraphQL rejects the unknown enum value. Since the help text advertises a fixed set of values (archived|saved|read|skipped|replied), constrain this option withzod.enum()to fail fast at the CLI layer.The
resolveStatus()function at lines 74-78 should also be updated to work only with the constrained enum values from the Zod schema.🤖 Prompt for AI Agents