@clubnet/seedclub 0.2.0

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,275 @@
1
+ import { Text } from "@mariozechner/pi-tui";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { ApiError, api } from "../api-client.js";
5
+ import { wrapExecute } from "../tool-utils.js";
6
+
7
+ // --- Type labels ---
8
+
9
+ const TYPE_LABEL: Record<string, string> = {
10
+ twitter_account: "twitter",
11
+ company: "company",
12
+ person: "person",
13
+ blog: "blog",
14
+ github_profile: "github",
15
+ topic: "topic",
16
+ newsletter: "newsletter",
17
+ podcast: "podcast",
18
+ subreddit: "subreddit",
19
+ custom: "signal",
20
+ };
21
+
22
+ // --- API handlers (used by /add internally, NOT exposed to LLM) ---
23
+
24
+ export async function importSignals(args: { input: string; defaultType?: string; tags?: string[] }) {
25
+ try {
26
+ const response = await api.post<any>("/signals/import", args);
27
+ return { created: response.created, skipped: response.skipped, total: response.total };
28
+ } catch (error) {
29
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ export async function createSignal(args: {
35
+ type: string;
36
+ name: string;
37
+ description?: string;
38
+ externalUrl?: string;
39
+ imageUrl?: string;
40
+ tags?: string[];
41
+ metadata?: Record<string, unknown>;
42
+ }) {
43
+ try {
44
+ const response = await api.post<any>("/signals", args);
45
+ return {
46
+ id: response.signal.id,
47
+ type: response.signal.type,
48
+ name: response.signal.name,
49
+ slug: response.signal.slug,
50
+ };
51
+ } catch (error) {
52
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
53
+ throw error;
54
+ }
55
+ }
56
+
57
+ // --- Read-only handlers ---
58
+
59
+ async function getSignal(args: { signalId?: string; slug?: string }) {
60
+ try {
61
+ const params: Record<string, string | undefined> = {};
62
+ if (args.signalId) params.id = args.signalId;
63
+ if (args.slug) params.slug = args.slug;
64
+ const response = await api.get<any>("/signals", params);
65
+ return { signal: response.signal };
66
+ } catch (error) {
67
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ export async function listSignals(args: { type?: string; tag?: string; limit?: number }) {
73
+ try {
74
+ const response = await api.get<any>("/signals", args);
75
+ return { signals: response.signals, total: response.total };
76
+ } catch (error) {
77
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ export async function searchSignals(args: { query: string; limit?: number }) {
83
+ try {
84
+ const response = await api.get<any>("/signals", { search: args.query, limit: args.limit });
85
+ return { signals: response.signals, total: response.total, query: args.query };
86
+ } catch (error) {
87
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ async function deleteSignal(args: { signalId: string }) {
93
+ try {
94
+ const response = await api.delete<any>("/signals", { id: args.signalId });
95
+ return { success: response.success, message: response.message };
96
+ } catch (error) {
97
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ // --- Bulk operations (used by /sort) ---
103
+
104
+ export async function deleteUnsortedSignals() {
105
+ try {
106
+ const response = await api.delete<any>("/signals", { id: "unsorted" });
107
+ return { deleted: response.deleted, message: response.message };
108
+ } catch (error) {
109
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ // --- Sort operations ---
115
+
116
+ export async function getUnsortedSignals() {
117
+ try {
118
+ const response = await api.get<any>("/signals/sort/auto", {});
119
+ return {
120
+ unsorted: response.unsorted,
121
+ unsortedCount: response.unsortedCount,
122
+ buckets: response.buckets,
123
+ angelMd: response.angelMd,
124
+ };
125
+ } catch (error) {
126
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ async function submitSortScores(args: { scores: Record<string, Record<string, number>> }) {
132
+ try {
133
+ const response = await api.post<any>("/signals/sort/auto", { scores: args.scores });
134
+ return {
135
+ sorted: response.sortedCount,
136
+ skipped: response.skippedCount,
137
+ adjusted: response.adjustedCount,
138
+ };
139
+ } catch (error) {
140
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ // --- LLM tool registration ---
146
+
147
+ export function registerSignalTools(pi: ExtensionAPI) {
148
+ pi.registerTool({
149
+ name: "leaf_list_signals",
150
+ label: "List Signals",
151
+ description: "List the user's signals. Use /add to create new signals.",
152
+ parameters: Type.Object({
153
+ type: Type.Optional(Type.String({ description: "Filter by signal type" })),
154
+ tag: Type.Optional(Type.String({ description: "Filter by tag" })),
155
+ limit: Type.Optional(Type.Number({ description: "Maximum number of results" })),
156
+ }),
157
+ execute: wrapExecute(listSignals),
158
+ renderResult(result: any, { expanded }: any, theme: any) {
159
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
160
+ const d = result.details || {};
161
+ let text = theme.fg("muted", `${d.total ?? 0} signals`);
162
+ if (d.signals?.length) {
163
+ for (const s of d.signals.slice(0, expanded ? 50 : 10)) {
164
+ const label = TYPE_LABEL[s.type] || "signal";
165
+ text += `\n ${s.name} [${label}]`;
166
+ }
167
+ if (!expanded && d.signals.length > 10) {
168
+ text += theme.fg("dim", `\n ... and ${d.signals.length - 10} more`);
169
+ }
170
+ }
171
+ return new Text(text, 0, 0);
172
+ },
173
+ });
174
+
175
+ pi.registerTool({
176
+ name: "leaf_search_signals",
177
+ label: "Search Signals",
178
+ description: "Search the user's signals by name or description.",
179
+ parameters: Type.Object({
180
+ query: Type.String({ description: "Search query" }),
181
+ limit: Type.Optional(Type.Number({ description: "Maximum number of results" })),
182
+ }),
183
+ execute: wrapExecute(searchSignals),
184
+ renderResult(result: any, { expanded }: any, theme: any) {
185
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
186
+ const d = result.details || {};
187
+ if (!d.signals?.length) return new Text(theme.fg("dim", `No results for "${d.query}"`), 0, 0);
188
+ let text = theme.fg("muted", `${d.total} results:`);
189
+ for (const s of d.signals.slice(0, expanded ? 50 : 10)) {
190
+ const label = TYPE_LABEL[s.type] || "signal";
191
+ text += `\n ${s.name} [${label}]`;
192
+ }
193
+ return new Text(text, 0, 0);
194
+ },
195
+ });
196
+
197
+ pi.registerTool({
198
+ name: "leaf_get_signal",
199
+ label: "Get Signal",
200
+ description: "Get details of a specific signal.",
201
+ parameters: Type.Object({
202
+ signalId: Type.Optional(Type.String({ description: "Signal ID" })),
203
+ slug: Type.Optional(Type.String({ description: "Signal slug" })),
204
+ }),
205
+ execute: wrapExecute(getSignal),
206
+ });
207
+
208
+ pi.registerTool({
209
+ name: "leaf_delete_signal",
210
+ label: "Delete Signal",
211
+ description: "Delete a signal by ID.",
212
+ parameters: Type.Object({
213
+ signalId: Type.String({ description: "ID of the signal to delete" }),
214
+ }),
215
+ execute: wrapExecute(deleteSignal),
216
+ });
217
+
218
+ pi.registerTool({
219
+ name: "leaf_get_unsorted_signals",
220
+ label: "Get Unsorted Signals",
221
+ description:
222
+ "Get signals that haven't been sorted into buckets yet. Returns signals with their descriptions, metadata, and the 10 bucket definitions. Also returns the user's angel.md (investment thesis) if set.",
223
+ parameters: Type.Object({}),
224
+ execute: wrapExecute(async () => {
225
+ const result = await getUnsortedSignals();
226
+ if ("error" in result) return result;
227
+ return {
228
+ count: result.unsortedCount,
229
+ buckets: result.buckets,
230
+ angelMd: result.angelMd,
231
+ signals: result.unsorted?.map((s: any) => ({
232
+ id: s.userSignalId,
233
+ name: s.name,
234
+ description: s.description,
235
+ url: s.externalUrl,
236
+ type: s.type,
237
+ metadata: s.metadata,
238
+ })),
239
+ };
240
+ }),
241
+ renderResult(result: any, _opts: any, theme: any) {
242
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
243
+ const d = result.details || {};
244
+ return new Text(
245
+ theme.fg("muted", `${d.count ?? 0} unsorted signals, ${d.buckets?.length ?? 0} buckets`),
246
+ 0,
247
+ 0,
248
+ );
249
+ },
250
+ });
251
+
252
+ pi.registerTool({
253
+ name: "leaf_submit_sort_scores",
254
+ label: "Submit Sort Scores",
255
+ description:
256
+ "Submit bucket scores for signals. Each signal needs scores (0-1) for each of the 10 buckets: vision, execution, resilience, social, learning, taste, technical, innovation, domain, commercial. Server applies the user's taste prior on top of your raw scores. Pass a map of userSignalId -> { bucketKey: score }.",
257
+ parameters: Type.Object({
258
+ scores: Type.Record(
259
+ Type.String({ description: "userSignalId" }),
260
+ Type.Record(Type.String({ description: "bucket key" }), Type.Number({ description: "score 0-1" })),
261
+ { description: "Map of signalId to bucket scores" },
262
+ ),
263
+ }),
264
+ execute: wrapExecute(submitSortScores),
265
+ renderResult(result: any, _opts: any, theme: any) {
266
+ if (result.isError) return new Text(theme.fg("error", result.content?.[0]?.text || "Error"), 0, 0);
267
+ const d = result.details || {};
268
+ const parts: string[] = [];
269
+ if (d.sorted) parts.push(`${d.sorted} sorted`);
270
+ if (d.skipped) parts.push(`${d.skipped} skipped`);
271
+ if (d.adjusted) parts.push(`${d.adjusted} adjusted by taste`);
272
+ return new Text(theme.fg("muted", parts.join(", ") || "done"), 0, 0);
273
+ },
274
+ });
275
+ }
@@ -0,0 +1,31 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import { ApiError, api } from "../api-client.js";
4
+ import { wrapExecute } from "../tool-utils.js";
5
+
6
+ export async function getCurrentUser() {
7
+ try {
8
+ const response = await api.get<any>("/user");
9
+ return {
10
+ id: response.user.id,
11
+ name: response.user.name,
12
+ email: response.user.email,
13
+ role: response.user.role,
14
+ createdAt: response.user.createdAt,
15
+ stats: response.stats,
16
+ };
17
+ } catch (error) {
18
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
19
+ throw error;
20
+ }
21
+ }
22
+
23
+ export function registerUtilityTools(pi: ExtensionAPI) {
24
+ pi.registerTool({
25
+ name: "leaf_get_current_user",
26
+ label: "Get Current User",
27
+ description: "Get information about the currently authenticated Seed Club user and their stats.",
28
+ parameters: Type.Object({}),
29
+ execute: wrapExecute(getCurrentUser),
30
+ });
31
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Singleton wrapper for the bird TwitterClient.
3
+ * Detects the system default browser and tries it first for cookie resolution.
4
+ */
5
+
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync, readdirSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ import { type CookieSource, resolveCredentials, TwitterClient, type TwitterCookies } from "@connormartin/bird";
11
+ import { isDiaAvailable, readDiaTwitterCookies } from "./dia-cookies.js";
12
+
13
+ // --- Default browser detection ---
14
+
15
+ type BrowserKind = "safari" | "chrome" | "firefox";
16
+
17
+ interface BrowserInfo {
18
+ kind: BrowserKind;
19
+ bundleId: string;
20
+ cookieDir?: string; // specific cookie directory for this browser
21
+ }
22
+
23
+ const BUNDLE_TO_BROWSER: Record<string, BrowserInfo> = {
24
+ "com.apple.safari": { kind: "safari", bundleId: "com.apple.safari" },
25
+ "com.google.chrome": { kind: "chrome", bundleId: "com.google.chrome", cookieDir: "Google/Chrome" },
26
+ "com.brave.browser": { kind: "chrome", bundleId: "com.brave.browser", cookieDir: "BraveSoftware/Brave-Browser" },
27
+ "company.thebrowser.browser": { kind: "chrome", bundleId: "company.thebrowser.browser", cookieDir: "Arc/User Data" },
28
+ "com.microsoft.edgemac": { kind: "chrome", bundleId: "com.microsoft.edgemac", cookieDir: "Microsoft Edge" },
29
+ "com.vivaldi.vivaldi": { kind: "chrome", bundleId: "com.vivaldi.vivaldi", cookieDir: "Vivaldi" },
30
+ "org.mozilla.firefox": { kind: "firefox", bundleId: "org.mozilla.firefox" },
31
+ "org.mozilla.nightly": { kind: "firefox", bundleId: "org.mozilla.nightly" },
32
+ "company.thebrowser.dia": { kind: "chrome", bundleId: "company.thebrowser.dia", cookieDir: "Dia/User Data" },
33
+ };
34
+
35
+ function detectDefaultBrowser(): BrowserInfo | null {
36
+ if (process.platform !== "darwin") return null;
37
+ try {
38
+ const plistPath = join(
39
+ process.env.HOME || "",
40
+ "Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
41
+ );
42
+ const plist = execFileSync("plutil", ["-convert", "json", "-o", "-", plistPath], {
43
+ encoding: "utf-8",
44
+ timeout: 3000,
45
+ });
46
+ const data = JSON.parse(plist);
47
+ const handlers = data.LSHandlers ?? [];
48
+ for (const h of handlers) {
49
+ if (h.LSHandlerURLScheme === "https" && h.LSHandlerRoleAll) {
50
+ const bundleId = h.LSHandlerRoleAll.toLowerCase();
51
+ for (const [prefix, info] of Object.entries(BUNDLE_TO_BROWSER)) {
52
+ if (bundleId.includes(prefix)) return info;
53
+ }
54
+ }
55
+ }
56
+ } catch {}
57
+ return null;
58
+ }
59
+
60
+ // --- Chrome profile detection ---
61
+
62
+ /** Find Chrome profiles in a specific app support directory. */
63
+ function detectChromeProfiles(cookieDir?: string): string[] {
64
+ if (process.platform !== "darwin") return [];
65
+
66
+ const home = process.env.HOME || "";
67
+ const dirs = cookieDir
68
+ ? [join(home, "Library/Application Support", cookieDir)]
69
+ : [
70
+ join(home, "Library/Application Support/Dia/User Data"),
71
+ join(home, "Library/Application Support/Google/Chrome"),
72
+ join(home, "Library/Application Support/BraveSoftware/Brave-Browser"),
73
+ join(home, "Library/Application Support/Arc/User Data"),
74
+ join(home, "Library/Application Support/Microsoft Edge"),
75
+ join(home, "Library/Application Support/Vivaldi"),
76
+ ];
77
+
78
+ const profiles: { name: string; mtimeMs: number }[] = [];
79
+
80
+ for (const dir of dirs) {
81
+ if (!existsSync(dir)) continue;
82
+ try {
83
+ const defaultCookies = join(dir, "Default", "Cookies");
84
+ if (existsSync(defaultCookies)) {
85
+ profiles.push({ name: "Default", mtimeMs: statSync(defaultCookies).mtimeMs });
86
+ }
87
+ const entries = readdirSync(dir);
88
+ for (const name of entries) {
89
+ if (!name.startsWith("Profile ")) continue;
90
+ const cookiesPath = join(dir, name, "Cookies");
91
+ if (existsSync(cookiesPath)) {
92
+ profiles.push({ name, mtimeMs: statSync(cookiesPath).mtimeMs });
93
+ }
94
+ }
95
+ } catch {}
96
+ }
97
+
98
+ profiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
99
+ return profiles.map((p) => p.name);
100
+ }
101
+
102
+ // --- Credential resolution ---
103
+
104
+ export class TwitterClientError extends Error {
105
+ constructor(
106
+ message: string,
107
+ public code: "NO_CREDENTIALS" | "API_ERROR",
108
+ ) {
109
+ super(message);
110
+ this.name = "TwitterClientError";
111
+ }
112
+ }
113
+
114
+ export interface CredentialCheckResult {
115
+ valid: boolean;
116
+ source?: string;
117
+ warnings: string[];
118
+ user?: { id: string; username: string; name: string };
119
+ }
120
+
121
+ let cachedClient: TwitterClient | null = null;
122
+ let _cachedCookies: TwitterCookies | null = null;
123
+
124
+ export function clearTwitterClient(): void {
125
+ cachedClient = null;
126
+ _cachedCookies = null;
127
+ }
128
+
129
+ async function tryBrowser(
130
+ source: CookieSource,
131
+ opts?: { chromeProfile?: string; firefoxProfile?: string },
132
+ ): Promise<{ cookies: TwitterCookies; warnings: string[] } | null> {
133
+ const result = await resolveCredentials({
134
+ cookieSource: [source],
135
+ chromeProfile: opts?.chromeProfile,
136
+ firefoxProfile: opts?.firefoxProfile,
137
+ });
138
+ if (result.cookies.authToken && result.cookies.ct0) {
139
+ return { cookies: result.cookies, warnings: result.warnings };
140
+ }
141
+ return null;
142
+ }
143
+
144
+ async function resolveTwitterCredentials(): Promise<{
145
+ cookies: TwitterCookies;
146
+ warnings: string[];
147
+ }> {
148
+ const allWarnings: string[] = [];
149
+ const defaultBrowser = detectDefaultBrowser();
150
+
151
+ // Dia: sweet-cookie doesn't support it, so we read cookies directly
152
+ if (defaultBrowser?.bundleId === "company.thebrowser.dia" || (!defaultBrowser && isDiaAvailable())) {
153
+ const dia = readDiaTwitterCookies();
154
+ if (dia) {
155
+ return {
156
+ cookies: {
157
+ authToken: dia.authToken,
158
+ ct0: dia.ct0,
159
+ cookieHeader: dia.cookieHeader,
160
+ source: "Dia",
161
+ },
162
+ warnings: [],
163
+ };
164
+ }
165
+ if (defaultBrowser?.bundleId === "company.thebrowser.dia") {
166
+ return {
167
+ cookies: { authToken: null, ct0: null, cookieHeader: null, source: null },
168
+ warnings: ["No Twitter login found in Dia. Log in to x.com there."],
169
+ };
170
+ }
171
+ }
172
+
173
+ // If we know the default browser, try ONLY that browser's cookies.
174
+ if (defaultBrowser) {
175
+ const result = await tryBrowserInfo(defaultBrowser);
176
+ if (result) return result;
177
+ allWarnings.push("No Twitter login found in your default browser. Log in to x.com there.");
178
+ return {
179
+ cookies: { authToken: null, ct0: null, cookieHeader: null, source: null },
180
+ warnings: allWarnings,
181
+ };
182
+ }
183
+
184
+ // No default browser detected — try all in order
185
+ for (const browser of ["safari", "chrome", "firefox"] as BrowserKind[]) {
186
+ const result = await tryBrowserKind(browser);
187
+ if (result) return result;
188
+ }
189
+
190
+ allWarnings.push("No Twitter credentials found in any browser. Log in to x.com.");
191
+ return {
192
+ cookies: { authToken: null, ct0: null, cookieHeader: null, source: null },
193
+ warnings: allWarnings,
194
+ };
195
+ }
196
+
197
+ async function tryBrowserInfo(info: BrowserInfo): Promise<{ cookies: TwitterCookies; warnings: string[] } | null> {
198
+ if (info.kind === "safari") return tryBrowser("safari");
199
+ if (info.kind === "firefox") return tryBrowser("firefox", { firefoxProfile: process.env.FIREFOX_PROFILE });
200
+
201
+ // Chrome-based — use specific cookie dir for this browser only
202
+ const profiles = detectChromeProfiles(info.cookieDir);
203
+ for (const profile of profiles) {
204
+ const result = await tryBrowser("chrome", { chromeProfile: profile });
205
+ if (result) return result;
206
+ }
207
+ return null;
208
+ }
209
+
210
+ async function tryBrowserKind(kind: BrowserKind): Promise<{ cookies: TwitterCookies; warnings: string[] } | null> {
211
+ if (kind === "safari") return tryBrowser("safari");
212
+ if (kind === "firefox") return tryBrowser("firefox", { firefoxProfile: process.env.FIREFOX_PROFILE });
213
+
214
+ // Chrome — try all dirs
215
+ const profiles = detectChromeProfiles();
216
+ for (const profile of profiles) {
217
+ const result = await tryBrowser("chrome", { chromeProfile: profile });
218
+ if (result) return result;
219
+ }
220
+ return null;
221
+ }
222
+
223
+ // --- Public API ---
224
+
225
+ export async function getTwitterClient(): Promise<TwitterClient> {
226
+ if (cachedClient) return cachedClient;
227
+
228
+ const { cookies, warnings: _warnings } = await resolveTwitterCredentials();
229
+ if (!cookies.authToken || !cookies.ct0) {
230
+ throw new TwitterClientError(
231
+ "Twitter credentials not found. Log in to x.com in your default browser.",
232
+ "NO_CREDENTIALS",
233
+ );
234
+ }
235
+
236
+ _cachedCookies = cookies;
237
+ cachedClient = new TwitterClient({ cookies });
238
+ return cachedClient;
239
+ }
240
+
241
+ export async function checkTwitterCredentials(): Promise<CredentialCheckResult> {
242
+ const { cookies, warnings } = await resolveTwitterCredentials();
243
+
244
+ if (!cookies.authToken || !cookies.ct0) {
245
+ return {
246
+ valid: false,
247
+ warnings: [...warnings, "No valid Twitter credentials found. Log in to x.com in your default browser."],
248
+ };
249
+ }
250
+
251
+ try {
252
+ const client = new TwitterClient({ cookies });
253
+ const result = await client.getCurrentUser();
254
+
255
+ if (!result.success || !result.user) {
256
+ return {
257
+ valid: false,
258
+ source: cookies.source ?? undefined,
259
+ warnings: [...warnings, result.error ?? "Failed to verify credentials"],
260
+ };
261
+ }
262
+
263
+ _cachedCookies = cookies;
264
+ cachedClient = client;
265
+
266
+ return { valid: true, source: cookies.source ?? undefined, warnings, user: result.user };
267
+ } catch (error) {
268
+ return {
269
+ valid: false,
270
+ source: cookies.source ?? undefined,
271
+ warnings: [
272
+ ...warnings,
273
+ `Credential verification failed: ${error instanceof Error ? error.message : String(error)}`,
274
+ ],
275
+ };
276
+ }
277
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Green Bar Editor — solid emerald background input, full width.
3
+ * The text input is a bright green bar. Content above is black bg.
4
+ */
5
+
6
+ import { type EditorTheme, type TUI, visibleWidth } from "@mariozechner/pi-tui";
7
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import type { KeybindingsManager } from "@mariozechner/pi-coding-agent";
9
+ import { CustomEditor } from "@mariozechner/pi-coding-agent";
10
+
11
+ const EMERALD_BG = "\x1b[48;2;80;200;120m";
12
+ const BLACK_FG = "\x1b[38;2;14;14;15m";
13
+ const EMERALD_FG = "\x1b[38;2;80;200;120m";
14
+ const PANEL_BG = "\x1b[48;2;14;14;15m";
15
+ const PANEL_FG = EMERALD_FG;
16
+ const RESET = "\x1b[0m";
17
+
18
+ class GreenBarEditor extends CustomEditor {
19
+ constructor(tui: TUI, theme: EditorTheme, kb: KeybindingsManager) {
20
+ super(tui, theme, kb);
21
+ // Disable default border rendering
22
+ this.borderColor = (_text: string) => "";
23
+ }
24
+
25
+ render(width: number): string[] {
26
+ const rawLines = super.render(width);
27
+ const filtered: string[] = [];
28
+
29
+ for (const line of rawLines) {
30
+ const stripped = this.stripAnsi(line);
31
+ if (this.isBorderLine(stripped)) continue;
32
+ filtered.push(line);
33
+ }
34
+
35
+ const paintEmerald = (line: string): string => {
36
+ const plain = this.stripAnsi(line);
37
+ const vis = visibleWidth(plain);
38
+ const pad = " ".repeat(Math.max(0, width - vis));
39
+ return `${EMERALD_BG}${BLACK_FG}${plain}${pad}${RESET}`;
40
+ };
41
+
42
+ if (filtered.length === 0) {
43
+ return [paintEmerald("")];
44
+ }
45
+
46
+ // Keep input bar emerald, but render slash/autocomplete menu on dark panel
47
+ // so it isn't washed out by emerald background.
48
+ if (this.isShowingAutocomplete()) {
49
+ const [inputLine, ...menuLines] = filtered;
50
+ const out = [paintEmerald(inputLine ?? "")];
51
+
52
+ for (const menuLine of menuLines) {
53
+ const plain = this.stripAnsi(menuLine);
54
+ const trimmed = plain.trim();
55
+ // Drop visual separator rules for cleaner menu block
56
+ if (/^[─-]+$/.test(trimmed)) continue;
57
+ const vis = visibleWidth(plain);
58
+ const pad = " ".repeat(Math.max(0, width - vis));
59
+ const color = trimmed.startsWith("→") || trimmed.startsWith(">") ? EMERALD_FG : PANEL_FG;
60
+ out.push(`${PANEL_BG}${color}${plain}${pad}${RESET}`);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ return filtered.map((line) => paintEmerald(line));
66
+ }
67
+
68
+ private stripAnsi(s: string): string {
69
+ return s
70
+ // CSI sequences (colors, clears, cursor moves, etc.)
71
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
72
+ // OSC/APC style sequences
73
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
74
+ .replace(/\x1b_[^\x1b]*\x1b\\/g, "");
75
+ }
76
+
77
+ private isBorderLine(stripped: string): boolean {
78
+ const trimmed = stripped.trim();
79
+ if (trimmed.length === 0) return false;
80
+ // Match lines that are purely border characters
81
+ return /^[─╭╮╰╯├┤┬┴│]+$/.test(trimmed);
82
+ }
83
+
84
+ override invalidate(): void {
85
+ super.invalidate();
86
+ }
87
+ }
88
+
89
+ export default function (pi: ExtensionAPI) {
90
+ pi.on("session_start", (_event, ctx) => {
91
+ ctx.ui.setEditorComponent((tui, theme, kb) => new GreenBarEditor(tui, theme, kb));
92
+ });
93
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Seed Club UI — built-in theme experience.
3
+ * Window frame, rounded emerald editor, welcome header with weather/markets.
4
+ */
5
+
6
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
+ import editorExtension from "./editor.js";
8
+ import updateExtension from "./update.js";
9
+ import welcomeExtension from "./welcome.js";
10
+
11
+ export default function (pi: ExtensionAPI) {
12
+ editorExtension(pi);
13
+ welcomeExtension(pi, { enableFrame: true });
14
+ updateExtension(pi);
15
+ }