From cf78fce64633a9fefed3220afdc81af2d8fe6208 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Tue, 6 Jan 2026 17:19:49 +0800 Subject: [PATCH 1/6] feat: improve text output with article preview, quote tweets, and media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhance the text output format to show more useful information: - Article tweets: Show 📰 indicator with title + preview text in feeds, full body when reading single tweets - Quote tweets: Display quoted content with ┌─ QT @user formatting - Media: Show 🖼️/🎬/🔄 indicators with URLs for photos/videos/GIFs Also refactors single tweet read to use shared printTweets function for consistent formatting. --- src/cli/shared.ts | 40 ++++++++++++++++++++++++++++++++- src/commands/read.ts | 6 +---- src/lib/twitter-client-types.ts | 5 +++++ src/lib/twitter-client-utils.ts | 25 +++++++++++++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 806de34..475aefd 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -320,7 +320,45 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn } for (const tweet of tweets) { console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); - console.log(tweet.text); + + // Display tweet text, with article indicator if present + if (tweet.article) { + // For articles: show preview if text is just the title (home timeline), + // otherwise show full text (single tweet detail) + const isFullBody = tweet.text.length > tweet.article.title.length + 50; + if (isFullBody) { + console.log(`📰 ${tweet.text}`); + } else { + console.log(`📰 ${tweet.article.title}`); + if (tweet.article.previewText) { + console.log(` ${tweet.article.previewText}`); + } + } + } else { + console.log(tweet.text); + } + + // Display media attachments + if (tweet.media && tweet.media.length > 0) { + for (const m of tweet.media) { + const label = m.type === 'video' ? '🎬' : m.type === 'animated_gif' ? '🔄' : '🖼️'; + console.log(`${label} ${m.url}`); + } + } + + // Display quoted tweet + if (tweet.quotedTweet) { + console.log(`┌─ QT @${tweet.quotedTweet.author.username}:`); + const qtText = tweet.quotedTweet.article ? `📰 ${tweet.quotedTweet.article.title}` : tweet.quotedTweet.text; + // Indent and truncate quoted tweet text + const maxLen = 280; + const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText; + for (const line of truncated.split('\n').slice(0, 4)) { + console.log(`│ ${line}`); + } + console.log(`└─ https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`); + } + if (tweet.createdAt) { console.log(`${l('date')}${tweet.createdAt}`); } diff --git a/src/commands/read.ts b/src/commands/read.ts index 3a1056d..5a5ebb6 100644 --- a/src/commands/read.ts +++ b/src/commands/read.ts @@ -36,11 +36,7 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { if (cmdOpts.json || cmdOpts.jsonFull) { console.log(JSON.stringify(result.tweet, null, 2)); } else { - console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); - console.log(result.tweet.text); - if (result.tweet.createdAt) { - console.log(`\n${ctx.l('date')}${result.tweet.createdAt}`); - } + ctx.printTweets([result.tweet], { showSeparator: false }); console.log(formatStatsLine(result.tweet, ctx.getOutput())); } } else { diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index b0118a7..dca792c 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -236,6 +236,11 @@ export interface TweetData { quotedTweet?: TweetData; // Media attachments (photos, videos, GIFs) media?: TweetMedia[]; + // Article metadata (for Twitter Articles / long-form posts) + article?: { + title: string; + previewText?: string; + }; // Raw GraphQL tweet result (only when includeRaw is enabled) _raw?: GraphqlTweetResult; } diff --git a/src/lib/twitter-client-utils.ts b/src/lib/twitter-client-utils.ts index 7730396..6ca6275 100644 --- a/src/lib/twitter-client-utils.ts +++ b/src/lib/twitter-client-utils.ts @@ -153,6 +153,29 @@ export function extractTweetText(result: GraphqlTweetResult | undefined): string return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text); } +export function extractArticleMetadata( + result: GraphqlTweetResult | undefined, +): { title: string; previewText?: string } | undefined { + const article = result?.article; + if (!article) { + return undefined; + } + + const articleResult = article.article_results?.result ?? article; + const title = firstText(articleResult.title, article.title); + if (!title) { + return undefined; + } + + // preview_text is available in home timeline responses + const previewText = firstText( + (articleResult as { preview_text?: string }).preview_text, + (article as { preview_text?: string }).preview_text, + ); + + return { title, previewText }; +} + export function extractMedia(result: GraphqlTweetResult | undefined): TweetMedia[] | undefined { // Prefer extended_entities (has video info), fall back to entities const rawMedia = result?.legacy?.extended_entities?.media ?? result?.legacy?.entities?.media; @@ -261,6 +284,7 @@ export function mapTweetResult( } const media = extractMedia(result); + const article = extractArticleMetadata(result); const tweetData: TweetData = { id: result.rest_id, @@ -278,6 +302,7 @@ export function mapTweetResult( authorId: userId, quotedTweet, media, + article, }; if (includeRaw) { From 0475a2e1b699624a272e7f3c332913f80a53a55f Mon Sep 17 00:00:00 2001 From: George Zhang Date: Tue, 6 Jan 2026 17:27:16 +0800 Subject: [PATCH 2/6] test: add tests for article metadata extraction - extractArticleMetadata: 6 tests for title/preview extraction - mapTweetResult: 3 tests for article field inclusion --- tests/extract-article-metadata.test.ts | 88 ++++++++++++++++++++++++++ tests/map-tweet-article.test.ts | 73 +++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 tests/extract-article-metadata.test.ts create mode 100644 tests/map-tweet-article.test.ts diff --git a/tests/extract-article-metadata.test.ts b/tests/extract-article-metadata.test.ts new file mode 100644 index 0000000..8bbb476 --- /dev/null +++ b/tests/extract-article-metadata.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js'; +import { extractArticleMetadata } from '../src/lib/twitter-client-utils.js'; + +describe('extractArticleMetadata', () => { + it('returns undefined when no result', () => { + expect(extractArticleMetadata(undefined)).toBeUndefined(); + }); + + it('returns undefined when no article field', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + legacy: { full_text: 'Hello world' }, + }; + expect(extractArticleMetadata(result)).toBeUndefined(); + }); + + it('returns undefined when article has no title', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + article: { + article_results: { + result: {}, + }, + }, + }; + expect(extractArticleMetadata(result)).toBeUndefined(); + }); + + it('extracts title from article_results.result', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + article: { + article_results: { + result: { + title: 'Building Context Graphs for GTM', + }, + }, + }, + }; + + const metadata = extractArticleMetadata(result); + expect(metadata).toEqual({ + title: 'Building Context Graphs for GTM', + previewText: undefined, + }); + }); + + it('extracts title and preview_text from article_results.result', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + article: { + article_results: { + result: { + title: 'Building Context Graphs for GTM', + preview_text: + 'Foundation Capital recently argued that one of the next trillion-dollar opportunities in AI will come from context graphs.', + }, + }, + }, + } as GraphqlTweetResult; + + const metadata = extractArticleMetadata(result); + expect(metadata).toEqual({ + title: 'Building Context Graphs for GTM', + previewText: + 'Foundation Capital recently argued that one of the next trillion-dollar opportunities in AI will come from context graphs.', + }); + }); + + it('falls back to article.title when article_results.result.title missing', () => { + const result: GraphqlTweetResult = { + rest_id: '123', + article: { + title: 'Fallback Title', + article_results: { + result: {}, + }, + }, + }; + + const metadata = extractArticleMetadata(result); + expect(metadata).toEqual({ + title: 'Fallback Title', + previewText: undefined, + }); + }); +}); diff --git a/tests/map-tweet-article.test.ts b/tests/map-tweet-article.test.ts new file mode 100644 index 0000000..f20d3aa --- /dev/null +++ b/tests/map-tweet-article.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js'; +import { mapTweetResult } from '../src/lib/twitter-client-utils.js'; + +describe('mapTweetResult article', () => { + const makeUserResult = () => ({ + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'alice', name: 'Alice' }, + }, + }, + }, + }); + + it('includes extracted article metadata on mapped tweet', () => { + const result: GraphqlTweetResult = { + rest_id: '1', + legacy: { + full_text: 'Check out my article', + }, + article: { + article_results: { + result: { + title: 'Building Context Graphs for GTM', + preview_text: 'Foundation Capital recently argued...', + }, + }, + }, + ...makeUserResult(), + } as GraphqlTweetResult; + + const mapped = mapTweetResult(result, 0); + expect(mapped).toBeDefined(); + expect(mapped?.article).toEqual({ + title: 'Building Context Graphs for GTM', + previewText: 'Foundation Capital recently argued...', + }); + }); + + it('omits article when none present', () => { + const result: GraphqlTweetResult = { + rest_id: '2', + legacy: { full_text: 'no article' }, + ...makeUserResult(), + }; + + const mapped = mapTweetResult(result, 0); + expect(mapped?.article).toBeUndefined(); + }); + + it('includes article with only title when no preview_text', () => { + const result: GraphqlTweetResult = { + rest_id: '3', + legacy: { full_text: 'article link' }, + article: { + article_results: { + result: { + title: 'My Article Title', + }, + }, + }, + ...makeUserResult(), + }; + + const mapped = mapTweetResult(result, 0); + expect(mapped?.article).toEqual({ + title: 'My Article Title', + previewText: undefined, + }); + }); +}); From d40c0483335ef8e26613930fd315d793387c2fc9 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Tue, 6 Jan 2026 17:58:12 +0800 Subject: [PATCH 3/6] fix: improve article detection and quote tweet display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use startsWith check instead of fragile length heuristic for detecting full article body vs preview mode - Add media display for quote tweets (🖼️/🎬/🔄 indicators) - Add preview_text to GraphqlTweetResult type definition to avoid type assertions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/cli/shared.ts | 15 +++++++++++---- src/lib/twitter-client-types.ts | 3 +++ src/lib/twitter-client-utils.ts | 5 +---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 475aefd..3e91d8b 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -323,10 +323,10 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn // Display tweet text, with article indicator if present if (tweet.article) { - // For articles: show preview if text is just the title (home timeline), - // otherwise show full text (single tweet detail) - const isFullBody = tweet.text.length > tweet.article.title.length + 50; - if (isFullBody) { + // Full body mode: text starts with article title (from extractArticleText) + // Preview mode: text is short tweet intro that doesn't start with title + const hasFullBody = tweet.text.startsWith(tweet.article.title); + if (hasFullBody) { console.log(`📰 ${tweet.text}`); } else { console.log(`📰 ${tweet.article.title}`); @@ -356,6 +356,13 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn for (const line of truncated.split('\n').slice(0, 4)) { console.log(`│ ${line}`); } + // Display quoted tweet media + if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) { + for (const m of tweet.quotedTweet.media) { + const label = m.type === 'video' ? '🎬' : m.type === 'animated_gif' ? '🔄' : '🖼️'; + console.log(`│ ${label} ${m.url}`); + } + } console.log(`└─ https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`); } diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index dca792c..01c02e8 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -80,9 +80,12 @@ export type GraphqlTweetResult = { }; }; article?: { + title?: string; + preview_text?: string; article_results?: { result?: { title?: string; + preview_text?: string; plain_text?: string; text?: string; richtext?: { diff --git a/src/lib/twitter-client-utils.ts b/src/lib/twitter-client-utils.ts index 6ca6275..daf049a 100644 --- a/src/lib/twitter-client-utils.ts +++ b/src/lib/twitter-client-utils.ts @@ -168,10 +168,7 @@ export function extractArticleMetadata( } // preview_text is available in home timeline responses - const previewText = firstText( - (articleResult as { preview_text?: string }).preview_text, - (article as { preview_text?: string }).preview_text, - ); + const previewText = firstText(articleResult.preview_text, article.preview_text); return { title, previewText }; } From a48aa6db88a2924be6125ce1520e222bcc48f3e3 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Tue, 6 Jan 2026 18:14:10 +0800 Subject: [PATCH 4/6] fix: remove duplicate title field in GraphqlTweetResult article type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The article type had title defined twice (at lines 83 and 135), causing TypeScript compilation to fail with TS2300: Duplicate identifier 'title'. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/lib/twitter-client-types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index 01c02e8..c27c80c 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -132,7 +132,6 @@ export type GraphqlTweetResult = { }>; }; }; - title?: string; plain_text?: string; text?: string; richtext?: { From 15aa24d2b10d72dbc888594ee07381a4e519205a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 23:31:26 +0000 Subject: [PATCH 5/6] fix: honor output flags in text rendering --- src/cli/shared.ts | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/cli/shared.ts b/src/cli/shared.ts index 3e91d8b..3f6bc3d 100644 --- a/src/cli/shared.ts +++ b/src/cli/shared.ts @@ -318,6 +318,16 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn console.log(opts.emptyMessage ?? 'No tweets found.'); return; } + const useEmoji = output.emoji && !output.plain; + const articleLabel = useEmoji ? '📰' : 'Article:'; + const mediaLabel = (type: 'photo' | 'video' | 'animated_gif'): string => { + if (useEmoji) { + return type === 'video' ? '🎬' : type === 'animated_gif' ? '🔄' : '🖼️'; + } + return type === 'video' ? 'VIDEO:' : type === 'animated_gif' ? 'GIF:' : 'PHOTO:'; + }; + const quotePrefix = useEmoji ? { top: '┌─', mid: '│ ', bot: '└─' } : { top: '> ', mid: '> ', bot: '> ' }; + for (const tweet of tweets) { console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); @@ -327,9 +337,9 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn // Preview mode: text is short tweet intro that doesn't start with title const hasFullBody = tweet.text.startsWith(tweet.article.title); if (hasFullBody) { - console.log(`📰 ${tweet.text}`); + console.log(`${articleLabel} ${tweet.text}`); } else { - console.log(`📰 ${tweet.article.title}`); + console.log(`${articleLabel} ${tweet.article.title}`); if (tweet.article.previewText) { console.log(` ${tweet.article.previewText}`); } @@ -341,29 +351,31 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn // Display media attachments if (tweet.media && tweet.media.length > 0) { for (const m of tweet.media) { - const label = m.type === 'video' ? '🎬' : m.type === 'animated_gif' ? '🔄' : '🖼️'; - console.log(`${label} ${m.url}`); + console.log(`${mediaLabel(m.type)} ${m.url}`); } } // Display quoted tweet if (tweet.quotedTweet) { - console.log(`┌─ QT @${tweet.quotedTweet.author.username}:`); - const qtText = tweet.quotedTweet.article ? `📰 ${tweet.quotedTweet.article.title}` : tweet.quotedTweet.text; + console.log(`${quotePrefix.top} QT @${tweet.quotedTweet.author.username}:`); + const qtText = tweet.quotedTweet.article + ? `${articleLabel} ${tweet.quotedTweet.article.title}` + : tweet.quotedTweet.text; // Indent and truncate quoted tweet text const maxLen = 280; const truncated = qtText.length > maxLen ? `${qtText.slice(0, maxLen)}...` : qtText; for (const line of truncated.split('\n').slice(0, 4)) { - console.log(`│ ${line}`); + console.log(`${quotePrefix.mid}${line}`); } // Display quoted tweet media if (tweet.quotedTweet.media && tweet.quotedTweet.media.length > 0) { for (const m of tweet.quotedTweet.media) { - const label = m.type === 'video' ? '🎬' : m.type === 'animated_gif' ? '🔄' : '🖼️'; - console.log(`│ ${label} ${m.url}`); + console.log(`${quotePrefix.mid}${mediaLabel(m.type)} ${m.url}`); } } - console.log(`└─ https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`); + console.log( + `${quotePrefix.bot} https://x.com/${tweet.quotedTweet.author.username}/status/${tweet.quotedTweet.id}`, + ); } if (tweet.createdAt) { From ba22d86f70d88a5bf49be9e74d7d005c4efd6a8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 23:35:35 +0000 Subject: [PATCH 6/6] docs: changelog for text output (#32) (thanks @odysseus0) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index df5755f..508437e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. - `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0. +- Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0. ### Fixed - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.