pythx-cli 0.0.1 → 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.
- package/.turbo/turbo-build.log +4 -0
- package/bin/pythx.js +2 -0
- package/dist/app.d.ts +5 -0
- package/dist/app.js +42 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +31 -0
- package/dist/components/detail-panel.d.ts +6 -0
- package/dist/components/detail-panel.js +35 -0
- package/dist/components/entity-row.d.ts +13 -0
- package/dist/components/entity-row.js +47 -0
- package/dist/components/header.d.ts +6 -0
- package/dist/components/header.js +17 -0
- package/dist/components/status-bar.d.ts +1 -0
- package/dist/components/status-bar.js +6 -0
- package/dist/hooks/use-live-data.d.ts +8 -0
- package/dist/hooks/use-live-data.js +75 -0
- package/package.json +29 -3
- package/src/app.tsx +95 -0
- package/src/cli.tsx +38 -0
- package/src/components/detail-panel.tsx +81 -0
- package/src/components/entity-row.tsx +83 -0
- package/src/components/header.tsx +48 -0
- package/src/components/status-bar.tsx +26 -0
- package/src/hooks/use-live-data.ts +86 -0
- package/tsconfig.json +13 -0
- package/README.md +0 -5
package/bin/pythx.js
ADDED
package/dist/app.d.ts
ADDED
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
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,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,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,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.
|
|
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",
|
|
@@ -10,6 +33,9 @@
|
|
|
10
33
|
"sentiment-analysis",
|
|
11
34
|
"x-api"
|
|
12
35
|
],
|
|
13
|
-
"author": "
|
|
14
|
-
"
|
|
36
|
+
"author": "ZachJxyz",
|
|
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
|
+
}
|