pythx-cli 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+
2
+ > pythx@0.0.1 build /Users/zachj/Projects/pythx/packages/pythx
3
+ > tsc
4
+
package/bin/pythx.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/cli.js");
package/dist/app.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ interface AppProps {
2
+ apiUrl?: string;
3
+ }
4
+ export declare function App({ apiUrl }: AppProps): import("react/jsx-runtime").JSX.Element;
5
+ export {};
package/dist/app.js ADDED
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import chalk from "chalk";
5
+ import { Header } from "./components/header.js";
6
+ import { EntityRow, COL } from "./components/entity-row.js";
7
+ import { DetailPanel } from "./components/detail-panel.js";
8
+ import { StatusBar } from "./components/status-bar.js";
9
+ import { useLiveData } from "./hooks/use-live-data.js";
10
+ export function App({ apiUrl }) {
11
+ const { data, loading, error, lastUpdated, refresh } = useLiveData(apiUrl);
12
+ const [activeIndex, setActiveIndex] = useState(0);
13
+ const [showDetail, setShowDetail] = useState(true);
14
+ const { exit } = useApp();
15
+ useInput((input, key) => {
16
+ if (input === "q") {
17
+ exit();
18
+ }
19
+ if (input === "r") {
20
+ refresh();
21
+ }
22
+ if (key.upArrow) {
23
+ setActiveIndex((i) => Math.max(0, i - 1));
24
+ }
25
+ if (key.downArrow) {
26
+ setActiveIndex((i) => Math.min(data.length - 1, i + 1));
27
+ }
28
+ if (key.return) {
29
+ setShowDetail((s) => !s);
30
+ }
31
+ });
32
+ const activeAnalysis = data[activeIndex];
33
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { lastUpdated: lastUpdated, loading: loading }), error && (_jsx(Box, { paddingX: 1, children: _jsx(Text, { children: chalk.red(`Error: ${error}`) }) })), loading && data.length === 0 ? (_jsx(Box, { paddingX: 1, paddingY: 1, children: _jsx(Text, { children: chalk.yellow("⟳ Fetching sentiment data...") }) })) : (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginY: 1, children: _jsx(Text, { children: chalk.dim(" " +
34
+ "ENTITY".padEnd(COL.name) +
35
+ "SCORE".padStart(COL.score) +
36
+ " " +
37
+ "TREND".padEnd(COL.trend) +
38
+ "POS".padStart(COL.stat) +
39
+ "NEU".padStart(COL.stat) +
40
+ "NEG".padStart(COL.stat) +
41
+ "TOT".padStart(COL.stat)) }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: chalk.dim(" " + "─".repeat(COL.name + COL.score + 2 + COL.trend + COL.stat * 4)) }) }), data.map((analysis, i) => (_jsx(EntityRow, { analysis: analysis, isActive: i === activeIndex }, analysis.entity.id)))] })), showDetail && activeAnalysis && (_jsx(DetailPanel, { analysis: activeAnalysis })), _jsx(StatusBar, {})] }));
42
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import meow from "meow";
5
+ import { App } from "./app.js";
6
+ const cli = meow(`
7
+ Usage
8
+ $ pythx
9
+
10
+ Options
11
+ --api-url URL of the Pythx web API (default: direct mode, no server needed)
12
+
13
+ Examples
14
+ $ pythx
15
+ $ pythx --api-url http://localhost:3000
16
+ $ pythx --api-url https://pythx.vercel.app
17
+ `, {
18
+ importMeta: import.meta,
19
+ flags: {
20
+ apiUrl: {
21
+ type: "string",
22
+ },
23
+ },
24
+ });
25
+ // Use alternate screen buffer so re-renders don't scroll the terminal
26
+ process.stdout.write("\x1b[?1049h"); // enter alternate screen
27
+ process.stdout.write("\x1b[H"); // move cursor to top
28
+ const instance = render(_jsx(App, { apiUrl: cli.flags.apiUrl }), { patchConsole: false });
29
+ instance.waitUntilExit().then(() => {
30
+ process.stdout.write("\x1b[?1049l"); // restore main screen on exit
31
+ });
@@ -0,0 +1,6 @@
1
+ import type { EntityAnalysis } from "@pythx/core";
2
+ interface DetailPanelProps {
3
+ analysis: EntityAnalysis;
4
+ }
5
+ export declare function DetailPanel({ analysis }: DetailPanelProps): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -0,0 +1,35 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+ function formatPost(post) {
5
+ const { sentiment } = post;
6
+ const labelMap = {
7
+ positive: chalk.green,
8
+ negative: chalk.red,
9
+ neutral: chalk.gray,
10
+ };
11
+ const colorFn = labelMap[sentiment.label];
12
+ const tag = colorFn(`[${sentiment.label.substring(0, 3).toUpperCase()} ${(sentiment.score * 100).toFixed(0)}%]`);
13
+ const author = post.authorUsername
14
+ ? chalk.dim(`@${post.authorUsername}`)
15
+ : chalk.dim("@unknown");
16
+ const text = post.text
17
+ .replace(/\n/g, " ")
18
+ .substring(0, 70)
19
+ .trim();
20
+ const ellipsis = post.text.length > 70 ? chalk.dim("...") : "";
21
+ return ` ${tag} ${author}: "${text}${ellipsis}"`;
22
+ }
23
+ export function DetailPanel({ analysis }) {
24
+ const { entity, snapshot } = analysis;
25
+ const scoreColor = snapshot.averageScore > 0.05
26
+ ? chalk.green
27
+ : snapshot.averageScore < -0.05
28
+ ? chalk.red
29
+ : chalk.gray;
30
+ const allPosts = [
31
+ ...snapshot.topPositive,
32
+ ...snapshot.topNegative,
33
+ ].sort((a, b) => b.sentiment.score - a.sentiment.score);
34
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [chalk.green("▶"), " ", chalk.bold(entity.name.toUpperCase()), chalk.dim(" — Detail")] }), _jsxs(Text, { children: [chalk.dim("avg: "), scoreColor(`${snapshot.averageScore >= 0 ? "+" : ""}${snapshot.averageScore.toFixed(3)}`)] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: allPosts.length === 0 ? (_jsx(Text, { children: chalk.dim(" No posts to display") })) : (allPosts.slice(0, 8).map((post) => (_jsx(Text, { children: formatPost(post) }, post.id)))) })] }));
35
+ }
@@ -0,0 +1,13 @@
1
+ import type { EntityAnalysis } from "@pythx/core";
2
+ export declare const COL: {
3
+ name: number;
4
+ score: number;
5
+ trend: number;
6
+ stat: number;
7
+ };
8
+ interface EntityRowProps {
9
+ analysis: EntityAnalysis;
10
+ isActive: boolean;
11
+ }
12
+ export declare function EntityRow({ analysis, isActive }: EntityRowProps): import("react/jsx-runtime").JSX.Element;
13
+ export {};
@@ -0,0 +1,47 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+ // Column widths — shared with header in app.tsx
5
+ // cursor(2) + name(15) + score(8) + gap(2) + trend(10) + gap(2) + pos(5) + neu(5) + neg(5) + tot(5)
6
+ export const COL = {
7
+ name: 15,
8
+ score: 8,
9
+ trend: 10,
10
+ stat: 5,
11
+ };
12
+ function formatScore(score) {
13
+ const prefix = score >= 0 ? "+" : "";
14
+ return `${prefix}${score.toFixed(2)}`;
15
+ }
16
+ function sparkline(distribution) {
17
+ const { positive, neutral, negative, total } = distribution;
18
+ if (total === 0)
19
+ return chalk.dim("░".repeat(COL.trend));
20
+ const posWidth = Math.round((positive / total) * COL.trend);
21
+ const negWidth = Math.round((negative / total) * COL.trend);
22
+ const neuWidth = COL.trend - posWidth - negWidth;
23
+ return (chalk.green("█".repeat(posWidth)) +
24
+ chalk.gray("░".repeat(Math.max(0, neuWidth))) +
25
+ chalk.red("█".repeat(negWidth)));
26
+ }
27
+ export function EntityRow({ analysis, isActive }) {
28
+ const { entity, snapshot } = analysis;
29
+ const { distribution } = snapshot;
30
+ const cursor = isActive ? chalk.green("▶ ") : " ";
31
+ const name = entity.name.length > COL.name
32
+ ? entity.name.substring(0, COL.name - 1) + "…"
33
+ : entity.name.padEnd(COL.name);
34
+ const score = formatScore(snapshot.averageScore).padStart(COL.score);
35
+ const trend = sparkline(distribution);
36
+ const pos = String(distribution.positive).padStart(COL.stat);
37
+ const neu = String(distribution.neutral).padStart(COL.stat);
38
+ const neg = String(distribution.negative).padStart(COL.stat);
39
+ const tot = String(distribution.total).padStart(COL.stat);
40
+ const colorName = isActive ? chalk.green.bold(name) : chalk.white(name);
41
+ const colorScore = snapshot.averageScore > 0.05
42
+ ? chalk.green(score)
43
+ : snapshot.averageScore < -0.05
44
+ ? chalk.red(score)
45
+ : chalk.gray(score);
46
+ return (_jsx(Box, { children: _jsxs(Text, { children: [cursor, colorName, colorScore, " ", trend, chalk.green(pos), chalk.gray(neu), chalk.red(neg), chalk.dim(tot)] }) }));
47
+ }
@@ -0,0 +1,6 @@
1
+ interface HeaderProps {
2
+ lastUpdated: Date | null;
3
+ loading: boolean;
4
+ }
5
+ export declare function Header({ lastUpdated, loading }: HeaderProps): import("react/jsx-runtime").JSX.Element;
6
+ export {};
@@ -0,0 +1,17 @@
1
+ import { jsxs as _jsxs, Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState, useEffect } from "react";
3
+ import { Box, Text } from "ink";
4
+ import chalk from "chalk";
5
+ export function Header({ lastUpdated, loading }) {
6
+ const [secondsAgo, setSecondsAgo] = useState(0);
7
+ useEffect(() => {
8
+ if (!lastUpdated)
9
+ return;
10
+ setSecondsAgo(Math.round((Date.now() - lastUpdated.getTime()) / 1000));
11
+ const interval = setInterval(() => {
12
+ setSecondsAgo(Math.round((Date.now() - lastUpdated.getTime()) / 1000));
13
+ }, 1000);
14
+ return () => clearInterval(interval);
15
+ }, [lastUpdated]);
16
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { children: [chalk.green.bold("PYTHX"), chalk.dim(" ░░ "), chalk.white("SENTIMENT TERMINAL")] }), _jsx(Text, { children: loading ? (chalk.yellow("⟳ Loading...")) : lastUpdated ? (_jsxs(_Fragment, { children: [chalk.green("● "), chalk.dim(`LIVE · ${secondsAgo}s ago`)] })) : (chalk.dim("Waiting...")) })] }));
17
+ }
@@ -0,0 +1 @@
1
+ export declare function StatusBar(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+ export function StatusBar() {
5
+ return (_jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, justifyContent: "center", children: _jsxs(Text, { children: [chalk.dim("░ "), chalk.white("q"), chalk.dim(":quit "), chalk.white("↑↓"), chalk.dim(":navigate "), chalk.white("enter"), chalk.dim(":expand "), chalk.white("r"), chalk.dim(":refresh")] }) }));
6
+ }
@@ -0,0 +1,8 @@
1
+ import type { EntityAnalysis } from "@pythx/core";
2
+ export declare function useLiveData(apiUrl?: string): {
3
+ data: EntityAnalysis[];
4
+ loading: boolean;
5
+ error: string | null;
6
+ lastUpdated: Date | null;
7
+ refresh: () => Promise<void>;
8
+ };
@@ -0,0 +1,75 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { DEFAULT_ENTITIES, getProvider, classifyPosts, aggregate, } from "@pythx/core";
3
+ const POLL_INTERVAL = 60_000;
4
+ export function useLiveData(apiUrl) {
5
+ const [data, setData] = useState([]);
6
+ const [loading, setLoading] = useState(true);
7
+ const [error, setError] = useState(null);
8
+ const [lastUpdated, setLastUpdated] = useState(null);
9
+ const fetchViaApi = useCallback(async () => {
10
+ if (!apiUrl)
11
+ return;
12
+ try {
13
+ const res = await fetch(`${apiUrl}/api/compare`, {
14
+ method: "POST",
15
+ headers: { "Content-Type": "application/json" },
16
+ body: JSON.stringify({
17
+ entityIds: DEFAULT_ENTITIES.map((e) => e.id),
18
+ count: 50,
19
+ }),
20
+ });
21
+ const json = (await res.json());
22
+ if (json.success && json.data) {
23
+ setData(json.data);
24
+ setLastUpdated(new Date());
25
+ setError(null);
26
+ }
27
+ else {
28
+ setError(json.error ?? "API error");
29
+ }
30
+ }
31
+ catch (err) {
32
+ setError(err instanceof Error ? err.message : "Fetch failed");
33
+ }
34
+ finally {
35
+ setLoading(false);
36
+ }
37
+ }, [apiUrl]);
38
+ const fetchDirect = useCallback(async () => {
39
+ try {
40
+ const results = [];
41
+ for (const entity of DEFAULT_ENTITIES) {
42
+ const allClassified = [];
43
+ for (const sq of entity.queries) {
44
+ const provider = getProvider(sq.source);
45
+ const posts = await provider.fetchPosts(sq.query, { count: 50 });
46
+ const classified = await classifyPosts(posts, provider.getModelId());
47
+ allClassified.push(...classified);
48
+ }
49
+ const snapshot = aggregate(allClassified);
50
+ results.push({
51
+ entity,
52
+ snapshot,
53
+ posts: allClassified,
54
+ fetchedAt: new Date().toISOString(),
55
+ });
56
+ }
57
+ setData(results);
58
+ setLastUpdated(new Date());
59
+ setError(null);
60
+ }
61
+ catch (err) {
62
+ setError(err instanceof Error ? err.message : "Analysis failed");
63
+ }
64
+ finally {
65
+ setLoading(false);
66
+ }
67
+ }, []);
68
+ const fetchData = apiUrl ? fetchViaApi : fetchDirect;
69
+ useEffect(() => {
70
+ fetchData();
71
+ const interval = setInterval(fetchData, POLL_INTERVAL);
72
+ return () => clearInterval(interval);
73
+ }, [fetchData]);
74
+ return { data, loading, error, lastUpdated, refresh: fetchData };
75
+ }
package/package.json CHANGED
@@ -1,7 +1,30 @@
1
1
  {
2
2
  "name": "pythx-cli",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Real-time sentiment intelligence terminal for prediction markets",
5
+ "type": "module",
6
+ "bin": {
7
+ "pythx": "./bin/pythx.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsx src/cli.tsx",
11
+ "build": "tsc",
12
+ "lint": "tsc --noEmit"
13
+ },
14
+ "dependencies": {
15
+ "@pythx/core": "workspace:*",
16
+ "ink": "^5.2.0",
17
+ "react": "^18.3.1",
18
+ "chalk": "^5.4.0",
19
+ "meow": "^13.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.0.0",
23
+ "@types/react": "^18.3.0",
24
+ "typescript": "^5.8.0",
25
+ "tsx": "^4.19.0",
26
+ "ink-testing-library": "^4.0.0"
27
+ },
5
28
  "keywords": [
6
29
  "sentiment",
7
30
  "terminal",
@@ -11,5 +34,8 @@
11
34
  "x-api"
12
35
  ],
13
36
  "author": "ZachJxyz",
14
- "license": "UNLICENSED"
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/zachjxyz/pythx"
40
+ }
15
41
  }
package/src/app.tsx ADDED
@@ -0,0 +1,95 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import chalk from "chalk";
4
+ import { Header } from "./components/header.js";
5
+ import { EntityRow, COL } from "./components/entity-row.js";
6
+ import { DetailPanel } from "./components/detail-panel.js";
7
+ import { StatusBar } from "./components/status-bar.js";
8
+ import { useLiveData } from "./hooks/use-live-data.js";
9
+
10
+ interface AppProps {
11
+ apiUrl?: string;
12
+ }
13
+
14
+ export function App({ apiUrl }: AppProps) {
15
+ const { data, loading, error, lastUpdated, refresh } = useLiveData(apiUrl);
16
+ const [activeIndex, setActiveIndex] = useState(0);
17
+ const [showDetail, setShowDetail] = useState(true);
18
+ const { exit } = useApp();
19
+
20
+ useInput((input, key) => {
21
+ if (input === "q") {
22
+ exit();
23
+ }
24
+ if (input === "r") {
25
+ refresh();
26
+ }
27
+ if (key.upArrow) {
28
+ setActiveIndex((i) => Math.max(0, i - 1));
29
+ }
30
+ if (key.downArrow) {
31
+ setActiveIndex((i) => Math.min(data.length - 1, i + 1));
32
+ }
33
+ if (key.return) {
34
+ setShowDetail((s) => !s);
35
+ }
36
+ });
37
+
38
+ const activeAnalysis = data[activeIndex];
39
+
40
+ return (
41
+ <Box flexDirection="column">
42
+ <Header lastUpdated={lastUpdated} loading={loading} />
43
+
44
+ {error && (
45
+ <Box paddingX={1}>
46
+ <Text>{chalk.red(`Error: ${error}`)}</Text>
47
+ </Box>
48
+ )}
49
+
50
+ {loading && data.length === 0 ? (
51
+ <Box paddingX={1} paddingY={1}>
52
+ <Text>{chalk.yellow("⟳ Fetching sentiment data...")}</Text>
53
+ </Box>
54
+ ) : (
55
+ <Box flexDirection="column" paddingX={1}>
56
+ <Box marginY={1}>
57
+ <Text>
58
+ {chalk.dim(
59
+ " " +
60
+ "ENTITY".padEnd(COL.name) +
61
+ "SCORE".padStart(COL.score) +
62
+ " " +
63
+ "TREND".padEnd(COL.trend) +
64
+ "POS".padStart(COL.stat) +
65
+ "NEU".padStart(COL.stat) +
66
+ "NEG".padStart(COL.stat) +
67
+ "TOT".padStart(COL.stat)
68
+ )}
69
+ </Text>
70
+ </Box>
71
+
72
+ <Box marginBottom={1}>
73
+ <Text>
74
+ {chalk.dim(" " + "─".repeat(COL.name + COL.score + 2 + COL.trend + COL.stat * 4))}
75
+ </Text>
76
+ </Box>
77
+
78
+ {data.map((analysis, i) => (
79
+ <EntityRow
80
+ key={analysis.entity.id}
81
+ analysis={analysis}
82
+ isActive={i === activeIndex}
83
+ />
84
+ ))}
85
+ </Box>
86
+ )}
87
+
88
+ {showDetail && activeAnalysis && (
89
+ <DetailPanel analysis={activeAnalysis} />
90
+ )}
91
+
92
+ <StatusBar />
93
+ </Box>
94
+ );
95
+ }
package/src/cli.tsx ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import React from "react";
3
+ import { render } from "ink";
4
+ import meow from "meow";
5
+ import { App } from "./app.js";
6
+
7
+ const cli = meow(
8
+ `
9
+ Usage
10
+ $ pythx
11
+
12
+ Options
13
+ --api-url URL of the Pythx web API (default: direct mode, no server needed)
14
+
15
+ Examples
16
+ $ pythx
17
+ $ pythx --api-url http://localhost:3000
18
+ $ pythx --api-url https://pythx.vercel.app
19
+ `,
20
+ {
21
+ importMeta: import.meta,
22
+ flags: {
23
+ apiUrl: {
24
+ type: "string",
25
+ },
26
+ },
27
+ }
28
+ );
29
+
30
+ // Use alternate screen buffer so re-renders don't scroll the terminal
31
+ process.stdout.write("\x1b[?1049h"); // enter alternate screen
32
+ process.stdout.write("\x1b[H"); // move cursor to top
33
+
34
+ const instance = render(<App apiUrl={cli.flags.apiUrl} />, { patchConsole: false });
35
+
36
+ instance.waitUntilExit().then(() => {
37
+ process.stdout.write("\x1b[?1049l"); // restore main screen on exit
38
+ });
@@ -0,0 +1,81 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+ import type { EntityAnalysis, PostWithSentiment } from "@pythx/core";
5
+
6
+ interface DetailPanelProps {
7
+ analysis: EntityAnalysis;
8
+ }
9
+
10
+ function formatPost(post: PostWithSentiment): string {
11
+ const { sentiment } = post;
12
+ const labelMap = {
13
+ positive: chalk.green,
14
+ negative: chalk.red,
15
+ neutral: chalk.gray,
16
+ };
17
+ const colorFn = labelMap[sentiment.label];
18
+ const tag = colorFn(
19
+ `[${sentiment.label.substring(0, 3).toUpperCase()} ${(sentiment.score * 100).toFixed(0)}%]`
20
+ );
21
+
22
+ const author = post.authorUsername
23
+ ? chalk.dim(`@${post.authorUsername}`)
24
+ : chalk.dim("@unknown");
25
+
26
+ const text = post.text
27
+ .replace(/\n/g, " ")
28
+ .substring(0, 70)
29
+ .trim();
30
+
31
+ const ellipsis = post.text.length > 70 ? chalk.dim("...") : "";
32
+
33
+ return ` ${tag} ${author}: "${text}${ellipsis}"`;
34
+ }
35
+
36
+ export function DetailPanel({ analysis }: DetailPanelProps) {
37
+ const { entity, snapshot } = analysis;
38
+ const scoreColor =
39
+ snapshot.averageScore > 0.05
40
+ ? chalk.green
41
+ : snapshot.averageScore < -0.05
42
+ ? chalk.red
43
+ : chalk.gray;
44
+
45
+ const allPosts = [
46
+ ...snapshot.topPositive,
47
+ ...snapshot.topNegative,
48
+ ].sort((a, b) => b.sentiment.score - a.sentiment.score);
49
+
50
+ return (
51
+ <Box
52
+ flexDirection="column"
53
+ borderStyle="single"
54
+ borderColor="gray"
55
+ paddingX={1}
56
+ >
57
+ <Box justifyContent="space-between">
58
+ <Text>
59
+ {chalk.green("▶")} {chalk.bold(entity.name.toUpperCase())}
60
+ {chalk.dim(" — Detail")}
61
+ </Text>
62
+ <Text>
63
+ {chalk.dim("avg: ")}
64
+ {scoreColor(
65
+ `${snapshot.averageScore >= 0 ? "+" : ""}${snapshot.averageScore.toFixed(3)}`
66
+ )}
67
+ </Text>
68
+ </Box>
69
+
70
+ <Box marginTop={1} flexDirection="column">
71
+ {allPosts.length === 0 ? (
72
+ <Text>{chalk.dim(" No posts to display")}</Text>
73
+ ) : (
74
+ allPosts.slice(0, 8).map((post) => (
75
+ <Text key={post.id}>{formatPost(post)}</Text>
76
+ ))
77
+ )}
78
+ </Box>
79
+ </Box>
80
+ );
81
+ }
@@ -0,0 +1,83 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+ import type { EntityAnalysis } from "@pythx/core";
5
+
6
+ // Column widths — shared with header in app.tsx
7
+ // cursor(2) + name(15) + score(8) + gap(2) + trend(10) + gap(2) + pos(5) + neu(5) + neg(5) + tot(5)
8
+ export const COL = {
9
+ name: 15,
10
+ score: 8,
11
+ trend: 10,
12
+ stat: 5,
13
+ };
14
+
15
+ interface EntityRowProps {
16
+ analysis: EntityAnalysis;
17
+ isActive: boolean;
18
+ }
19
+
20
+ function formatScore(score: number): string {
21
+ const prefix = score >= 0 ? "+" : "";
22
+ return `${prefix}${score.toFixed(2)}`;
23
+ }
24
+
25
+ function sparkline(distribution: {
26
+ positive: number;
27
+ neutral: number;
28
+ negative: number;
29
+ total: number;
30
+ }): string {
31
+ const { positive, neutral, negative, total } = distribution;
32
+ if (total === 0) return chalk.dim("░".repeat(COL.trend));
33
+
34
+ const posWidth = Math.round((positive / total) * COL.trend);
35
+ const negWidth = Math.round((negative / total) * COL.trend);
36
+ const neuWidth = COL.trend - posWidth - negWidth;
37
+
38
+ return (
39
+ chalk.green("█".repeat(posWidth)) +
40
+ chalk.gray("░".repeat(Math.max(0, neuWidth))) +
41
+ chalk.red("█".repeat(negWidth))
42
+ );
43
+ }
44
+
45
+ export function EntityRow({ analysis, isActive }: EntityRowProps) {
46
+ const { entity, snapshot } = analysis;
47
+ const { distribution } = snapshot;
48
+
49
+ const cursor = isActive ? chalk.green("▶ ") : " ";
50
+ const name = entity.name.length > COL.name
51
+ ? entity.name.substring(0, COL.name - 1) + "…"
52
+ : entity.name.padEnd(COL.name);
53
+ const score = formatScore(snapshot.averageScore).padStart(COL.score);
54
+ const trend = sparkline(distribution);
55
+ const pos = String(distribution.positive).padStart(COL.stat);
56
+ const neu = String(distribution.neutral).padStart(COL.stat);
57
+ const neg = String(distribution.negative).padStart(COL.stat);
58
+ const tot = String(distribution.total).padStart(COL.stat);
59
+
60
+ const colorName = isActive ? chalk.green.bold(name) : chalk.white(name);
61
+ const colorScore =
62
+ snapshot.averageScore > 0.05
63
+ ? chalk.green(score)
64
+ : snapshot.averageScore < -0.05
65
+ ? chalk.red(score)
66
+ : chalk.gray(score);
67
+
68
+ return (
69
+ <Box>
70
+ <Text>
71
+ {cursor}
72
+ {colorName}
73
+ {colorScore}
74
+ {" "}
75
+ {trend}
76
+ {chalk.green(pos)}
77
+ {chalk.gray(neu)}
78
+ {chalk.red(neg)}
79
+ {chalk.dim(tot)}
80
+ </Text>
81
+ </Box>
82
+ );
83
+ }
@@ -0,0 +1,48 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+
5
+ interface HeaderProps {
6
+ lastUpdated: Date | null;
7
+ loading: boolean;
8
+ }
9
+
10
+ export function Header({ lastUpdated, loading }: HeaderProps) {
11
+ const [secondsAgo, setSecondsAgo] = useState(0);
12
+
13
+ useEffect(() => {
14
+ if (!lastUpdated) return;
15
+ setSecondsAgo(Math.round((Date.now() - lastUpdated.getTime()) / 1000));
16
+ const interval = setInterval(() => {
17
+ setSecondsAgo(Math.round((Date.now() - lastUpdated.getTime()) / 1000));
18
+ }, 1000);
19
+ return () => clearInterval(interval);
20
+ }, [lastUpdated]);
21
+
22
+ return (
23
+ <Box
24
+ borderStyle="single"
25
+ borderColor="green"
26
+ paddingX={1}
27
+ justifyContent="space-between"
28
+ >
29
+ <Text>
30
+ {chalk.green.bold("PYTHX")}
31
+ {chalk.dim(" ░░ ")}
32
+ {chalk.white("SENTIMENT TERMINAL")}
33
+ </Text>
34
+ <Text>
35
+ {loading ? (
36
+ chalk.yellow("⟳ Loading...")
37
+ ) : lastUpdated ? (
38
+ <>
39
+ {chalk.green("● ")}
40
+ {chalk.dim(`LIVE · ${secondsAgo}s ago`)}
41
+ </>
42
+ ) : (
43
+ chalk.dim("Waiting...")
44
+ )}
45
+ </Text>
46
+ </Box>
47
+ );
48
+ }
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import chalk from "chalk";
4
+
5
+ export function StatusBar() {
6
+ return (
7
+ <Box
8
+ borderStyle="single"
9
+ borderColor="gray"
10
+ paddingX={1}
11
+ justifyContent="center"
12
+ >
13
+ <Text>
14
+ {chalk.dim("░ ")}
15
+ {chalk.white("q")}
16
+ {chalk.dim(":quit ")}
17
+ {chalk.white("↑↓")}
18
+ {chalk.dim(":navigate ")}
19
+ {chalk.white("enter")}
20
+ {chalk.dim(":expand ")}
21
+ {chalk.white("r")}
22
+ {chalk.dim(":refresh")}
23
+ </Text>
24
+ </Box>
25
+ );
26
+ }
@@ -0,0 +1,86 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import {
3
+ DEFAULT_ENTITIES,
4
+ getProvider,
5
+ classifyPosts,
6
+ aggregate,
7
+ } from "@pythx/core";
8
+ import type { EntityAnalysis } from "@pythx/core";
9
+
10
+ const POLL_INTERVAL = 60_000;
11
+
12
+ export function useLiveData(apiUrl?: string) {
13
+ const [data, setData] = useState<EntityAnalysis[]>([]);
14
+ const [loading, setLoading] = useState(true);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
17
+
18
+ const fetchViaApi = useCallback(async () => {
19
+ if (!apiUrl) return;
20
+ try {
21
+ const res = await fetch(`${apiUrl}/api/compare`, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({
25
+ entityIds: DEFAULT_ENTITIES.map((e) => e.id),
26
+ count: 50,
27
+ }),
28
+ });
29
+ const json = (await res.json()) as { success: boolean; data?: EntityAnalysis[]; error?: string };
30
+ if (json.success && json.data) {
31
+ setData(json.data);
32
+ setLastUpdated(new Date());
33
+ setError(null);
34
+ } else {
35
+ setError(json.error ?? "API error");
36
+ }
37
+ } catch (err) {
38
+ setError(err instanceof Error ? err.message : "Fetch failed");
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ }, [apiUrl]);
43
+
44
+ const fetchDirect = useCallback(async () => {
45
+ try {
46
+ const results: EntityAnalysis[] = [];
47
+
48
+ for (const entity of DEFAULT_ENTITIES) {
49
+ const allClassified = [];
50
+
51
+ for (const sq of entity.queries) {
52
+ const provider = getProvider(sq.source);
53
+ const posts = await provider.fetchPosts(sq.query, { count: 50 });
54
+ const classified = await classifyPosts(posts, provider.getModelId());
55
+ allClassified.push(...classified);
56
+ }
57
+
58
+ const snapshot = aggregate(allClassified);
59
+ results.push({
60
+ entity,
61
+ snapshot,
62
+ posts: allClassified,
63
+ fetchedAt: new Date().toISOString(),
64
+ });
65
+ }
66
+
67
+ setData(results);
68
+ setLastUpdated(new Date());
69
+ setError(null);
70
+ } catch (err) {
71
+ setError(err instanceof Error ? err.message : "Analysis failed");
72
+ } finally {
73
+ setLoading(false);
74
+ }
75
+ }, []);
76
+
77
+ const fetchData = apiUrl ? fetchViaApi : fetchDirect;
78
+
79
+ useEffect(() => {
80
+ fetchData();
81
+ const interval = setInterval(fetchData, POLL_INTERVAL);
82
+ return () => clearInterval(interval);
83
+ }, [fetchData]);
84
+
85
+ return { data, loading, error, lastUpdated, refresh: fetchData };
86
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "lib": ["ES2022"],
6
+ "types": ["node"],
7
+ "declaration": true,
8
+ "outDir": "./dist",
9
+ "rootDir": "./src"
10
+ },
11
+ "include": ["src"],
12
+ "exclude": ["node_modules", "dist"]
13
+ }
package/README.md DELETED
@@ -1,5 +0,0 @@
1
- # pythx
2
-
3
- Real-time sentiment intelligence terminal.
4
-
5
- Coming soon.