openpoly 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.
@@ -0,0 +1,276 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useApp, useInput, Static } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ import { Banner } from "./Banner.js";
6
+ import { Transcript } from "./Transcript.js";
7
+ import { PermissionDialog } from "./PermissionDialog.js";
8
+ import { StatusBar } from "./StatusBar.js";
9
+ import { ModelMenu } from "./ModelMenu.js";
10
+ import { KeyPrompt } from "./KeyPrompt.js";
11
+ export function App({ session, broker, config, initialModel, switchModel, listModels, onSaveKey, promptKeyOnStart, version, intro, }) {
12
+ const { exit } = useApp();
13
+ const [items, setItems] = useState(intro ? [{ id: 0, kind: "system", text: intro }] : []);
14
+ const [input, setInput] = useState("");
15
+ const [busy, setBusy] = useState(false);
16
+ const [modelName, setModelName] = useState(initialModel);
17
+ const [reasoning, setReasoning] = useState(session.getReasoning());
18
+ // A queue, not a single slot: the model can request several permissions at
19
+ // once (concurrent tool calls), and dropping any would hang the whole turn.
20
+ const [permQueue, setPermQueue] = useState([]);
21
+ const permQueueRef = useRef([]);
22
+ const [menuOpen, setMenuOpen] = useState(false);
23
+ const [menuLoading, setMenuLoading] = useState(false);
24
+ const [menuItems, setMenuItems] = useState([]);
25
+ const [keyPromptOpen, setKeyPromptOpen] = useState(!!promptKeyOnStart);
26
+ const modelCache = useRef(null);
27
+ // A message typed while the AI is busy; sent automatically when it finishes.
28
+ const pendingRef = useRef(null);
29
+ const idRef = useRef(1);
30
+ const anyModalOpen = permQueue.length > 0 || keyPromptOpen || menuOpen;
31
+ // Esc cancels the in-flight turn — a guaranteed way to get the input back if
32
+ // a response ever stalls. Ignored while a modal owns the keyboard.
33
+ useInput((_input, key) => {
34
+ if (key.escape && busy && !anyModalOpen) {
35
+ pendingRef.current = null;
36
+ session.abort();
37
+ }
38
+ }, { isActive: busy && !anyModalOpen });
39
+ const nextId = () => idRef.current++;
40
+ const add = (item) => setItems((prev) => [...prev, { ...item, id: nextId() }]);
41
+ // Wire up agent + permission events once.
42
+ useEffect(() => {
43
+ const onDelta = (delta) => setItems((prev) => {
44
+ const last = prev[prev.length - 1];
45
+ if (last && last.kind === "assistant") {
46
+ const copy = [...prev];
47
+ copy[copy.length - 1] = { ...last, text: last.text + delta };
48
+ return copy;
49
+ }
50
+ return [...prev, { id: nextId(), kind: "assistant", text: delta }];
51
+ });
52
+ const onToolCall = ({ toolName, args }) => setItems((prev) => [
53
+ ...prev,
54
+ {
55
+ id: nextId(),
56
+ kind: "tool",
57
+ name: toolName,
58
+ summary: summarizeArgs(toolName, args),
59
+ status: "running",
60
+ },
61
+ ]);
62
+ const onToolResult = ({ toolName, result, }) => setItems((prev) => {
63
+ for (let i = prev.length - 1; i >= 0; i--) {
64
+ const it = prev[i];
65
+ if (it.kind === "tool" && it.name === toolName && it.status === "running") {
66
+ const copy = [...prev];
67
+ copy[i] = { ...it, status: "done", result: String(result ?? "") };
68
+ return copy;
69
+ }
70
+ }
71
+ return prev;
72
+ });
73
+ const onError = (message) => add({ kind: "error", text: message });
74
+ const onDone = () => {
75
+ setBusy(false);
76
+ // Flush a message the user queued while the AI was working.
77
+ const queued = pendingRef.current;
78
+ if (queued) {
79
+ pendingRef.current = null;
80
+ add({ kind: "user", text: queued });
81
+ setBusy(true);
82
+ void session.send(queued);
83
+ }
84
+ };
85
+ const onRequest = (req) => {
86
+ permQueueRef.current = [...permQueueRef.current, req];
87
+ setPermQueue(permQueueRef.current);
88
+ };
89
+ session.on("text-delta", onDelta);
90
+ session.on("tool-call", onToolCall);
91
+ session.on("tool-result", onToolResult);
92
+ session.on("error", onError);
93
+ session.on("done", onDone);
94
+ broker.on("request", onRequest);
95
+ return () => {
96
+ session.off("text-delta", onDelta);
97
+ session.off("tool-call", onToolCall);
98
+ session.off("tool-result", onToolResult);
99
+ session.off("error", onError);
100
+ session.off("done", onDone);
101
+ broker.off("request", onRequest);
102
+ };
103
+ }, [session, broker]);
104
+ const decide = (decision) => {
105
+ const [first, ...rest] = permQueueRef.current;
106
+ if (!first)
107
+ return;
108
+ first.respond(decision);
109
+ permQueueRef.current = rest;
110
+ setPermQueue(rest);
111
+ };
112
+ // Configured models, used as a fallback if the live model list can't load.
113
+ const configMenuItems = () => config.models.map((m) => ({
114
+ label: `${m.name === modelName ? "● " : " "}${m.name}`,
115
+ value: m.name,
116
+ }));
117
+ const infosToItems = (infos) => infos.map((m) => ({
118
+ label: `${m.id === modelName ? "● " : " "}${m.id}${m.free ? " ·free" : ""}`,
119
+ value: m.id,
120
+ }));
121
+ // Open the picker pre-populated with all tool-capable models (free first),
122
+ // fetched live and cached after the first open. Falls back to configured
123
+ // models on failure.
124
+ const openMenu = () => {
125
+ setMenuOpen(true);
126
+ if (modelCache.current) {
127
+ setMenuItems(infosToItems(modelCache.current));
128
+ setMenuLoading(false);
129
+ return;
130
+ }
131
+ setMenuItems([]);
132
+ setMenuLoading(true);
133
+ listModels()
134
+ .then((infos) => {
135
+ modelCache.current = infos;
136
+ setMenuItems(infosToItems(infos));
137
+ setMenuLoading(false);
138
+ })
139
+ .catch((err) => {
140
+ setMenuItems(configMenuItems());
141
+ setMenuLoading(false);
142
+ add({
143
+ kind: "error",
144
+ text: `Could not load free models (${err instanceof Error ? err.message : String(err)}). Showing configured models.`,
145
+ });
146
+ });
147
+ };
148
+ const handleMenuSelect = (value) => {
149
+ setMenuOpen(false);
150
+ const result = switchModel(value);
151
+ add({ kind: result.ok ? "system" : "error", text: result.message });
152
+ if (result.ok && result.name)
153
+ setModelName(result.name);
154
+ };
155
+ const handleSaveKey = (key) => {
156
+ setKeyPromptOpen(false);
157
+ const result = onSaveKey(key);
158
+ add({
159
+ kind: result.ok ? "system" : "error",
160
+ text: result.ok ? "API key saved." : result.message,
161
+ });
162
+ if (result.ok && result.name)
163
+ setModelName(result.name);
164
+ };
165
+ const handleSubmit = (value) => {
166
+ const text = value.trim();
167
+ setInput("");
168
+ if (!text)
169
+ return;
170
+ if (text.startsWith("/")) {
171
+ handleCommand(text);
172
+ return;
173
+ }
174
+ if (busy) {
175
+ // Don't drop it — queue and send when the current turn finishes.
176
+ pendingRef.current = text;
177
+ add({ kind: "system", text: "(queued — will send when the reply finishes)" });
178
+ return;
179
+ }
180
+ add({ kind: "user", text });
181
+ setBusy(true);
182
+ void session.send(text);
183
+ };
184
+ const handleCommand = (text) => {
185
+ const [cmd, ...rest] = text.slice(1).split(/\s+/);
186
+ const arg = rest.join(" ").trim();
187
+ switch (cmd) {
188
+ case "help":
189
+ add({
190
+ kind: "system",
191
+ text: [
192
+ "Commands:",
193
+ " /think <level> reasoning effort: off|low|medium|high (lower = faster)",
194
+ " /key enter/update the API key for the current model",
195
+ " /model open the model picker (free first, then all)",
196
+ " /models same as /model",
197
+ " /model <name> switch directly (any OpenRouter model id works)",
198
+ " /clear clear the conversation",
199
+ " /help show this help",
200
+ " /exit quit polycode",
201
+ ].join("\n"),
202
+ });
203
+ break;
204
+ case "think": {
205
+ const levels = ["off", "low", "medium", "high"];
206
+ if (!arg) {
207
+ add({
208
+ kind: "system",
209
+ text: `Thinking level: ${reasoning}. Use /think <off|low|medium|high> (lower = faster).`,
210
+ });
211
+ }
212
+ else if (levels.includes(arg)) {
213
+ const level = arg;
214
+ session.setReasoning(level);
215
+ setReasoning(level);
216
+ add({ kind: "system", text: `Thinking level set to ${level}.` });
217
+ }
218
+ else {
219
+ add({
220
+ kind: "error",
221
+ text: `Unknown level "${arg}". Choose off, low, medium, or high.`,
222
+ });
223
+ }
224
+ break;
225
+ }
226
+ case "key":
227
+ setKeyPromptOpen(true);
228
+ break;
229
+ case "models":
230
+ openMenu();
231
+ break;
232
+ case "model":
233
+ if (!arg) {
234
+ openMenu();
235
+ }
236
+ else {
237
+ const result = switchModel(arg);
238
+ add({
239
+ kind: result.ok ? "system" : "error",
240
+ text: result.message,
241
+ });
242
+ if (result.ok && result.name)
243
+ setModelName(result.name);
244
+ }
245
+ break;
246
+ case "clear":
247
+ session.clearHistory();
248
+ setItems([]);
249
+ break;
250
+ case "exit":
251
+ case "quit":
252
+ exit();
253
+ break;
254
+ default:
255
+ add({ kind: "error", text: `Unknown command: /${cmd}` });
256
+ }
257
+ };
258
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Static, { items: [version], children: (v) => _jsx(Banner, { version: v }, "banner") }), _jsx(Transcript, { items: items }), permQueue.length > 0 ? (_jsx(PermissionDialog, { request: permQueue[0], queued: permQueue.length - 1, onDecide: decide })) : keyPromptOpen ? (_jsx(KeyPrompt, { label: `Enter the API key for ${modelName}`, hint: "OpenRouter keys are free (no credit card): https://openrouter.ai/keys", onSubmit: handleSaveKey, onCancel: () => setKeyPromptOpen(false) })) : menuOpen ? (_jsx(ModelMenu, { items: menuItems, current: modelName, loading: menuLoading, onSelect: handleMenuSelect, onCancel: () => setMenuOpen(false) })) : (_jsxs(Box, { borderStyle: "round", borderColor: busy ? "yellow" : "cyan", borderBottom: false, borderLeft: false, borderRight: false, marginTop: 1, children: [_jsx(Text, { color: busy ? "yellow" : "cyan", bold: true, children: "❯ " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit, placeholder: busy
259
+ ? "Working… type to queue a message, or Esc to cancel"
260
+ : "Ask polycode to build or change something…" })] })), _jsx(StatusBar, { modelName: modelName, busy: busy, reasoning: reasoning })] }));
261
+ }
262
+ function summarizeArgs(toolName, args) {
263
+ const a = (args ?? {});
264
+ switch (toolName) {
265
+ case "read_file":
266
+ case "write_file":
267
+ case "edit_file":
268
+ return String(a.path ?? "");
269
+ case "run_command":
270
+ return String(a.command ?? "");
271
+ case "search_code":
272
+ return String(a.pattern ?? "");
273
+ default:
274
+ return "";
275
+ }
276
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ /**
4
+ * One-time header shown at the top of the session. Rendered inside <Static> so
5
+ * it's printed once and scrolls naturally with the conversation.
6
+ */
7
+ export function Banner({ version }) {
8
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\u25C6 polycode" }), _jsx(Text, { dimColor: true, children: ` v${version}` })] }), _jsx(Text, { dimColor: true, children: "free, open-source AI models in your terminal \u2014 type a request, or /help" })] }));
9
+ }
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import TextInput from "ink-text-input";
5
+ /**
6
+ * Inline prompt to paste an API key straight into the terminal (masked). The
7
+ * key is saved locally and reused on later runs. Enter saves, Esc cancels.
8
+ */
9
+ export function KeyPrompt({ label, hint, onSubmit, onCancel, }) {
10
+ const [value, setValue] = useState("");
11
+ useInput((_input, key) => {
12
+ if (key.escape)
13
+ onCancel();
14
+ });
15
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: label }), hint ? _jsx(Text, { dimColor: true, children: hint }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: "key: " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: (v) => {
16
+ if (v.trim())
17
+ onSubmit(v.trim());
18
+ }, mask: "*", placeholder: "paste your key and press enter" })] }), _jsx(Text, { dimColor: true, children: "enter to save \u00B7 esc to cancel" })] }));
19
+ }
@@ -0,0 +1,22 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import SelectInput from "ink-select-input";
5
+ import TextInput from "ink-text-input";
6
+ /**
7
+ * Interactive model picker (like Claude Code's): opens already populated with
8
+ * the free models available. Type to filter, arrow keys to move, enter to pick,
9
+ * esc to cancel.
10
+ */
11
+ export function ModelMenu({ items, current, loading, onSelect, onCancel, }) {
12
+ const [query, setQuery] = useState("");
13
+ useInput((_input, key) => {
14
+ if (key.escape)
15
+ onCancel();
16
+ });
17
+ const q = query.trim().toLowerCase();
18
+ const filtered = q
19
+ ? items.filter((i) => i.value.toLowerCase().includes(q))
20
+ : items;
21
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { color: "magenta", bold: true, children: ["Select a model", items.length ? ` (${items.length} available)` : ""] }), _jsxs(Text, { dimColor: true, children: ["type to filter \u00B7 \u2191\u2193 move \u00B7 enter select \u00B7 esc cancel \u00B7 current: ", current] }), loading ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: "Loading free models\u2026" }) })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to search\u2026" })] }), filtered.length === 0 ? (_jsxs(Text, { dimColor: true, children: ["no models match \"", query, "\""] })) : (_jsx(SelectInput, { items: filtered, limit: 10, onSelect: (item) => onSelect(item.value) }, q))] }))] }));
22
+ }
@@ -0,0 +1,17 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ /**
4
+ * Modal-style prompt shown when a tool requests permission. Captures keystrokes
5
+ * while visible: (y) allow once, (a) always allow this tool, (n/esc) deny.
6
+ */
7
+ export function PermissionDialog({ request, queued = 0, onDecide, }) {
8
+ useInput((input, key) => {
9
+ if (key.escape || input === "n" || input === "d")
10
+ onDecide("deny");
11
+ else if (input === "y" || key.return)
12
+ onDecide("allow");
13
+ else if (input === "a")
14
+ onDecide("always");
15
+ });
16
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginBottom: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Permission required", queued > 0 ? ` (${queued} more pending)` : ""] }), _jsx(Text, { children: request.title }), request.detail ? (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: request.detail }) })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: "green", children: "(y)" }), " allow", " ", _jsx(Text, { color: "cyan", children: "(a)" }), " always allow", " ", _jsx(Text, { color: "red", children: "(n)" }), " deny"] }) })] }));
17
+ }
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ export function StatusBar({ modelName, busy, reasoning, }) {
5
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "model: " }), _jsx(Text, { color: "magenta", children: modelName }), reasoning ? _jsx(Text, { dimColor: true, children: ` · think:${reasoning}` }) : null, _jsx(Text, { dimColor: true, children: " · " }), busy ? (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " thinking\u2026", " ", _jsx(Text, { dimColor: true, children: "(Esc to cancel)" })] })) : (_jsx(Text, { dimColor: true, children: "/help for commands \u00B7 ctrl+c to exit" }))] }));
6
+ }
@@ -0,0 +1,44 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ const TOOL_LABEL = {
4
+ read_file: "read",
5
+ write_file: "write",
6
+ edit_file: "edit",
7
+ run_command: "run",
8
+ search_code: "search",
9
+ };
10
+ /** A tool whose result indicates a denial or error gets a red marker. */
11
+ function toolTone(item) {
12
+ if (item.status === "running")
13
+ return { icon: "◐", color: "yellow" };
14
+ const r = item.result ?? "";
15
+ if (/^Denied|^Error/i.test(r))
16
+ return { icon: "⏺", color: "red" };
17
+ return { icon: "⏺", color: "green" };
18
+ }
19
+ function ToolView({ item }) {
20
+ const label = TOOL_LABEL[item.name] ?? item.name;
21
+ const { icon, color } = toolTone(item);
22
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: color, children: [icon, " "] }), _jsx(Text, { color: color, bold: true, children: label }), item.summary ? _jsx(Text, { dimColor: true, children: ` ${item.summary}` }) : null] }), item.status === "done" && item.result ? (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: _jsx(Text, { dimColor: true, children: truncate(item.result, 600) }) })) : null] }));
23
+ }
24
+ export function Transcript({ items }) {
25
+ return (_jsx(Box, { flexDirection: "column", children: items.map((item) => {
26
+ switch (item.kind) {
27
+ case "user":
28
+ return (_jsxs(Box, { marginTop: 1, marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "❯ " }), _jsx(Text, { bold: true, children: item.text })] }, item.id));
29
+ case "assistant":
30
+ return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: item.text }) }, item.id));
31
+ case "tool":
32
+ return _jsx(ToolView, { item: item }, item.id);
33
+ case "error":
34
+ return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "red", children: "✗ " }), _jsx(Text, { color: "red", children: item.text })] }, item.id));
35
+ case "system":
36
+ return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: item.text }) }, item.id));
37
+ }
38
+ }) }));
39
+ }
40
+ function truncate(s, max) {
41
+ if (s.length <= max)
42
+ return s;
43
+ return s.slice(0, max) + `\n… (${s.length - max} more chars)`;
44
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "openpoly",
3
+ "version": "0.1.0",
4
+ "description": "An open-source, model-agnostic terminal coding agent. Free open-source models (DeepSeek, GLM, Qwen, Llama) via OpenRouter or local Ollama — or bring your own GPT/Claude/Gemini keys.",
5
+ "type": "module",
6
+ "bin": {
7
+ "polycode": "dist/cli.js",
8
+ "openpoly": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "dev": "tsx src/cli.tsx",
17
+ "build": "tsc",
18
+ "start": "node dist/cli.js",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepare": "npm run build",
21
+ "install:global": "npm run build && npm install -g ."
22
+ },
23
+ "keywords": [
24
+ "ai",
25
+ "agent",
26
+ "cli",
27
+ "tui",
28
+ "coding-assistant",
29
+ "llm",
30
+ "openai",
31
+ "anthropic",
32
+ "ollama"
33
+ ],
34
+ "license": "MIT",
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "dependencies": {
39
+ "@ai-sdk/anthropic": "^1.2.12",
40
+ "@ai-sdk/google": "^1.2.22",
41
+ "@ai-sdk/openai": "^1.3.24",
42
+ "@ai-sdk/openai-compatible": "^0.2.16",
43
+ "ai": "^4.3.19",
44
+ "ink": "^5.1.0",
45
+ "ink-select-input": "^6.0.0",
46
+ "ink-spinner": "^5.0.0",
47
+ "ink-text-input": "^6.0.0",
48
+ "react": "^18.3.1",
49
+ "zod": "^3.25.76"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^22.10.0",
53
+ "@types/react": "^18.3.12",
54
+ "tsx": "^4.19.2",
55
+ "typescript": "^5.7.2"
56
+ }
57
+ }