chels-code 0.0.0-stage → 0.5.1

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,259 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Box, Static, Text, useApp, useInput } from "ink";
4
+ import { collectModels, createEngine } from "../engine.js";
5
+ import { fetchAvailableCredits } from "../providers.js";
6
+ import { saveSession } from "../session.js";
7
+ import { DEFAULT_EFFORT, DEFAULT_MODE } from "../types.js";
8
+ import { ActionConfirmation, PlanConfirmation } from "./ConfirmPanel.js";
9
+ import { CommandPalette } from "./CommandPalette.js";
10
+ import { Spinner } from "./Spinner.js";
11
+ import { TextArea } from "./TextArea.js";
12
+ import { TranscriptEntryView } from "./Transcript.js";
13
+ import { ASSISTANT_NAME, EFFORT_COLORS, formatTokenCount, MODE_LABELS, PLAN_NAME, theme } from "./theme.js";
14
+ import { useTerminalSize } from "./useTerminalSize.js";
15
+ function initialEntries(workspace, version, resume) {
16
+ const header = {
17
+ id: 0,
18
+ kind: "header",
19
+ workspace,
20
+ version,
21
+ ...(resume ? { resumedFrom: resume.id } : {}),
22
+ };
23
+ if (!resume)
24
+ return [header];
25
+ const restored = resume.entries
26
+ .filter((entry) => entry.kind !== "header")
27
+ .map((entry, index) => ({ ...entry, id: index + 1 }));
28
+ return [header, ...restored];
29
+ }
30
+ export function App({ apiKeys, workspace, version, sessionId, resume, sessionsDir, missingKeys = false, }) {
31
+ const { exit } = useApp();
32
+ const { columns, rows } = useTerminalSize();
33
+ const [entries, setEntries] = useState(() => (initialEntries(workspace, version, resume)));
34
+ const nextEntryId = useRef(entries.reduce((max, entry) => Math.max(max, entry.id), 0) + 1);
35
+ const createdAt = useRef(resume?.createdAt ?? new Date().toISOString());
36
+ const [status, setStatus] = useState("Connecting to OpenRouter, NVIDIA, and Groq…");
37
+ const [tokensUsed, setTokensUsed] = useState(resume?.tokensUsed ?? 0);
38
+ const [creditsUsed, setCreditsUsed] = useState(resume?.creditsUsed ?? 0);
39
+ const [creditsAvailable, setCreditsAvailable] = useState(resume?.creditsAvailable ?? null);
40
+ const [commandsOpen, setCommandsOpen] = useState(false);
41
+ const [effort, setEffort] = useState(resume?.effort ?? DEFAULT_EFFORT);
42
+ const [mode, setMode] = useState(resume?.mode ?? DEFAULT_MODE);
43
+ const [models, setModels] = useState(null);
44
+ const [startupError, setStartupError] = useState(null);
45
+ const [busy, setBusy] = useState(false);
46
+ const [pendingPlan, setPendingPlan] = useState(null);
47
+ const [pendingAction, setPendingAction] = useState(null);
48
+ const planResolver = useRef(null);
49
+ const actionResolver = useRef(null);
50
+ const busyRef = useRef(false);
51
+ const saveErrorShown = useRef(false);
52
+ const pushEntry = useCallback((entry) => {
53
+ setEntries((current) => [
54
+ ...current,
55
+ { ...entry, id: nextEntryId.current++ },
56
+ ]);
57
+ }, []);
58
+ useEffect(() => {
59
+ if (!sessionId)
60
+ return;
61
+ const snapshot = {
62
+ schema: 1,
63
+ id: sessionId,
64
+ workspace,
65
+ createdAt: createdAt.current,
66
+ updatedAt: new Date().toISOString(),
67
+ mode,
68
+ effort,
69
+ tokensUsed,
70
+ creditsUsed,
71
+ creditsAvailable,
72
+ entries: entries.filter((entry) => entry.kind !== "header"),
73
+ };
74
+ try {
75
+ saveSession(snapshot, sessionsDir);
76
+ }
77
+ catch (error) {
78
+ if (saveErrorShown.current)
79
+ return;
80
+ saveErrorShown.current = true;
81
+ pushEntry({
82
+ kind: "error",
83
+ title: "Session not saved",
84
+ message: `This session cannot be resumed: ${error instanceof Error ? error.message : String(error)}`,
85
+ });
86
+ }
87
+ }, [
88
+ sessionId,
89
+ workspace,
90
+ mode,
91
+ effort,
92
+ tokensUsed,
93
+ creditsUsed,
94
+ creditsAvailable,
95
+ entries,
96
+ sessionsDir,
97
+ pushEntry,
98
+ ]);
99
+ const resolvePlan = useCallback((approved) => {
100
+ const resolve = planResolver.current;
101
+ planResolver.current = null;
102
+ setPendingPlan(null);
103
+ resolve?.(approved);
104
+ }, []);
105
+ const resolveAction = useCallback((approved) => {
106
+ const resolve = actionResolver.current;
107
+ actionResolver.current = null;
108
+ setPendingAction(null);
109
+ resolve?.(approved);
110
+ }, []);
111
+ const hooks = useMemo(() => ({
112
+ onStatus: (text) => setStatus(text),
113
+ onUsage: (usage) => {
114
+ setTokensUsed((current) => current + usage.totalTokens);
115
+ const cost = usage.cost;
116
+ if (usage.provider === "openrouter" && cost) {
117
+ setCreditsUsed((current) => current + cost);
118
+ }
119
+ },
120
+ onEntry: pushEntry,
121
+ confirmPlan: (tasks) => new Promise((resolve) => {
122
+ planResolver.current = resolve;
123
+ setCommandsOpen(false);
124
+ setStatus(null);
125
+ setPendingPlan(tasks);
126
+ }),
127
+ confirmAction: (action) => new Promise((resolve) => {
128
+ actionResolver.current = resolve;
129
+ setCommandsOpen(false);
130
+ setStatus(null);
131
+ setPendingAction(action);
132
+ }),
133
+ }), [pushEntry]);
134
+ useEffect(() => {
135
+ let cancelled = false;
136
+ void (async () => {
137
+ try {
138
+ const discovered = await collectModels(apiKeys, (text) => {
139
+ if (!cancelled)
140
+ setStatus(text);
141
+ });
142
+ if (cancelled)
143
+ return;
144
+ setModels(discovered);
145
+ }
146
+ catch (error) {
147
+ if (cancelled)
148
+ return;
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ setStartupError(message);
151
+ pushEntry({ kind: "error", title: "Setup failed", message });
152
+ }
153
+ finally {
154
+ if (!cancelled)
155
+ setStatus(null);
156
+ }
157
+ })();
158
+ return () => {
159
+ cancelled = true;
160
+ };
161
+ }, [apiKeys, pushEntry]);
162
+ useEffect(() => {
163
+ let cancelled = false;
164
+ void (async () => {
165
+ const credits = await fetchAvailableCredits(apiKeys.openrouter);
166
+ if (!cancelled && credits)
167
+ setCreditsAvailable(credits.remaining);
168
+ })();
169
+ return () => {
170
+ cancelled = true;
171
+ };
172
+ }, [apiKeys.openrouter]);
173
+ const engine = useMemo(() => (models ? createEngine({ apiKeys, models, workspace, hooks, effort }) : null), [apiKeys, models, workspace, hooks, effort]);
174
+ const endSession = useCallback(() => {
175
+ setTimeout(() => exit(), 60);
176
+ }, [exit]);
177
+ const runCommand = useCallback((command) => {
178
+ setCommandsOpen(false);
179
+ if (command === "exit") {
180
+ endSession();
181
+ return;
182
+ }
183
+ pushEntry({
184
+ kind: "note",
185
+ text: "enter send · shift+enter newline · tab / shift+tab mode · ctrl+p commands · /exit quit",
186
+ });
187
+ }, [endSession, pushEntry]);
188
+ useInput((input, key) => {
189
+ if (key.eventType === "release")
190
+ return;
191
+ if ((key.tab || (key.ctrl && input === "t"))
192
+ && !busyRef.current
193
+ && !pendingPlan
194
+ && !pendingAction
195
+ && !commandsOpen) {
196
+ setMode((current) => (current === "build" ? "plan" : "build"));
197
+ return;
198
+ }
199
+ if (key.ctrl && input === "p" && !pendingPlan && !pendingAction) {
200
+ setCommandsOpen((open) => !open);
201
+ }
202
+ });
203
+ const handleSubmit = useCallback((raw) => {
204
+ const request = raw.trim();
205
+ if (!request || busyRef.current)
206
+ return;
207
+ if (request === "/exit" || request === "/quit") {
208
+ endSession();
209
+ return;
210
+ }
211
+ if (missingKeys) {
212
+ pushEntry({
213
+ kind: "note",
214
+ text: "API keys are not configured. Add OPENROUTER_API_KEY, NVIDIA_API_KEY, and GROQ_API_KEY to your .env or environment before continuing.",
215
+ });
216
+ return;
217
+ }
218
+ if (!engine)
219
+ return;
220
+ busyRef.current = true;
221
+ setBusy(true);
222
+ pushEntry({ kind: "user", text: request });
223
+ void (async () => {
224
+ try {
225
+ await engine.runTask(request);
226
+ }
227
+ catch (error) {
228
+ pushEntry({
229
+ kind: "error",
230
+ title: "Task failed",
231
+ message: error instanceof Error ? error.message : String(error),
232
+ });
233
+ }
234
+ finally {
235
+ busyRef.current = false;
236
+ setBusy(false);
237
+ setStatus(null);
238
+ }
239
+ })();
240
+ }, [endSession, engine, pushEntry]);
241
+ const width = Math.max(20, columns);
242
+ const inputColumns = Math.max(16, width - 4);
243
+ const contextCeiling = models
244
+ ? Math.max(0, ...Object.values(models).map((model) => model.contextLength))
245
+ : 0;
246
+ const percent = creditsAvailable !== null
247
+ ? creditsUsed <= 0
248
+ ? 0
249
+ : creditsAvailable <= 0
250
+ ? 100
251
+ : Math.min(100, Math.round((creditsUsed / creditsAvailable) * 100))
252
+ : contextCeiling > 0
253
+ ? Math.min(100, Math.round((tokensUsed / contextCeiling) * 100))
254
+ : 0;
255
+ const tokenLabel = `${formatTokenCount(tokensUsed)} (${percent}%)`;
256
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: entries, children: (entry) => (_jsx(TranscriptEntryView, { entry: entry, columns: width }, entry.id)) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: [status ? (_jsx(Spinner, { active: true, label: "Thinking" })) : null, pendingPlan ? (_jsx(PlanConfirmation, { tasks: pendingPlan, onResolve: resolvePlan })) : null, pendingAction ? (_jsx(ActionConfirmation, { action: pendingAction, onResolve: resolveAction })) : null, startupError ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.red, children: "Setup failed. Fix the error above, then restart ChelsCode." }) })) : null, commandsOpen ? (_jsx(CommandPalette, { columns: inputColumns, mode: mode, onMode: setMode, effort: effort, onEffort: setEffort, onRun: runCommand, onClose: () => setCommandsOpen(false) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: status || pendingPlan || pendingAction || startupError ? 1 : 0, children: [_jsx(TextArea, { onSubmit: handleSubmit, disabled: busy || !engine || startupError !== null || commandsOpen, columns: inputColumns, rows: rows, placeholder: mode === "plan"
257
+ ? "Describe what you want to plan…"
258
+ : "Describe what you want to build…" }), _jsx(Box, { width: inputColumns, children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.blue, children: MODE_LABELS[mode] }), _jsx(Text, { color: theme.faint, children: " \u00B7 " }), _jsxs(Text, { color: theme.white, children: [ASSISTANT_NAME, " ", PLAN_NAME] }), _jsx(Text, { color: theme.faint, children: " \u00B7 " }), _jsx(Text, { color: EFFORT_COLORS[effort], children: effort })] }) }), _jsx(Box, { width: inputColumns, justifyContent: "flex-end", children: _jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: theme.faint, children: [tokenLabel, " "] }), _jsx(Text, { color: theme.white, children: "shift+tab" }), _jsxs(Text, { color: theme.faint, children: [" mode", " "] }), _jsx(Text, { color: theme.white, children: "ctrl+p" }), _jsx(Text, { color: theme.faint, children: " commands" })] }) })] })] })] }));
259
+ }
@@ -0,0 +1,76 @@
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 { EFFORT_LEVELS, MODE_OPTIONS, } from "../types.js";
5
+ import { EFFORT_COLORS, MODE_LABELS, theme } from "./theme.js";
6
+ export const COMMANDS = [
7
+ { id: "mode", label: "Mode", description: "Switch between Build and Plan" },
8
+ { id: "effort", label: "Effort", description: "Change the effort level" },
9
+ { id: "help", label: "Help", description: "Show keys and commands" },
10
+ { id: "exit", label: "Exit", description: "End the session" },
11
+ ];
12
+ export function CommandPalette({ columns, mode, onMode, effort, onEffort, onRun, onClose, }) {
13
+ const [view, setView] = useState("commands");
14
+ const [selected, setSelected] = useState(0);
15
+ const options = view === "commands"
16
+ ? COMMANDS
17
+ : view === "mode"
18
+ ? MODE_OPTIONS
19
+ : EFFORT_LEVELS;
20
+ const optionCount = options.length;
21
+ useInput((input, key) => {
22
+ if (key.eventType === "release")
23
+ return;
24
+ if (key.escape) {
25
+ if (view !== "commands") {
26
+ setView("commands");
27
+ setSelected(0);
28
+ return;
29
+ }
30
+ onClose();
31
+ return;
32
+ }
33
+ if (key.upArrow || input === "k") {
34
+ setSelected((current) => (current - 1 + optionCount) % optionCount);
35
+ return;
36
+ }
37
+ if (key.downArrow || input === "j") {
38
+ setSelected((current) => (current + 1) % optionCount);
39
+ return;
40
+ }
41
+ if (key.return) {
42
+ if (view === "mode") {
43
+ onMode(MODE_OPTIONS[selected]);
44
+ onClose();
45
+ return;
46
+ }
47
+ if (view === "effort") {
48
+ onEffort(EFFORT_LEVELS[selected]);
49
+ onClose();
50
+ return;
51
+ }
52
+ const command = COMMANDS[selected];
53
+ if (command.id === "mode") {
54
+ setView("mode");
55
+ setSelected(Math.max(0, MODE_OPTIONS.indexOf(mode)));
56
+ return;
57
+ }
58
+ if (command.id === "effort") {
59
+ setView("effort");
60
+ setSelected(Math.max(0, EFFORT_LEVELS.indexOf(effort)));
61
+ return;
62
+ }
63
+ onRun(command.id);
64
+ }
65
+ });
66
+ const title = view === "commands" ? "Commands" : view === "mode" ? "Mode" : "Effort";
67
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.primary, paddingX: 1, marginTop: 1, width: Math.min(64, Math.max(16, columns)), children: [_jsx(Text, { bold: true, children: title }), view === "commands" ? COMMANDS.map((command, index) => (_jsxs(Text, { children: [_jsx(Text, { color: index === selected ? theme.primary : theme.faint, children: index === selected ? "› " : " " }), _jsx(Text, { bold: index === selected, color: index === selected ? theme.white : theme.dim, children: command.label }), _jsx(Text, { color: theme.faint, children: " " }), command.id === "mode"
68
+ ? _jsx(Text, { color: theme.blue, children: MODE_LABELS[mode] })
69
+ : command.id === "effort"
70
+ ? _jsx(Text, { color: EFFORT_COLORS[effort], children: effort })
71
+ : _jsx(Text, { color: theme.faint, children: command.description })] }, command.id))) : view === "mode" ? MODE_OPTIONS.map((option, index) => (_jsxs(Text, { children: [_jsx(Text, { color: index === selected ? theme.primary : theme.faint, children: index === selected ? "› " : " " }), _jsx(Text, { bold: index === selected, color: theme.blue, children: MODE_LABELS[option] }), option === mode ? _jsx(Text, { color: theme.faint, children: " current" }) : null] }, option))) : EFFORT_LEVELS.map((level, index) => (_jsxs(Text, { children: [_jsx(Text, { color: index === selected ? theme.primary : theme.faint, children: index === selected ? "› " : " " }), _jsx(Text, { bold: index === selected, color: EFFORT_COLORS[level], children: level }), level === effort ? _jsx(Text, { color: theme.faint, children: " current" }) : null] }, level))), _jsx(Text, { color: theme.faint, children: view === "effort"
72
+ ? "↑↓ select · enter apply · esc back"
73
+ : view === "mode"
74
+ ? "↑↓ select · enter switch · esc back"
75
+ : "↑↓ select · enter run · esc close" })] }));
76
+ }
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { formatBytes, plural, sanitize, theme } from "./theme.js";
5
+ function useDecision(onResolve) {
6
+ const resolved = useRef(false);
7
+ useInput((input, key) => {
8
+ if (resolved.current || key.eventType === "release")
9
+ return;
10
+ if (key.escape || key.return) {
11
+ resolved.current = true;
12
+ onResolve(false);
13
+ return;
14
+ }
15
+ const answer = input.trim().toLowerCase();
16
+ if (answer === "y") {
17
+ resolved.current = true;
18
+ onResolve(true);
19
+ }
20
+ else if (answer === "n") {
21
+ resolved.current = true;
22
+ onResolve(false);
23
+ }
24
+ });
25
+ }
26
+ export function PlanConfirmation({ tasks, onResolve, }) {
27
+ useDecision(onResolve);
28
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.primary, paddingX: 1, marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.primary, children: "?" }), _jsx(Text, { bold: true, children: " Proceed with this plan?" })] }), _jsxs(Text, { color: theme.dim, children: [tasks.length, " ", plural(tasks.length, "task"), " will run one at a time. Every file write and command asks for confirmation again."] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.green, children: "y" }), _jsx(Text, { color: theme.dim, children: " proceed \u00B7 " }), _jsx(Text, { color: theme.dim, children: "n / enter cancel" })] })] }));
29
+ }
30
+ export function ActionConfirmation({ action, onResolve, }) {
31
+ useDecision(onResolve);
32
+ if (action.kind === "write") {
33
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.yellow, paddingX: 1, marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.yellow, children: "?" }), _jsx(Text, { bold: true, children: " Write this file?" })] }), _jsx(Text, { color: theme.bright, wrap: "hard", children: sanitize(action.path) }), _jsxs(Text, { color: theme.dim, children: [action.lines, " ", plural(action.lines, "line"), " \u00B7 ", formatBytes(action.bytes)] }), action.preview ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.faint, children: "preview" }), _jsx(Text, { color: theme.dim, wrap: "hard", children: sanitize(action.preview) }), action.truncated ? _jsx(Text, { color: theme.faint, children: "\u2026 preview truncated" }) : null] })) : null, _jsxs(Text, { children: [_jsx(Text, { color: theme.green, children: "y" }), _jsx(Text, { color: theme.dim, children: " write \u00B7 " }), _jsx(Text, { color: theme.dim, children: "n / enter skip" })] })] }));
34
+ }
35
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.yellow, paddingX: 1, marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.yellow, children: "?" }), _jsx(Text, { bold: true, children: " Run this PowerShell command?" })] }), _jsx(Text, { color: theme.yellow, wrap: "hard", children: sanitize(action.command) }), _jsxs(Text, { children: [_jsx(Text, { color: theme.green, children: "y" }), _jsx(Text, { color: theme.dim, children: " run \u00B7 " }), _jsx(Text, { color: theme.dim, children: "n / enter skip" })] })] }));
36
+ }
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Text } from "ink";
4
+ import { theme } from "./theme.js";
5
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ export function Spinner({ active, label }) {
7
+ const [frame, setFrame] = useState(0);
8
+ useEffect(() => {
9
+ if (!active) {
10
+ setFrame(0);
11
+ return;
12
+ }
13
+ const timer = setInterval(() => {
14
+ setFrame((current) => (current + 1) % SPINNER_FRAMES.length);
15
+ }, 80);
16
+ return () => clearInterval(timer);
17
+ }, [active]);
18
+ if (!active)
19
+ return null;
20
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.primary, children: SPINNER_FRAMES[frame] }), _jsxs(Text, { bold: true, children: [" ", label] })] }));
21
+ }
@@ -0,0 +1,243 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { sanitize, theme } from "./theme.js";
5
+ function layoutLines(chars, width) {
6
+ const lines = [];
7
+ let lineStart = 0;
8
+ for (let index = 0; index <= chars.length; index += 1) {
9
+ if (index === chars.length || chars[index] === "\n") {
10
+ const length = index - lineStart;
11
+ if (length === 0) {
12
+ lines.push({ start: lineStart, text: "" });
13
+ }
14
+ else {
15
+ for (let offset = 0; offset < length; offset += width) {
16
+ lines.push({
17
+ start: lineStart + offset,
18
+ text: chars.slice(lineStart + offset, lineStart + Math.min(offset + width, length)).join(""),
19
+ });
20
+ }
21
+ }
22
+ lineStart = index + 1;
23
+ }
24
+ }
25
+ return lines;
26
+ }
27
+ function cursorPosition(lines, cursor) {
28
+ for (let index = 0; index < lines.length; index += 1) {
29
+ const line = lines[index];
30
+ if (cursor >= line.start && cursor < line.start + line.text.length) {
31
+ return { line: index, column: cursor - line.start };
32
+ }
33
+ }
34
+ let chosen = 0;
35
+ for (let index = 0; index < lines.length; index += 1) {
36
+ if (lines[index].start <= cursor)
37
+ chosen = index;
38
+ else
39
+ break;
40
+ }
41
+ return { line: chosen, column: cursor - lines[chosen].start };
42
+ }
43
+ function lineStartAt(chars, position) {
44
+ let index = Math.min(position, chars.length);
45
+ while (index > 0 && chars[index - 1] !== "\n")
46
+ index -= 1;
47
+ return index;
48
+ }
49
+ function lineEndAt(chars, position) {
50
+ let index = Math.min(position, chars.length);
51
+ while (index < chars.length && chars[index] !== "\n")
52
+ index += 1;
53
+ return index;
54
+ }
55
+ export function TextArea({ onSubmit, disabled = false, columns, rows, placeholder = "Describe what you want to build…", }) {
56
+ const [value, setValue] = useState("");
57
+ const [cursor, setCursor] = useState(0);
58
+ const [cursorOn, setCursorOn] = useState(true);
59
+ const valueRef = useRef(value);
60
+ const cursorRef = useRef(cursor);
61
+ const outerWidth = Math.max(16, columns);
62
+ const contentWidth = Math.max(8, outerWidth - 5);
63
+ const maxVisible = Math.max(3, Math.min(10, rows - 12));
64
+ const chars = useMemo(() => Array.from(value), [value]);
65
+ const visualLines = useMemo(() => layoutLines(chars, contentWidth), [chars, contentWidth]);
66
+ const position = cursorPosition(visualLines, Math.min(cursor, chars.length));
67
+ let windowStart = 0;
68
+ if (visualLines.length > maxVisible) {
69
+ windowStart = Math.max(0, Math.min(position.line - Math.floor(maxVisible / 2), visualLines.length - maxVisible));
70
+ }
71
+ const visibleLines = visualLines.slice(windowStart, windowStart + maxVisible);
72
+ const hiddenAbove = windowStart;
73
+ const hiddenBelow = visualLines.length - windowStart - visibleLines.length;
74
+ const update = (nextValue, nextCursor) => {
75
+ valueRef.current = nextValue;
76
+ cursorRef.current = nextCursor;
77
+ setValue(nextValue);
78
+ setCursor(nextCursor);
79
+ };
80
+ const insert = (text) => {
81
+ const cleaned = sanitize(text);
82
+ if (!cleaned)
83
+ return;
84
+ const current = Array.from(valueRef.current);
85
+ const at = Math.min(cursorRef.current, current.length);
86
+ const inserted = Array.from(cleaned);
87
+ update([...current.slice(0, at), ...inserted, ...current.slice(at)].join(""), at + inserted.length);
88
+ };
89
+ const removeBefore = () => {
90
+ const current = Array.from(valueRef.current);
91
+ const at = Math.min(cursorRef.current, current.length);
92
+ if (at === 0)
93
+ return;
94
+ update([...current.slice(0, at - 1), ...current.slice(at)].join(""), at - 1);
95
+ };
96
+ const removeAt = () => {
97
+ const current = Array.from(valueRef.current);
98
+ const at = Math.min(cursorRef.current, current.length);
99
+ if (at >= current.length)
100
+ return;
101
+ update([...current.slice(0, at), ...current.slice(at + 1)].join(""), at);
102
+ };
103
+ const removeWordBefore = () => {
104
+ const current = Array.from(valueRef.current);
105
+ const at = Math.min(cursorRef.current, current.length);
106
+ let start = at;
107
+ while (start > 0 && current[start - 1] === " ")
108
+ start -= 1;
109
+ while (start > 0 && current[start - 1] !== " " && current[start - 1] !== "\n")
110
+ start -= 1;
111
+ if (start === at)
112
+ return;
113
+ update([...current.slice(0, start), ...current.slice(at)].join(""), start);
114
+ };
115
+ const moveVertical = (delta) => {
116
+ const current = Array.from(valueRef.current);
117
+ const at = Math.min(cursorRef.current, current.length);
118
+ const lines = layoutLines(current, contentWidth);
119
+ const currentPosition = cursorPosition(lines, at);
120
+ const targetIndex = Math.max(0, Math.min(lines.length - 1, currentPosition.line + delta));
121
+ const target = lines[targetIndex];
122
+ update(current.join(""), target.start + Math.min(currentPosition.column, target.text.length));
123
+ };
124
+ useEffect(() => {
125
+ if (disabled) {
126
+ setCursorOn(true);
127
+ return;
128
+ }
129
+ setCursorOn(true);
130
+ const timer = setInterval(() => setCursorOn((visible) => !visible), 530);
131
+ return () => clearInterval(timer);
132
+ }, [disabled]);
133
+ useInput((input, key) => {
134
+ if (key.eventType === "release")
135
+ return;
136
+ if (key.return) {
137
+ if (key.shift || key.ctrl || key.meta) {
138
+ insert("\n");
139
+ }
140
+ else {
141
+ const request = valueRef.current;
142
+ update("", 0);
143
+ onSubmit(request);
144
+ }
145
+ return;
146
+ }
147
+ if (key.home) {
148
+ const current = Array.from(valueRef.current);
149
+ update(current.join(""), lineStartAt(current, cursorRef.current));
150
+ return;
151
+ }
152
+ if (key.end) {
153
+ const current = Array.from(valueRef.current);
154
+ update(current.join(""), lineEndAt(current, cursorRef.current));
155
+ return;
156
+ }
157
+ if (key.pageUp) {
158
+ moveVertical(-maxVisible);
159
+ return;
160
+ }
161
+ if (key.pageDown) {
162
+ moveVertical(maxVisible);
163
+ return;
164
+ }
165
+ if (key.ctrl || key.meta) {
166
+ if (input === "a") {
167
+ const current = Array.from(valueRef.current);
168
+ update(current.join(""), lineStartAt(current, cursorRef.current));
169
+ return;
170
+ }
171
+ if (input === "e") {
172
+ const current = Array.from(valueRef.current);
173
+ update(current.join(""), lineEndAt(current, cursorRef.current));
174
+ return;
175
+ }
176
+ if (input === "u") {
177
+ const current = Array.from(valueRef.current);
178
+ const start = lineStartAt(current, cursorRef.current);
179
+ const at = Math.min(cursorRef.current, current.length);
180
+ update([...current.slice(0, start), ...current.slice(at)].join(""), start);
181
+ return;
182
+ }
183
+ if (input === "k") {
184
+ const current = Array.from(valueRef.current);
185
+ const end = lineEndAt(current, cursorRef.current);
186
+ const at = Math.min(cursorRef.current, current.length);
187
+ update([...current.slice(0, at), ...current.slice(end)].join(""), at);
188
+ return;
189
+ }
190
+ if (input === "w") {
191
+ removeWordBefore();
192
+ return;
193
+ }
194
+ return;
195
+ }
196
+ if (key.tab) {
197
+ return;
198
+ }
199
+ if (key.backspace) {
200
+ removeBefore();
201
+ return;
202
+ }
203
+ if (key.delete) {
204
+ removeAt();
205
+ return;
206
+ }
207
+ if (key.leftArrow) {
208
+ const current = Array.from(valueRef.current);
209
+ update(current.join(""), Math.max(0, cursorRef.current - 1));
210
+ return;
211
+ }
212
+ if (key.rightArrow) {
213
+ const current = Array.from(valueRef.current);
214
+ update(current.join(""), Math.min(current.length, cursorRef.current + 1));
215
+ return;
216
+ }
217
+ if (key.upArrow) {
218
+ moveVertical(-1);
219
+ return;
220
+ }
221
+ if (key.downArrow) {
222
+ moveVertical(1);
223
+ return;
224
+ }
225
+ if (key.escape)
226
+ return;
227
+ if (input)
228
+ insert(input);
229
+ }, { isActive: !disabled });
230
+ const renderLine = (line, index) => {
231
+ const isCursorLine = index + windowStart === position.line && !disabled;
232
+ if (!isCursorLine) {
233
+ return _jsx(Text, { children: line.text || " " }, `${line.start}-${index}`);
234
+ }
235
+ const lineChars = Array.from(line.text);
236
+ const column = Math.min(position.column, lineChars.length);
237
+ const before = lineChars.slice(0, column).join("");
238
+ const at = lineChars[column] ?? " ";
239
+ const after = lineChars.slice(column + 1).join("");
240
+ return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: cursorOn, children: at }), after] }, `${line.start}-${index}`));
241
+ };
242
+ return (_jsxs(Box, { flexDirection: "column", width: outerWidth, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.primary, children: "\u25C6" }), _jsx(Text, { color: theme.dim, children: " Ask anything" })] }), outerWidth >= 50 && value.length > 0 ? (_jsxs(Text, { color: theme.faint, children: [chars.length, " ", chars.length === 1 ? "char" : "chars"] })) : null] }), hiddenAbove > 0 ? (_jsxs(Text, { color: theme.faint, children: [" ", "\u2191 ", hiddenAbove, " more ", hiddenAbove === 1 ? "line" : "lines"] })) : null, _jsx(Box, { width: outerWidth, borderStyle: "round", borderColor: disabled ? theme.faint : theme.primary, paddingX: 1, flexDirection: "column", children: value.length === 0 ? (_jsxs(Text, { children: [!disabled ? _jsx(Text, { inverse: cursorOn, children: " " }) : null, _jsx(Text, { color: theme.faint, children: placeholder })] })) : (visibleLines.map(renderLine)) }), hiddenBelow > 0 ? (_jsxs(Text, { color: theme.faint, children: [" ", "\u2193 ", hiddenBelow, " more ", hiddenBelow === 1 ? "line" : "lines"] })) : null] }));
243
+ }