cli-funnel-react 0.1.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.
- package/dist/funnel.css +61 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +421 -0
- package/package.json +51 -0
package/dist/funnel.css
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
.cf {
|
|
2
|
+
--cf-bg: #fff;
|
|
3
|
+
--cf-fg: #17181a;
|
|
4
|
+
--cf-muted: #6a6f76;
|
|
5
|
+
--cf-line: #d9dce0;
|
|
6
|
+
--cf-accent: #1f5eff;
|
|
7
|
+
--cf-accent-fg: #fff;
|
|
8
|
+
--cf-danger: #b3261e;
|
|
9
|
+
--cf-radius: 8px;
|
|
10
|
+
font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
11
|
+
color: var(--cf-fg);
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: dark) {
|
|
14
|
+
.cf {
|
|
15
|
+
--cf-bg: #16181b;
|
|
16
|
+
--cf-fg: #eceef1;
|
|
17
|
+
--cf-muted: #9aa0a8;
|
|
18
|
+
--cf-line: #2c3036;
|
|
19
|
+
--cf-accent: #6d97ff;
|
|
20
|
+
--cf-accent-fg: #0b1020;
|
|
21
|
+
--cf-danger: #ff8a80;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
.cf * { box-sizing: border-box; }
|
|
25
|
+
.cf-mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; }
|
|
26
|
+
.cf-picker { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
|
27
|
+
.cf-field { display: grid; gap: 6px; align-content: start; border: 0; padding: 0; margin: 0; min-width: 0; }
|
|
28
|
+
.cf-field > span, .cf-field > label, .cf-field > legend { font-weight: 600; font-size: 13px; }
|
|
29
|
+
.cf-field small, .cf-check small, .cf-option small { color: var(--cf-muted); font-size: 12px; }
|
|
30
|
+
.cf select, .cf input:not([type]), .cf input[type="text"] {
|
|
31
|
+
width: 100%; min-height: 36px; padding: 6px 10px; color: inherit; background: var(--cf-bg);
|
|
32
|
+
border: 1px solid var(--cf-line); border-radius: var(--cf-radius);
|
|
33
|
+
}
|
|
34
|
+
.cf select:focus-visible, .cf input:focus-visible, .cf button:focus-visible { outline: 2px solid var(--cf-accent); outline-offset: 1px; }
|
|
35
|
+
.cf button {
|
|
36
|
+
min-height: 36px; padding: 6px 14px; white-space: nowrap; cursor: pointer; color: inherit;
|
|
37
|
+
background: var(--cf-bg); border: 1px solid var(--cf-line); border-radius: var(--cf-radius);
|
|
38
|
+
}
|
|
39
|
+
.cf button:hover:not(:disabled) { border-color: var(--cf-muted); }
|
|
40
|
+
.cf button:disabled { opacity: 0.55; cursor: default; }
|
|
41
|
+
.cf button.cf-primary { background: var(--cf-accent); color: var(--cf-accent-fg); border-color: transparent; }
|
|
42
|
+
.cf-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
43
|
+
.cf-row > input { flex: 1 1 200px; }
|
|
44
|
+
.cf-check { display: grid; grid-template-columns: auto 1fr; column-gap: 8px; align-items: center; align-self: end; }
|
|
45
|
+
.cf-check small { grid-column: 2; }
|
|
46
|
+
.cf-access { grid-column: 1 / -1; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
|
|
47
|
+
.cf-access legend { grid-column: 1 / -1; margin-bottom: 4px; }
|
|
48
|
+
.cf-option { display: grid; grid-template-columns: auto 1fr; column-gap: 8px; padding: 10px 12px; border: 1px solid var(--cf-line); border-radius: var(--cf-radius); cursor: pointer; }
|
|
49
|
+
.cf-option small { grid-column: 2; }
|
|
50
|
+
.cf-option.cf-on { border-color: var(--cf-accent); box-shadow: inset 0 0 0 1px var(--cf-accent); }
|
|
51
|
+
.cf-browser { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--cf-line); border-radius: var(--cf-radius); }
|
|
52
|
+
.cf-crumb { color: var(--cf-muted); overflow-wrap: anywhere; }
|
|
53
|
+
.cf-list { display: flex; flex-wrap: wrap; gap: 6px; max-height: 180px; overflow: auto; }
|
|
54
|
+
.cf-list button { min-height: 30px; padding: 3px 10px; }
|
|
55
|
+
.cf-error { color: var(--cf-danger); }
|
|
56
|
+
.cf-login { display: grid; gap: 10px; padding: 16px; border: 1px solid var(--cf-line); border-radius: 12px; }
|
|
57
|
+
.cf-login header { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
|
58
|
+
.cf-login h3, .cf-login p { margin: 0; }
|
|
59
|
+
.cf-approval { display: grid; gap: 8px; padding: 12px 14px; border: 1px solid var(--cf-accent); border-radius: var(--cf-radius); }
|
|
60
|
+
.cf-approval pre { margin: 0; max-height: 160px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
61
|
+
.cf-folder { grid-column: 1 / -1; }
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { FunnelClient, Selection, ModelInfo, ProviderOverview, AuthStatus, LoginEvent, FunnelEvent, Usage, ApprovalRequest, ApprovalDecision } from 'cli-funnel/client';
|
|
2
|
+
import * as react from 'react';
|
|
3
|
+
|
|
4
|
+
type SelectionField = keyof Selection;
|
|
5
|
+
/** Loads installed providers and their login state. */
|
|
6
|
+
declare function useProviders(client: FunnelClient): {
|
|
7
|
+
providers: ProviderOverview[];
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error: string | undefined;
|
|
10
|
+
refresh: () => Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Holds a Selection and keeps it valid. Change the provider and the model, effort, context,
|
|
14
|
+
* fast and access fall back to values that provider supports. `locked` fields never change.
|
|
15
|
+
*/
|
|
16
|
+
declare function useSelection(client: FunnelClient, opts?: {
|
|
17
|
+
defaults?: Partial<Selection>;
|
|
18
|
+
locked?: Partial<Selection>;
|
|
19
|
+
/** Chooses the model and effort to start from when the provider changes, for example the cheapest one. */
|
|
20
|
+
pick?: (provider: Selection["provider"], models: ModelInfo[]) => {
|
|
21
|
+
model?: string;
|
|
22
|
+
effort?: string;
|
|
23
|
+
} | undefined;
|
|
24
|
+
}): {
|
|
25
|
+
providers: ProviderOverview[];
|
|
26
|
+
models: ModelInfo[];
|
|
27
|
+
overview: ProviderOverview | undefined;
|
|
28
|
+
model: ModelInfo | undefined;
|
|
29
|
+
selection: Partial<Selection>;
|
|
30
|
+
/** A full Selection once provider, model, folder and access are all set. */
|
|
31
|
+
value: Selection | undefined;
|
|
32
|
+
set: (patch: Partial<Selection>) => void;
|
|
33
|
+
loading: boolean;
|
|
34
|
+
error: string | undefined;
|
|
35
|
+
refresh: () => Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
/** Sign-in, sign-out and one-click update for one provider. */
|
|
38
|
+
declare function useAuth(client: FunnelClient, provider: Selection["provider"], onChange?: () => void): {
|
|
39
|
+
status: AuthStatus | undefined;
|
|
40
|
+
events: LoginEvent[];
|
|
41
|
+
busy: "login" | "logout" | "update" | undefined;
|
|
42
|
+
message: string | undefined;
|
|
43
|
+
login: () => Promise<void>;
|
|
44
|
+
sendCode: (code: string) => "" | Promise<{
|
|
45
|
+
ok: boolean;
|
|
46
|
+
}> | undefined;
|
|
47
|
+
cancel: () => void;
|
|
48
|
+
logout: () => Promise<void>;
|
|
49
|
+
update: () => Promise<void>;
|
|
50
|
+
refresh: () => Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
/** Runs prompts and exposes streaming text, tool calls and pending approvals. */
|
|
53
|
+
declare function useRun(client: FunnelClient): {
|
|
54
|
+
text: string;
|
|
55
|
+
events: FunnelEvent[];
|
|
56
|
+
result: {
|
|
57
|
+
text: string;
|
|
58
|
+
sessionId: string | undefined;
|
|
59
|
+
model: string | undefined;
|
|
60
|
+
usage: Usage | undefined;
|
|
61
|
+
finishReason: string | undefined;
|
|
62
|
+
toolCalls: {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
input?: unknown;
|
|
66
|
+
error?: string;
|
|
67
|
+
}[];
|
|
68
|
+
};
|
|
69
|
+
approvals: ApprovalRequest[];
|
|
70
|
+
status: "error" | "done" | "idle" | "running";
|
|
71
|
+
error: string | undefined;
|
|
72
|
+
/** The conversation `send` will continue, or undefined when the next run starts fresh. */
|
|
73
|
+
session: {
|
|
74
|
+
id: string;
|
|
75
|
+
provider: string;
|
|
76
|
+
cwd: string;
|
|
77
|
+
} | undefined;
|
|
78
|
+
/** Same as `session?.id`. */
|
|
79
|
+
sessionId: string | undefined;
|
|
80
|
+
send: (prompt: string, selection: Selection) => Promise<void>;
|
|
81
|
+
approve: (id: string, decision: ApprovalDecision) => Promise<void>;
|
|
82
|
+
cancel: () => void | undefined;
|
|
83
|
+
newConversation: () => void;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
interface FunnelPickerProps {
|
|
87
|
+
client: FunnelClient;
|
|
88
|
+
/** Starting values. The user can change them. */
|
|
89
|
+
defaults?: Partial<Selection>;
|
|
90
|
+
/** Fixed values. These fields are hidden and always applied. Use it to hardcode a choice. */
|
|
91
|
+
locked?: Partial<Selection>;
|
|
92
|
+
/** Called whenever the selection changes. `undefined` until provider, model, folder and access are all set. */
|
|
93
|
+
onChange?: (selection: Selection | undefined) => void;
|
|
94
|
+
className?: string;
|
|
95
|
+
}
|
|
96
|
+
type SelectionState = ReturnType<typeof useSelection>;
|
|
97
|
+
declare function FunnelPicker({ client, defaults, locked, onChange, className }: FunnelPickerProps): react.JSX.Element;
|
|
98
|
+
/** The picker markup on top of a `useSelection` state you own. Use it when other parts of your UI need the same state. */
|
|
99
|
+
declare function FunnelPickerView({ client, state: s, locked, className }: {
|
|
100
|
+
client: FunnelClient;
|
|
101
|
+
state: SelectionState;
|
|
102
|
+
locked?: Partial<Selection>;
|
|
103
|
+
className?: string;
|
|
104
|
+
}): react.JSX.Element;
|
|
105
|
+
|
|
106
|
+
interface FunnelLoginProps {
|
|
107
|
+
client: FunnelClient;
|
|
108
|
+
provider: ProviderOverview;
|
|
109
|
+
/** Called after sign-in, sign-out or update so the parent can refresh its provider list. */
|
|
110
|
+
onChange?: () => void;
|
|
111
|
+
className?: string;
|
|
112
|
+
}
|
|
113
|
+
/** Status, sign-in, sign-out and one-click CLI update for one provider. */
|
|
114
|
+
declare function FunnelLogin({ client, provider, onChange, className }: FunnelLoginProps): react.JSX.Element;
|
|
115
|
+
|
|
116
|
+
interface ApprovalPromptProps {
|
|
117
|
+
request: ApprovalRequest;
|
|
118
|
+
onDecide: (id: string, decision: ApprovalDecision) => void;
|
|
119
|
+
className?: string;
|
|
120
|
+
}
|
|
121
|
+
/** The approve button for supervised runs. Render one per entry in `useRun().approvals`. */
|
|
122
|
+
declare function ApprovalPrompt({ request, onDecide, className }: ApprovalPromptProps): react.JSX.Element;
|
|
123
|
+
|
|
124
|
+
export { ApprovalPrompt, type ApprovalPromptProps, FunnelLogin, type FunnelLoginProps, FunnelPicker, type FunnelPickerProps, FunnelPickerView, type SelectionField, type SelectionState, useAuth, useProviders, useRun, useSelection };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// src/hooks.ts
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
function useProviders(client) {
|
|
4
|
+
const [providers, setProviders] = useState([]);
|
|
5
|
+
const [loading, setLoading] = useState(true);
|
|
6
|
+
const [error, setError] = useState();
|
|
7
|
+
const refresh = useCallback(async () => {
|
|
8
|
+
try {
|
|
9
|
+
setProviders(await client.providers());
|
|
10
|
+
setError(void 0);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
13
|
+
} finally {
|
|
14
|
+
setLoading(false);
|
|
15
|
+
}
|
|
16
|
+
}, [client]);
|
|
17
|
+
useEffect(() => void refresh(), [refresh]);
|
|
18
|
+
return { providers, loading, error, refresh };
|
|
19
|
+
}
|
|
20
|
+
function useSelection(client, opts = {}) {
|
|
21
|
+
const { providers, loading: loadingProviders, error, refresh } = useProviders(client);
|
|
22
|
+
const [loaded, setLoaded] = useState();
|
|
23
|
+
const [draft, setDraft] = useState({ ...opts.defaults, ...opts.locked });
|
|
24
|
+
const locked = opts.locked ?? {};
|
|
25
|
+
const providerId = draft.provider ?? providers.find((p) => p.installation.installed && p.auth?.loggedIn)?.id ?? providers.find((p) => p.installation.installed)?.id;
|
|
26
|
+
const overview = providers.find((p) => p.id === providerId);
|
|
27
|
+
const models = useMemo(() => loaded && loaded.provider === providerId ? loaded.models : [], [loaded, providerId]);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!providerId) return;
|
|
30
|
+
let live = true;
|
|
31
|
+
client.models(providerId).then((m) => live && setLoaded({ provider: providerId, models: m }), () => live && setLoaded({ provider: providerId, models: [] }));
|
|
32
|
+
return () => {
|
|
33
|
+
live = false;
|
|
34
|
+
};
|
|
35
|
+
}, [client, providerId]);
|
|
36
|
+
const selection = useMemo(() => {
|
|
37
|
+
if (!providerId || !overview) return { ...draft, ...locked };
|
|
38
|
+
const cap = overview.capabilities;
|
|
39
|
+
const picked = opts.pick?.(providerId, models);
|
|
40
|
+
const model = models.find((m) => m.id === draft.model) ?? models.find((m) => m.id === picked?.model) ?? models[0];
|
|
41
|
+
const pickedEffort = model?.id === picked?.model && model?.efforts.some((e) => e.id === picked?.effort) ? picked?.effort : void 0;
|
|
42
|
+
const effort = model?.efforts.some((e) => e.id === draft.effort) ? draft.effort : pickedEffort ?? model?.defaultEffort ?? model?.efforts[0]?.id;
|
|
43
|
+
const contextWindow = model?.contextWindows.includes(draft.contextWindow ?? -1) ? draft.contextWindow : model?.defaultContextWindow ?? model?.contextWindows[0];
|
|
44
|
+
const access = cap.access.includes(draft.access) ? draft.access : cap.access[0];
|
|
45
|
+
return {
|
|
46
|
+
provider: providerId,
|
|
47
|
+
model: model?.id,
|
|
48
|
+
effort: cap.effort ? effort : void 0,
|
|
49
|
+
contextWindow: cap.contextWindow ? contextWindow : void 0,
|
|
50
|
+
fast: cap.fast && model?.fast ? !!draft.fast : void 0,
|
|
51
|
+
cwd: draft.cwd,
|
|
52
|
+
access,
|
|
53
|
+
...locked
|
|
54
|
+
};
|
|
55
|
+
}, [draft, models, overview, providerId, locked, opts.pick]);
|
|
56
|
+
const set = useCallback(
|
|
57
|
+
(patch) => setDraft((d) => {
|
|
58
|
+
const switching = patch.provider !== void 0 && patch.provider !== d.provider;
|
|
59
|
+
return { ...d, ...switching ? { model: void 0, effort: void 0, contextWindow: void 0, fast: void 0 } : {}, ...patch };
|
|
60
|
+
}),
|
|
61
|
+
[]
|
|
62
|
+
);
|
|
63
|
+
const complete = !!(selection.provider && selection.model && selection.cwd && selection.access);
|
|
64
|
+
return {
|
|
65
|
+
providers,
|
|
66
|
+
models,
|
|
67
|
+
overview,
|
|
68
|
+
model: models.find((m) => m.id === selection.model),
|
|
69
|
+
selection,
|
|
70
|
+
/** A full Selection once provider, model, folder and access are all set. */
|
|
71
|
+
value: complete ? selection : void 0,
|
|
72
|
+
set,
|
|
73
|
+
loading: loadingProviders,
|
|
74
|
+
error,
|
|
75
|
+
refresh
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function useAuth(client, provider, onChange) {
|
|
79
|
+
const [status, setStatus] = useState();
|
|
80
|
+
const [events, setEvents] = useState([]);
|
|
81
|
+
const [busy, setBusy] = useState();
|
|
82
|
+
const [message, setMessage] = useState();
|
|
83
|
+
const loginId = useRef(void 0);
|
|
84
|
+
const abort = useRef(void 0);
|
|
85
|
+
const refresh = useCallback(async () => {
|
|
86
|
+
setStatus(await client.authStatus(provider).catch(() => ({ loggedIn: false })));
|
|
87
|
+
}, [client, provider]);
|
|
88
|
+
useEffect(() => void refresh(), [refresh]);
|
|
89
|
+
const login = useCallback(async () => {
|
|
90
|
+
setBusy("login");
|
|
91
|
+
setEvents([]);
|
|
92
|
+
abort.current = new AbortController();
|
|
93
|
+
try {
|
|
94
|
+
for await (const e of client.login(provider, abort.current.signal)) {
|
|
95
|
+
if (e.type === "login") loginId.current = e.loginId;
|
|
96
|
+
else setEvents((prev) => [...prev, e]);
|
|
97
|
+
}
|
|
98
|
+
} catch (e) {
|
|
99
|
+
setEvents((prev) => [...prev, { type: "error", message: e instanceof Error ? e.message : String(e) }]);
|
|
100
|
+
} finally {
|
|
101
|
+
setBusy(void 0);
|
|
102
|
+
await refresh();
|
|
103
|
+
onChange?.();
|
|
104
|
+
}
|
|
105
|
+
}, [client, provider, refresh, onChange]);
|
|
106
|
+
const sendCode = useCallback((code) => loginId.current && client.sendLoginCode(loginId.current, code), [client]);
|
|
107
|
+
const cancel = useCallback(() => {
|
|
108
|
+
if (loginId.current) void client.cancelLogin(loginId.current);
|
|
109
|
+
abort.current?.abort();
|
|
110
|
+
}, [client]);
|
|
111
|
+
const logout = useCallback(async () => {
|
|
112
|
+
setBusy("logout");
|
|
113
|
+
try {
|
|
114
|
+
await client.logout(provider);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
setMessage(e instanceof Error ? e.message : String(e));
|
|
117
|
+
}
|
|
118
|
+
setBusy(void 0);
|
|
119
|
+
await refresh();
|
|
120
|
+
onChange?.();
|
|
121
|
+
}, [client, provider, refresh, onChange]);
|
|
122
|
+
const update = useCallback(async () => {
|
|
123
|
+
setBusy("update");
|
|
124
|
+
try {
|
|
125
|
+
const r = await client.update(provider);
|
|
126
|
+
setMessage(r.changed ? `Updated ${r.from} to ${r.to}` : `Already up to date${r.to ? ` (${r.to})` : ""}`);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
setMessage(e instanceof Error ? e.message : String(e));
|
|
129
|
+
}
|
|
130
|
+
setBusy(void 0);
|
|
131
|
+
onChange?.();
|
|
132
|
+
}, [client, provider, onChange]);
|
|
133
|
+
return { status, events, busy, message, login, sendCode, cancel, logout, update, refresh };
|
|
134
|
+
}
|
|
135
|
+
function useRun(client) {
|
|
136
|
+
const [text, setText] = useState("");
|
|
137
|
+
const [events, setEvents] = useState([]);
|
|
138
|
+
const [approvals, setApprovals] = useState([]);
|
|
139
|
+
const [status, setStatus] = useState("idle");
|
|
140
|
+
const [error, setError] = useState();
|
|
141
|
+
const [session, setSession] = useState();
|
|
142
|
+
const runId = useRef(void 0);
|
|
143
|
+
const abort = useRef(void 0);
|
|
144
|
+
const send = useCallback(
|
|
145
|
+
async (prompt, selection) => {
|
|
146
|
+
abort.current = new AbortController();
|
|
147
|
+
setText("");
|
|
148
|
+
setEvents([]);
|
|
149
|
+
setApprovals([]);
|
|
150
|
+
setError(void 0);
|
|
151
|
+
setStatus("running");
|
|
152
|
+
try {
|
|
153
|
+
const resume = session && session.provider === selection.provider && session.cwd === selection.cwd ? session.id : void 0;
|
|
154
|
+
for await (const e of client.run({ prompt, selection, sessionId: resume }, abort.current.signal)) {
|
|
155
|
+
if (e.type === "run") {
|
|
156
|
+
runId.current = e.runId;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
setEvents((prev) => [...prev, e]);
|
|
160
|
+
if (e.type === "text.delta") setText((t) => t + e.text);
|
|
161
|
+
else if (e.type === "session") setSession({ id: e.sessionId, provider: selection.provider, cwd: selection.cwd });
|
|
162
|
+
else if (e.type === "approval.request") setApprovals((a) => [...a, e.request]);
|
|
163
|
+
else if (e.type === "error") {
|
|
164
|
+
setError(e.message);
|
|
165
|
+
setStatus("error");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
setStatus((s) => s === "error" ? s : "done");
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (!abort.current.signal.aborted) {
|
|
171
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
172
|
+
setStatus("error");
|
|
173
|
+
} else setStatus("done");
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
[client, session]
|
|
177
|
+
);
|
|
178
|
+
const approve = useCallback(
|
|
179
|
+
async (id, decision) => {
|
|
180
|
+
setApprovals((a) => a.filter((r) => r.id !== id));
|
|
181
|
+
if (runId.current) await client.approve(runId.current, id, decision);
|
|
182
|
+
},
|
|
183
|
+
[client]
|
|
184
|
+
);
|
|
185
|
+
const result = useMemo(() => {
|
|
186
|
+
const out = { text: "", sessionId: void 0, model: void 0, usage: void 0, finishReason: void 0, toolCalls: [] };
|
|
187
|
+
for (const e of events) {
|
|
188
|
+
if (e.type === "session") {
|
|
189
|
+
out.sessionId = e.sessionId;
|
|
190
|
+
out.model = e.model ?? out.model;
|
|
191
|
+
} else if (e.type === "text.delta") out.text += e.text;
|
|
192
|
+
else if (e.type === "tool.start") out.toolCalls.push({ id: e.id, name: e.name, input: e.input });
|
|
193
|
+
else if (e.type === "tool.end") {
|
|
194
|
+
const call = out.toolCalls.find((c) => c.id === e.id);
|
|
195
|
+
if (call && e.error) call.error = e.error;
|
|
196
|
+
} else if (e.type === "usage") out.usage = e.usage;
|
|
197
|
+
else if (e.type === "done") out.finishReason = e.finishReason;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}, [events]);
|
|
201
|
+
const cancel = useCallback(() => abort.current?.abort(), []);
|
|
202
|
+
const reset = useCallback(() => setSession(void 0), []);
|
|
203
|
+
return {
|
|
204
|
+
text,
|
|
205
|
+
events,
|
|
206
|
+
result,
|
|
207
|
+
approvals,
|
|
208
|
+
status,
|
|
209
|
+
error,
|
|
210
|
+
/** The conversation `send` will continue, or undefined when the next run starts fresh. */
|
|
211
|
+
session,
|
|
212
|
+
/** Same as `session?.id`. */
|
|
213
|
+
sessionId: session?.id,
|
|
214
|
+
send,
|
|
215
|
+
approve,
|
|
216
|
+
cancel,
|
|
217
|
+
newConversation: reset
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/FunnelPicker.tsx
|
|
222
|
+
import { useEffect as useEffect2, useId, useState as useState2 } from "react";
|
|
223
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
224
|
+
var ACCESS_LABELS = {
|
|
225
|
+
supervised: { label: "Supervised", hint: "Asks before every action" },
|
|
226
|
+
"accept-edits": { label: "Accept edits", hint: "Edits files freely, asks for the rest" },
|
|
227
|
+
auto: { label: "Auto", hint: "A classifier approves safe actions" },
|
|
228
|
+
full: { label: "Full access", hint: "Runs anything without asking" }
|
|
229
|
+
};
|
|
230
|
+
var fmtTokens = (n) => n >= 1e6 ? `${n / 1e6}M` : `${n / 1e3}k`;
|
|
231
|
+
function FunnelPicker({ client, defaults, locked, onChange, className }) {
|
|
232
|
+
const s = useSelection(client, { defaults, locked });
|
|
233
|
+
useEffect2(() => onChange?.(s.value), [s.value && JSON.stringify(s.value)]);
|
|
234
|
+
return /* @__PURE__ */ jsx(FunnelPickerView, { client, state: s, locked, className });
|
|
235
|
+
}
|
|
236
|
+
function FunnelPickerView({ client, state: s, locked, className }) {
|
|
237
|
+
const id = useId();
|
|
238
|
+
const { selection, model, overview } = s;
|
|
239
|
+
const cap = overview?.capabilities;
|
|
240
|
+
if (s.loading) return /* @__PURE__ */ jsx("div", { className: `cf ${className ?? ""}`, children: "Loading providers\u2026" });
|
|
241
|
+
if (s.error) return /* @__PURE__ */ jsx("div", { className: `cf cf-error ${className ?? ""}`, children: s.error });
|
|
242
|
+
const installed = s.providers.filter((p) => p.installation.installed);
|
|
243
|
+
const show = (k) => !(locked && k in locked);
|
|
244
|
+
return /* @__PURE__ */ jsxs("div", { className: `cf cf-picker ${className ?? ""}`, children: [
|
|
245
|
+
show("provider") && /* @__PURE__ */ jsxs("label", { className: "cf-field", children: [
|
|
246
|
+
/* @__PURE__ */ jsx("span", { children: "Provider" }),
|
|
247
|
+
/* @__PURE__ */ jsx("select", { value: selection.provider ?? "", onChange: (e) => s.set({ provider: e.target.value }), children: installed.map((p) => /* @__PURE__ */ jsxs("option", { value: p.id, children: [
|
|
248
|
+
p.displayName,
|
|
249
|
+
p.auth?.loggedIn ? "" : " (signed out)"
|
|
250
|
+
] }, p.id)) }),
|
|
251
|
+
installed.length === 0 && /* @__PURE__ */ jsx("small", { children: "No supported CLI found. Run \u201Ccli-funnel doctor\u201D." })
|
|
252
|
+
] }),
|
|
253
|
+
show("model") && /* @__PURE__ */ jsxs("label", { className: "cf-field", children: [
|
|
254
|
+
/* @__PURE__ */ jsx("span", { children: "Model" }),
|
|
255
|
+
/* @__PURE__ */ jsx("select", { value: selection.model ?? "", onChange: (e) => s.set({ model: e.target.value }), children: s.models.map((m) => /* @__PURE__ */ jsx("option", { value: m.id, children: m.name }, m.id)) }),
|
|
256
|
+
selection.model && /* @__PURE__ */ jsx("small", { className: "cf-mono", children: selection.model })
|
|
257
|
+
] }),
|
|
258
|
+
cap?.effort && model && model.efforts.length > 0 && show("effort") && /* @__PURE__ */ jsxs("label", { className: "cf-field", children: [
|
|
259
|
+
/* @__PURE__ */ jsx("span", { children: "Reasoning" }),
|
|
260
|
+
/* @__PURE__ */ jsx("select", { value: selection.effort ?? "", onChange: (e) => s.set({ effort: e.target.value }), children: model.efforts.map((e) => /* @__PURE__ */ jsx("option", { value: e.id, children: e.label }, e.id)) })
|
|
261
|
+
] }),
|
|
262
|
+
cap?.contextWindow && model && model.contextWindows.length > 0 && show("contextWindow") && /* @__PURE__ */ jsxs("label", { className: "cf-field", children: [
|
|
263
|
+
/* @__PURE__ */ jsx("span", { children: "Context window" }),
|
|
264
|
+
/* @__PURE__ */ jsx("select", { value: selection.contextWindow ?? "", onChange: (e) => s.set({ contextWindow: Number(e.target.value) }), children: model.contextWindows.map((c) => /* @__PURE__ */ jsxs("option", { value: c, children: [
|
|
265
|
+
fmtTokens(c),
|
|
266
|
+
" tokens"
|
|
267
|
+
] }, c)) })
|
|
268
|
+
] }),
|
|
269
|
+
cap?.fast && model?.fast && show("fast") && /* @__PURE__ */ jsxs("label", { className: "cf-check", children: [
|
|
270
|
+
/* @__PURE__ */ jsx("input", { type: "checkbox", checked: !!selection.fast, onChange: (e) => s.set({ fast: e.target.checked }) }),
|
|
271
|
+
/* @__PURE__ */ jsx("span", { children: "Fast mode" }),
|
|
272
|
+
/* @__PURE__ */ jsx("small", { children: "Quicker output, uses your quota faster" })
|
|
273
|
+
] }),
|
|
274
|
+
show("cwd") && /* @__PURE__ */ jsx(FolderField, { client, value: selection.cwd, onChange: (cwd) => s.set({ cwd }), labelId: id }),
|
|
275
|
+
cap && show("access") && /* @__PURE__ */ jsxs("fieldset", { className: "cf-field cf-access", children: [
|
|
276
|
+
/* @__PURE__ */ jsx("legend", { children: "Machine access" }),
|
|
277
|
+
cap.access.map((a) => /* @__PURE__ */ jsxs("label", { className: selection.access === a ? "cf-option cf-on" : "cf-option", children: [
|
|
278
|
+
/* @__PURE__ */ jsx("input", { type: "radio", name: `${id}-access`, checked: selection.access === a, onChange: () => s.set({ access: a }) }),
|
|
279
|
+
/* @__PURE__ */ jsx("span", { children: ACCESS_LABELS[a].label }),
|
|
280
|
+
/* @__PURE__ */ jsx("small", { children: ACCESS_LABELS[a].hint })
|
|
281
|
+
] }, a))
|
|
282
|
+
] })
|
|
283
|
+
] });
|
|
284
|
+
}
|
|
285
|
+
function FolderField({ client, value, onChange, labelId }) {
|
|
286
|
+
const [open, setOpen] = useState2(false);
|
|
287
|
+
const [listing, setListing] = useState2();
|
|
288
|
+
const [err, setErr] = useState2();
|
|
289
|
+
const load = (path) => client.listDirs(path).then(
|
|
290
|
+
(l) => {
|
|
291
|
+
setListing(l);
|
|
292
|
+
setErr(void 0);
|
|
293
|
+
},
|
|
294
|
+
(e) => setErr(e instanceof Error ? e.message : String(e))
|
|
295
|
+
);
|
|
296
|
+
return /* @__PURE__ */ jsxs("div", { className: "cf-field cf-folder", children: [
|
|
297
|
+
/* @__PURE__ */ jsx("label", { htmlFor: `${labelId}-cwd`, children: "Project folder" }),
|
|
298
|
+
/* @__PURE__ */ jsxs("div", { className: "cf-row", children: [
|
|
299
|
+
/* @__PURE__ */ jsx("input", { id: `${labelId}-cwd`, className: "cf-mono", value: value ?? "", placeholder: "/path/to/project", onChange: (e) => onChange(e.target.value) }),
|
|
300
|
+
/* @__PURE__ */ jsx(
|
|
301
|
+
"button",
|
|
302
|
+
{
|
|
303
|
+
type: "button",
|
|
304
|
+
onClick: () => {
|
|
305
|
+
setOpen((o) => !o);
|
|
306
|
+
if (!open) void load(value);
|
|
307
|
+
},
|
|
308
|
+
children: "Browse"
|
|
309
|
+
}
|
|
310
|
+
)
|
|
311
|
+
] }),
|
|
312
|
+
open && /* @__PURE__ */ jsxs("div", { className: "cf-browser", role: "listbox", "aria-label": "Folders", children: [
|
|
313
|
+
err && /* @__PURE__ */ jsx("small", { className: "cf-error", children: err }),
|
|
314
|
+
listing && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
315
|
+
/* @__PURE__ */ jsx("div", { className: "cf-crumb cf-mono", children: listing.path }),
|
|
316
|
+
/* @__PURE__ */ jsxs("div", { className: "cf-list", children: [
|
|
317
|
+
listing.parent && /* @__PURE__ */ jsx("button", { type: "button", onClick: () => load(listing.parent), children: ".." }),
|
|
318
|
+
listing.dirs.map((d) => /* @__PURE__ */ jsx("button", { type: "button", onClick: () => load(`${listing.path}/${d}`), children: d }, d))
|
|
319
|
+
] }),
|
|
320
|
+
/* @__PURE__ */ jsx(
|
|
321
|
+
"button",
|
|
322
|
+
{
|
|
323
|
+
type: "button",
|
|
324
|
+
className: "cf-primary",
|
|
325
|
+
onClick: () => {
|
|
326
|
+
onChange(listing.path);
|
|
327
|
+
setOpen(false);
|
|
328
|
+
},
|
|
329
|
+
children: "Use this folder"
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
] })
|
|
333
|
+
] })
|
|
334
|
+
] });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/FunnelLogin.tsx
|
|
338
|
+
import { useState as useState3 } from "react";
|
|
339
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
340
|
+
function FunnelLogin({ client, provider, onChange, className }) {
|
|
341
|
+
const a = useAuth(client, provider.id, onChange);
|
|
342
|
+
const [code, setCode] = useState3("");
|
|
343
|
+
const install = provider.installation;
|
|
344
|
+
const url = [...a.events].reverse().find((e) => e.type === "open-url");
|
|
345
|
+
const prompt = [...a.events].reverse().find((e) => e.type === "code-prompt");
|
|
346
|
+
const terminal = a.events.find((e) => e.type === "needs-terminal");
|
|
347
|
+
const failure = [...a.events].reverse().find((e) => e.type === "error");
|
|
348
|
+
return /* @__PURE__ */ jsxs2("section", { className: `cf cf-login ${className ?? ""}`, children: [
|
|
349
|
+
/* @__PURE__ */ jsxs2("header", { children: [
|
|
350
|
+
/* @__PURE__ */ jsx2("h3", { children: provider.displayName }),
|
|
351
|
+
/* @__PURE__ */ jsx2("span", { className: "cf-mono", children: install.installed ? install.version ?? "installed" : "not installed" })
|
|
352
|
+
] }),
|
|
353
|
+
!install.installed ? /* @__PURE__ */ jsxs2("p", { children: [
|
|
354
|
+
"Install the ",
|
|
355
|
+
provider.displayName,
|
|
356
|
+
" CLI, then reload."
|
|
357
|
+
] }) : /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
358
|
+
/* @__PURE__ */ jsxs2("p", { children: [
|
|
359
|
+
a.status?.loggedIn ? `Signed in${a.status.account ? ` as ${a.status.account}` : ""}${a.status.plan ? `, ${a.status.plan} plan` : ""}.` : "Signed out.",
|
|
360
|
+
install.withinTestedRange === false && " This CLI version is newer or older than the tested range."
|
|
361
|
+
] }),
|
|
362
|
+
/* @__PURE__ */ jsxs2("div", { className: "cf-row", children: [
|
|
363
|
+
a.status?.loggedIn ? /* @__PURE__ */ jsx2("button", { type: "button", onClick: a.logout, disabled: !!a.busy, children: "Sign out" }) : /* @__PURE__ */ jsx2("button", { type: "button", className: "cf-primary", onClick: a.login, disabled: a.busy === "login", children: a.busy === "login" ? "Waiting for sign-in\u2026" : "Sign in" }),
|
|
364
|
+
/* @__PURE__ */ jsx2("button", { type: "button", onClick: a.update, disabled: !!a.busy, children: a.busy === "update" ? "Updating\u2026" : "Update CLI" }),
|
|
365
|
+
a.busy === "login" && /* @__PURE__ */ jsx2("button", { type: "button", onClick: a.cancel, children: "Cancel" })
|
|
366
|
+
] })
|
|
367
|
+
] }),
|
|
368
|
+
url?.type === "open-url" && /* @__PURE__ */ jsxs2("p", { children: [
|
|
369
|
+
"Open ",
|
|
370
|
+
/* @__PURE__ */ jsx2("a", { href: url.url, target: "_blank", rel: "noreferrer", children: "the sign-in page" }),
|
|
371
|
+
" if it did not open on its own."
|
|
372
|
+
] }),
|
|
373
|
+
prompt?.type === "code-prompt" && /* @__PURE__ */ jsxs2(
|
|
374
|
+
"form",
|
|
375
|
+
{
|
|
376
|
+
className: "cf-row",
|
|
377
|
+
onSubmit: (e) => {
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
void a.sendCode(code);
|
|
380
|
+
setCode("");
|
|
381
|
+
},
|
|
382
|
+
children: [
|
|
383
|
+
/* @__PURE__ */ jsx2("input", { value: code, onChange: (e) => setCode(e.target.value), placeholder: "Paste the code", "aria-label": "Sign-in code" }),
|
|
384
|
+
/* @__PURE__ */ jsx2("button", { type: "submit", children: "Submit" })
|
|
385
|
+
]
|
|
386
|
+
}
|
|
387
|
+
),
|
|
388
|
+
terminal?.type === "needs-terminal" && /* @__PURE__ */ jsxs2("p", { children: [
|
|
389
|
+
"This provider signs in from a terminal. Run ",
|
|
390
|
+
/* @__PURE__ */ jsx2("code", { className: "cf-mono", children: terminal.command.join(" ") }),
|
|
391
|
+
", then come back."
|
|
392
|
+
] }),
|
|
393
|
+
failure?.type === "error" && /* @__PURE__ */ jsx2("p", { className: "cf-error", children: failure.message }),
|
|
394
|
+
a.message && /* @__PURE__ */ jsx2("p", { children: a.message })
|
|
395
|
+
] });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// src/ApprovalPrompt.tsx
|
|
399
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
400
|
+
function ApprovalPrompt({ request, onDecide, className }) {
|
|
401
|
+
const detail = typeof request.input === "string" ? request.input : JSON.stringify(request.input, null, 2);
|
|
402
|
+
return /* @__PURE__ */ jsxs3("div", { className: `cf cf-approval ${className ?? ""}`, role: "alertdialog", "aria-label": `Approve ${request.tool}`, children: [
|
|
403
|
+
/* @__PURE__ */ jsx3("strong", { children: request.tool }),
|
|
404
|
+
request.description && /* @__PURE__ */ jsx3("p", { children: request.description }),
|
|
405
|
+
/* @__PURE__ */ jsx3("pre", { className: "cf-mono", children: detail }),
|
|
406
|
+
/* @__PURE__ */ jsxs3("div", { className: "cf-row", children: [
|
|
407
|
+
/* @__PURE__ */ jsx3("button", { type: "button", className: "cf-primary", onClick: () => onDecide(request.id, "allow"), children: "Allow" }),
|
|
408
|
+
/* @__PURE__ */ jsx3("button", { type: "button", onClick: () => onDecide(request.id, "deny"), children: "Deny" })
|
|
409
|
+
] })
|
|
410
|
+
] });
|
|
411
|
+
}
|
|
412
|
+
export {
|
|
413
|
+
ApprovalPrompt,
|
|
414
|
+
FunnelLogin,
|
|
415
|
+
FunnelPicker,
|
|
416
|
+
FunnelPickerView,
|
|
417
|
+
useAuth,
|
|
418
|
+
useProviders,
|
|
419
|
+
useRun,
|
|
420
|
+
useSelection
|
|
421
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cli-funnel-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React hooks and components for cli-funnel: model picker, folder picker, access level, login and one-click update.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./styles.css": "./dist/funnel.css"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": [
|
|
18
|
+
"*.css"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"cli-funnel": "^0.1.0",
|
|
22
|
+
"react": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^19.0.0",
|
|
26
|
+
"react": "^19.0.0",
|
|
27
|
+
"cli-funnel": "*"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup && cp src/funnel.css dist/funnel.css",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/SuperTost100/cli-funnel.git",
|
|
37
|
+
"directory": "packages/react"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/SuperTost100/cli-funnel#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/SuperTost100/cli-funnel/issues"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"claude-code",
|
|
45
|
+
"codex",
|
|
46
|
+
"cursor-agent",
|
|
47
|
+
"antigravity",
|
|
48
|
+
"react",
|
|
49
|
+
"ai"
|
|
50
|
+
]
|
|
51
|
+
}
|