Skip to content

Commit 5a5f567

Browse files
mega123-artclaude
andcommitted
Port webview marketplace features to CLI for full parity (#93)
CLI's Ink marketplace (surfaces/cli/src/views/SkillMarket.tsx) already fetched agent/skill data via marketplaceEnv but rendered a fraction of it. Split the render layer into surfaces/cli/src/views/market/ and add: agent tier badge+gauge+ladder, earned SOL, verified GitHub repos, blog carousel, full (scrollable) comment stacks, required-skills checkmarks+prices+collect-all, full SKILL.md via an in-view scroll viewport, dispose/re-equip, firing pulse, hide-owned filter, agent wallet search, Helius RPC status+settings, live 3-phase publish progress, and a publish image field + self blog composer. No webview/vscode/core changes — marketplaceEnv already returned everything needed; only the CLI's local MarketApi type was narrower than the real object. Two items from the issue's gap table were dropped after checking the actual protocol/webview source: postNote (skill comments) and publishSkill never had image/githubLink params in the wire protocol, and the webview itself doesn't expose them either — so there was no feature to port. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6e18e10 commit 5a5f567

7 files changed

Lines changed: 857 additions & 125 deletions

File tree

surfaces/cli/src/views/SkillMarket.tsx

Lines changed: 341 additions & 125 deletions
Large diffs are not rendered by default.
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Agent profile — full parity with surfaces/webview/src/market/AgentProfileView.tsx:
2+
// tier tag + gauge + ladder, earned SOL, verified GitHub repos, blog carousel, full
3+
// comment stack, buy-all with count feedback, self-only "write a blog post" entry.
4+
import React from "react";
5+
import { Box, Text } from "ink";
6+
import type { AgentProfile, SkillCard, Note } from "@iqlabs-official/agent-sdk";
7+
import { colors, glyph } from "../../theme.js";
8+
import { tierInfo, tierGauge, repoGauge, STAR_TIERS } from "./tiers.js";
9+
import { ScrollView } from "./ScrollView.js";
10+
11+
const short = (w: string) => `${w.slice(0, 4)}${w.slice(-4)}`;
12+
13+
function earnedSol(totalEarned?: string): string {
14+
const lamports = totalEarned ? Number(totalEarned) : 0;
15+
const solVal = lamports / 1e9;
16+
return (solVal >= 100 ? solVal.toFixed(0) : solVal.toFixed(2)) + "◎";
17+
}
18+
19+
function noteDate(ts: number): string {
20+
return new Date(ts).toLocaleDateString();
21+
}
22+
23+
export type ProfileSub = "main" | "repos" | "comments" | "blog";
24+
25+
export function AgentProfileView({
26+
profile,
27+
owned,
28+
buyAllResult,
29+
busy,
30+
sub,
31+
scrollOffset,
32+
self,
33+
}: {
34+
profile: AgentProfile;
35+
owned: Set<string>;
36+
buyAllResult: string | null;
37+
busy: boolean;
38+
sub: ProfileSub;
39+
scrollOffset: number;
40+
self: boolean;
41+
}) {
42+
const r = profile.reputation;
43+
const stars = r.stars ?? 0;
44+
const { cur, next } = tierInfo(stars);
45+
const blogNotes = (profile.notes ?? []).filter((n) => n.isSelfNote);
46+
const comments = (profile.notes ?? []).filter((n) => !n.isSelfNote);
47+
const allSkills = profile.createdSkills ?? [];
48+
const unowned = allSkills.filter((s) => !owned.has(s.name));
49+
50+
if (sub === "repos") {
51+
const repos = [...(profile.verifiedRepos ?? [])].sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0));
52+
const lines = repos.map((repo) => (
53+
<Box key={repo.url} flexDirection="column">
54+
<Text>
55+
<Text color={colors.iqCyan}>{repo.owner}/{repo.name}</Text>
56+
<Text dimColor> {repo.skillMints.length} skill{repo.skillMints.length !== 1 ? "s" : ""} linked</Text>
57+
</Text>
58+
<Text dimColor>{repo.stars} {repoGauge(repo.stars)}</Text>
59+
</Box>
60+
));
61+
return (
62+
<Box flexDirection="column" paddingX={1} borderStyle="round" borderColor={colors.iqViolet}>
63+
<Text bold color={colors.iqMagenta}>❖ verified repos ({repos.length})</Text>
64+
<Box flexDirection="column" marginTop={1}>
65+
{repos.length === 0 ? <Text dimColor>no verified repos</Text> : <ScrollView lines={lines} height={10} offset={scrollOffset} />}
66+
</Box>
67+
<Box marginTop={1}><Text dimColor>↑/↓/PgUp/PgDn scroll · esc back</Text></Box>
68+
</Box>
69+
);
70+
}
71+
72+
if (sub === "comments") {
73+
const lines = comments.map((n: Note) => (
74+
<Box key={n.id} flexDirection="column">
75+
<Text>
76+
<Text color={colors.iqCyan}>{short(n.author)}</Text>
77+
<Text dimColor> {noteDate(n.timestamp)}</Text>
78+
</Text>
79+
{n.title ? <Text bold>{n.title}</Text> : null}
80+
<Text> {n.text}</Text>
81+
{n.gitLink ? <Text dimColor> {glyph.sparkle} {n.gitLink}</Text> : null}
82+
</Box>
83+
));
84+
return (
85+
<Box flexDirection="column" paddingX={1} borderStyle="round" borderColor={colors.iqViolet}>
86+
<Text bold color={colors.iqMagenta}>❖ comments ({comments.length})</Text>
87+
<Box flexDirection="column" marginTop={1}>
88+
{comments.length === 0 ? <Text dimColor>no comments yet</Text> : <ScrollView lines={lines} height={12} offset={scrollOffset} />}
89+
</Box>
90+
<Box marginTop={1}><Text dimColor>↑/↓/PgUp/PgDn scroll · esc back</Text></Box>
91+
</Box>
92+
);
93+
}
94+
95+
if (sub === "blog") {
96+
const lines = blogNotes.map((n: Note) => (
97+
<Box key={n.id} flexDirection="column" marginBottom={1}>
98+
{n.title ? <Text bold color={colors.iqCyan}>{n.title}</Text> : null}
99+
{n.text ? <Text> {n.text}</Text> : null}
100+
{n.image ? <Text dimColor> [image: {n.image}]</Text> : null}
101+
{n.gitLink ? <Text dimColor> {glyph.sparkle} {n.gitLink}</Text> : null}
102+
<Text dimColor> {noteDate(n.timestamp)}</Text>
103+
</Box>
104+
));
105+
return (
106+
<Box flexDirection="column" paddingX={1} borderStyle="round" borderColor={colors.iqViolet}>
107+
<Text bold color={colors.iqMagenta}>❖ blog ({blogNotes.length})</Text>
108+
<Box flexDirection="column" marginTop={1}>
109+
{blogNotes.length === 0 ? <Text dimColor>no posts yet</Text> : <ScrollView lines={lines} height={12} offset={scrollOffset} />}
110+
</Box>
111+
<Box marginTop={1}>
112+
<Text dimColor>{self ? "[n] new post · " : ""}↑/↓/PgUp/PgDn scroll · esc back</Text>
113+
</Box>
114+
</Box>
115+
);
116+
}
117+
118+
// main
119+
return (
120+
<Box flexDirection="column" paddingX={1} borderStyle="round" borderColor={colors.iqViolet}>
121+
<Box>
122+
<Text bold color={colors.iqCyan}>{short(r.wallet)}</Text>
123+
{cur ? <Text color={colors.warn}> [{cur.name}]</Text> : null}
124+
<Text dimColor> {r.skillsPublished} skills · ×{r.totalSupply} supply · {r.notesReceived} notes</Text>
125+
</Box>
126+
<Box marginTop={1}>
127+
<Text dimColor>tier </Text><Text>{tierGauge(stars)}</Text>
128+
</Box>
129+
<Box>
130+
<Text dimColor>ladder</Text>
131+
{STAR_TIERS.map((t) => (
132+
<Text key={t.name} color={stars >= t.min ? colors.ok : colors.dim}> {t.name}({t.min})</Text>
133+
))}
134+
</Box>
135+
<Box marginTop={1}>
136+
<Text dimColor>earned </Text><Text color={colors.ok}>{earnedSol(r.totalEarned)}</Text>
137+
</Box>
138+
139+
<Box marginTop={1}>
140+
<Text dimColor>
141+
[r] verified repos ({(profile.verifiedRepos ?? []).length}) · [k] comments ({comments.length}) · [g] blog ({blogNotes.length})
142+
</Text>
143+
</Box>
144+
145+
{allSkills.length ? (
146+
<Box flexDirection="column" marginTop={1}>
147+
<Text dimColor>skills:</Text>
148+
{allSkills.slice(0, 8).map((s: SkillCard) => (
149+
<Box key={s.id}>
150+
<Text> · </Text>
151+
<Text color={owned.has(s.name) ? colors.ok : undefined}>{s.name}</Text>
152+
{owned.has(s.name) ? <Text color={colors.ok}> owned</Text> : null}
153+
</Box>
154+
))}
155+
</Box>
156+
) : null}
157+
158+
{buyAllResult ? (
159+
<Box marginTop={1}><Text color={colors.ok}>{glyph.sparkle} {buyAllResult}</Text></Box>
160+
) : null}
161+
162+
<Box marginTop={1}>
163+
<Text dimColor>
164+
{busy
165+
? "buying…"
166+
: unowned.length === 0
167+
? "all skills owned · "
168+
: unowned.length === allSkills.length
169+
? `[b] buy all ${unowned.length} skill${unowned.length !== 1 ? "s" : ""} · `
170+
: `[b] buy ${unowned.length} more skill${unowned.length !== 1 ? "s" : ""} · `}
171+
{self ? "[n] new post · " : ""}esc back
172+
</Text>
173+
</Box>
174+
</Box>
175+
);
176+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Helius RPC status badge + settings entry — ported from
2+
// surfaces/webview/src/market/MarketScreen.tsx + HeliusKeyForm.tsx. The public devnet
3+
// RPC doesn't serve DAS reads, so the market nudges the user to add a Helius key.
4+
// Core owns storage (saveHeliusKey/maskedHeliusKey/hasDasRpc, 0600 file) — this is
5+
// pure render + a text field, matching the CLI's other composer patterns.
6+
import React from "react";
7+
import { Box, Text } from "ink";
8+
import { colors } from "../../theme.js";
9+
10+
export interface RpcStatusLite {
11+
hasKey: boolean;
12+
masked: string | null;
13+
network: "devnet" | "mainnet";
14+
}
15+
16+
export function HeliusBadge({ status }: { status: RpcStatusLite | null }) {
17+
if (!status) return null;
18+
if (status.hasKey) {
19+
return <Text color={colors.ok}>{status.network} · {status.masked}</Text>;
20+
}
21+
return <Text color={colors.warn}>add a Helius key for faster results</Text>;
22+
}
23+
24+
export function HeliusPanel({
25+
status,
26+
keyInput,
27+
busy,
28+
flash,
29+
}: {
30+
status: RpcStatusLite | null;
31+
keyInput: string;
32+
busy: boolean;
33+
flash: string | null;
34+
}) {
35+
return (
36+
<Box flexDirection="column" paddingX={1} borderStyle="round" borderColor={colors.iqViolet}>
37+
<Text bold color={colors.iqMagenta}>❖ RPC settings</Text>
38+
<Box marginTop={1}>
39+
<Text dimColor>status </Text>
40+
<HeliusBadge status={status} />
41+
</Box>
42+
<Box marginTop={1}>
43+
<Text color={colors.iqCyan}></Text>
44+
<Text dimColor>helius key </Text>
45+
<Text>{keyInput}</Text>
46+
<Text inverse> </Text>
47+
</Box>
48+
{flash ? <Box marginTop={1}><Text color={colors.ok}>{flash}</Text></Box> : null}
49+
{busy ? <Text dimColor>saving…</Text> : null}
50+
<Box marginTop={1}>
51+
<Text dimColor>paste key or full RPC URL · ↵ save · [x] clear key · esc back</Text>
52+
</Box>
53+
</Box>
54+
);
55+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Live publish progress — ported from surfaces/webview/src/market/PublishForm.tsx.
2+
// Three phases (store the body -> mint the NFT -> list for sale), each a separate
3+
// wallet signature; store carries an optional 0..100 sub-percent for the code-in chunking.
4+
import React from "react";
5+
import { Box, Text } from "ink";
6+
import { colors } from "../../theme.js";
7+
8+
export interface PublishProgress {
9+
phase: "store" | "mint" | "list";
10+
signed: number;
11+
percent?: number;
12+
kind: "skill" | "workflow";
13+
}
14+
15+
const PHASES: { key: PublishProgress["phase"]; label: string }[] = [
16+
{ key: "store", label: "storing on-chain" },
17+
{ key: "mint", label: "minting the NFT" },
18+
{ key: "list", label: "listing for sale" },
19+
];
20+
21+
export function PublishProgressView({ progress }: { progress: PublishProgress | null }) {
22+
const idx = progress ? Math.max(0, PHASES.findIndex((p) => p.key === progress.phase)) : 0;
23+
const sub = progress?.phase === "store" && progress.percent != null ? progress.percent / 100 : idx > 0 ? 1 : 0;
24+
const overall = progress ? Math.min(100, Math.round(((idx + sub) / PHASES.length) * 100)) : 0;
25+
const signed = progress?.signed ?? 0;
26+
return (
27+
<Box flexDirection="column" marginTop={1}>
28+
{PHASES.map((p, i) => (
29+
<Text key={p.key} color={i < idx ? colors.ok : i === idx ? colors.iqCyan : colors.dim}>
30+
{i < idx ? "✓" : i === idx ? "▸" : "○"} {p.label}
31+
</Text>
32+
))}
33+
<Text dimColor>{overall}% · {signed > 0 ? `${signed} signature${signed === 1 ? "" : "s"} approved` : "waiting for the first signature…"}</Text>
34+
</Box>
35+
);
36+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// In-view scroll viewport — the CLI's answer to the webview's free-scrolling panels.
2+
// A fixed-height Box slices a line array to [offset, offset+height) plus a footer
3+
// showing position, so long content (SKILL.md, comment stacks, blog carousels) never
4+
// blows past the terminal without dumping raw text into native scrollback.
5+
import React from "react";
6+
import { Box, Text } from "ink";
7+
8+
// Scroll offset itself lives in the parent (SkillMarket's single useInput handler owns
9+
// all key routing); this component just renders the [offset, offset+height) slice.
10+
// Parents clamp with the same math as __selfCheck below.
11+
export function ScrollView({
12+
lines,
13+
height,
14+
offset,
15+
title,
16+
}: {
17+
lines: React.ReactNode[];
18+
height: number;
19+
offset: number;
20+
title?: string;
21+
}) {
22+
const total = lines.length;
23+
const visible = lines.slice(offset, offset + height);
24+
const end = Math.min(total, offset + height);
25+
return (
26+
<Box flexDirection="column">
27+
{title ? <Text dimColor>── {title} ──</Text> : null}
28+
<Box flexDirection="column" minHeight={height}>
29+
{visible.map((l, i) => (
30+
<Box key={offset + i}>{typeof l === "string" ? <Text>{l}</Text> : l}</Box>
31+
))}
32+
</Box>
33+
{total > height ? (
34+
<Text dimColor>
35+
{offset > 0 ? "▲" : " "} {offset + 1}{end}/{total} {end < total ? "▼" : " "}
36+
</Text>
37+
) : null}
38+
</Box>
39+
);
40+
}
41+
42+
// ponytail: assert-based self-check for the slice-window clamp logic.
43+
export function __selfCheck(): void {
44+
const assert = (cond: boolean, msg: string) => { if (!cond) throw new Error(`ScrollView self-check failed: ${msg}`); };
45+
const clampWith = (total: number, height: number, n: number) => Math.max(0, Math.min(Math.max(0, total - height), n));
46+
assert(clampWith(10, 5, -3) === 0, "clamps below zero");
47+
assert(clampWith(10, 5, 100) === 5, "clamps at maxOffset (total-height)");
48+
assert(clampWith(3, 5, 2) === 0, "content shorter than viewport -> maxOffset 0");
49+
}

0 commit comments

Comments
 (0)