codemaxxing 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/src/index.tsx ADDED
@@ -0,0 +1,858 @@
1
+ #!/usr/bin/env node
2
+
3
+ import React, { useState, useEffect, useCallback } from "react";
4
+ import { render, Box, Text, useInput, useApp, useStdout } from "ink";
5
+ import { EventEmitter } from "events";
6
+ import TextInput from "ink-text-input";
7
+ import { CodingAgent } from "./agent.js";
8
+ import { loadConfig, detectLocalProvider, parseCLIArgs, applyOverrides, listModels } from "./config.js";
9
+ import { listSessions, getSession, loadMessages } from "./utils/sessions.js";
10
+ import { execSync } from "child_process";
11
+ import { isGitRepo, getBranch, getStatus, getDiff, undoLastCommit } from "./utils/git.js";
12
+
13
+ const VERSION = "0.1.0";
14
+
15
+ // ── Helpers ──
16
+ function formatTimeAgo(date: Date): string {
17
+ const secs = Math.floor((Date.now() - date.getTime()) / 1000);
18
+ if (secs < 60) return `${secs}s ago`;
19
+ const mins = Math.floor(secs / 60);
20
+ if (mins < 60) return `${mins}m ago`;
21
+ const hours = Math.floor(mins / 60);
22
+ if (hours < 24) return `${hours}h ago`;
23
+ const days = Math.floor(hours / 24);
24
+ return `${days}d ago`;
25
+ }
26
+
27
+ // ── Slash Commands ──
28
+ const SLASH_COMMANDS = [
29
+ { cmd: "/help", desc: "show commands" },
30
+ { cmd: "/map", desc: "show repository map" },
31
+ { cmd: "/reset", desc: "clear conversation" },
32
+ { cmd: "/context", desc: "show message count" },
33
+ { cmd: "/diff", desc: "show git changes" },
34
+ { cmd: "/undo", desc: "revert last codemaxxing commit" },
35
+ { cmd: "/commit", desc: "commit all changes" },
36
+ { cmd: "/push", desc: "push to remote" },
37
+ { cmd: "/git on", desc: "enable auto-commits" },
38
+ { cmd: "/git off", desc: "disable auto-commits" },
39
+ { cmd: "/models", desc: "list available models" },
40
+ { cmd: "/model", desc: "switch model mid-session" },
41
+ { cmd: "/sessions", desc: "list past sessions" },
42
+ { cmd: "/resume", desc: "resume a past session" },
43
+ { cmd: "/quit", desc: "exit" },
44
+ ];
45
+
46
+ const SPINNER_FRAMES = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
47
+
48
+ const SPINNER_MESSAGES = [
49
+ "Locking in...", "Cooking...", "Maxxing...", "In the zone...",
50
+ "Yapping...", "Frame mogging...", "Jester gooning...", "Gooning...",
51
+ "Doing back flips...", "Jester maxxing...", "Getting baked...",
52
+ "Blasting tren...", "Pumping...", "Wondering if I should actually do this...",
53
+ "Hacking the main frame...", "Codemaxxing...", "Vibe coding...", "Running a marathon...",
54
+ ];
55
+
56
+ // ── Neon Spinner ──
57
+ function NeonSpinner({ message }: { message: string }) {
58
+ const [frame, setFrame] = useState(0);
59
+ const [elapsed, setElapsed] = useState(0);
60
+
61
+ useEffect(() => {
62
+ const start = Date.now();
63
+ const interval = setInterval(() => {
64
+ setFrame((f) => (f + 1) % SPINNER_FRAMES.length);
65
+ setElapsed(Math.floor((Date.now() - start) / 1000));
66
+ }, 80);
67
+ return () => clearInterval(interval);
68
+ }, []);
69
+
70
+ return (
71
+ <Text>
72
+ {" "}<Text color="#00FFFF">{SPINNER_FRAMES[frame]}</Text>
73
+ {" "}<Text bold color="#FF00FF">{message}</Text>
74
+ {" "}<Text color="#008B8B">[{elapsed}s]</Text>
75
+ </Text>
76
+ );
77
+ }
78
+
79
+ // ── Streaming Indicator (subtle, shows model is still working) ──
80
+ const STREAM_DOTS = ["· ", "·· ", "···", " ··", " ·", " "];
81
+ function StreamingIndicator() {
82
+ const [frame, setFrame] = useState(0);
83
+
84
+ useEffect(() => {
85
+ const interval = setInterval(() => {
86
+ setFrame((f) => (f + 1) % STREAM_DOTS.length);
87
+ }, 300);
88
+ return () => clearInterval(interval);
89
+ }, []);
90
+
91
+ return (
92
+ <Text dimColor>
93
+ {" "}<Text color="#008B8B">{STREAM_DOTS[frame]}</Text>
94
+ {" "}<Text color="#008B8B">streaming</Text>
95
+ </Text>
96
+ );
97
+ }
98
+
99
+ // ── Message Types ──
100
+ interface ChatMessage {
101
+ id: number;
102
+ type: "user" | "response" | "tool" | "tool-result" | "error" | "info";
103
+ text: string;
104
+ }
105
+
106
+ let msgId = 0;
107
+
108
+ // ── Main App ──
109
+ function App() {
110
+ const { exit } = useApp();
111
+ const { stdout } = useStdout();
112
+ const termWidth = stdout?.columns ?? 80;
113
+
114
+ const [input, setInput] = useState("");
115
+ const [pastedChunks, setPastedChunks] = useState<Array<{ id: number; lines: number; content: string }>>([]);
116
+ const [pasteCount, setPasteCount] = useState(0);
117
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
118
+ const [loading, setLoading] = useState(false);
119
+ const [streaming, setStreaming] = useState(false);
120
+ const [spinnerMsg, setSpinnerMsg] = useState("");
121
+ const [agent, setAgent] = useState<CodingAgent | null>(null);
122
+ const [modelName, setModelName] = useState("");
123
+ const providerRef = React.useRef<{ baseUrl: string; apiKey: string }>({ baseUrl: "", apiKey: "" });
124
+ const [ready, setReady] = useState(false);
125
+ const [connectionInfo, setConnectionInfo] = useState<string[]>([]);
126
+ const [ctrlCPressed, setCtrlCPressed] = useState(false);
127
+ const [cmdIndex, setCmdIndex] = useState(0);
128
+ const [inputKey, setInputKey] = useState(0);
129
+ const [sessionPicker, setSessionPicker] = useState<Array<{ id: string; display: string }> | null>(null);
130
+ const [sessionPickerIndex, setSessionPickerIndex] = useState(0);
131
+ const [approval, setApproval] = useState<{
132
+ tool: string;
133
+ args: Record<string, unknown>;
134
+ resolve: (decision: "yes" | "no" | "always") => void;
135
+ } | null>(null);
136
+
137
+ // Listen for paste events from stdin interceptor
138
+ useEffect(() => {
139
+ const handler = ({ content, lines }: { content: string; lines: number }) => {
140
+ setPasteCount((c) => {
141
+ const newId = c + 1;
142
+ setPastedChunks((prev) => [...prev, { id: newId, lines, content }]);
143
+ return newId;
144
+ });
145
+ };
146
+ pasteEvents.on("paste", handler);
147
+ return () => { pasteEvents.off("paste", handler); };
148
+ }, []);
149
+
150
+ // Initialize agent
151
+ useEffect(() => {
152
+ (async () => {
153
+ const cliArgs = parseCLIArgs();
154
+ const rawConfig = loadConfig();
155
+ const config = applyOverrides(rawConfig, cliArgs);
156
+ let provider = config.provider;
157
+ const info: string[] = [];
158
+
159
+ if (provider.model === "auto" || (provider.baseUrl === "http://localhost:1234/v1" && !cliArgs.baseUrl)) {
160
+ info.push("Detecting local LLM server...");
161
+ setConnectionInfo([...info]);
162
+ const detected = await detectLocalProvider();
163
+ if (detected) {
164
+ // Keep CLI model override if specified
165
+ if (cliArgs.model) detected.model = cliArgs.model;
166
+ provider = detected;
167
+ info.push(`✔ Connected to ${provider.baseUrl} → ${provider.model}`);
168
+ setConnectionInfo([...info]);
169
+ } else {
170
+ info.push("✗ No local LLM server found. Start LM Studio or Ollama.");
171
+ info.push(" Use --base-url and --api-key to connect to a remote provider.");
172
+ setConnectionInfo([...info]);
173
+ return;
174
+ }
175
+ } else {
176
+ info.push(`Provider: ${provider.baseUrl}`);
177
+ info.push(`Model: ${provider.model}`);
178
+ setConnectionInfo([...info]);
179
+ }
180
+
181
+ const cwd = process.cwd();
182
+
183
+ // Git info
184
+ if (isGitRepo(cwd)) {
185
+ const branch = getBranch(cwd);
186
+ const status = getStatus(cwd);
187
+ info.push(`Git: ${branch} (${status})`);
188
+ setConnectionInfo([...info]);
189
+ }
190
+
191
+ const a = new CodingAgent({
192
+ provider,
193
+ cwd,
194
+ maxTokens: config.defaults.maxTokens,
195
+ autoApprove: config.defaults.autoApprove,
196
+ onToken: (token) => {
197
+ // Switch from big spinner to streaming mode
198
+ setLoading(false);
199
+ setStreaming(true);
200
+
201
+ // Update the current streaming response in-place
202
+ setMessages((prev) => {
203
+ const lastIdx = prev.length - 1;
204
+ const last = prev[lastIdx];
205
+
206
+ if (last && last.type === "response" && (last as any)._streaming) {
207
+ return [
208
+ ...prev.slice(0, lastIdx),
209
+ { ...last, text: last.text + token },
210
+ ];
211
+ }
212
+
213
+ // First token of a new response
214
+ return [...prev, { id: msgId++, type: "response" as const, text: token, _streaming: true } as any];
215
+ });
216
+ },
217
+ onToolCall: (name, args) => {
218
+ setLoading(true);
219
+ setSpinnerMsg("Executing tools...");
220
+ const argStr = Object.entries(args)
221
+ .map(([k, v]) => {
222
+ const val = String(v);
223
+ return val.length > 60 ? val.slice(0, 60) + "..." : val;
224
+ })
225
+ .join(", ");
226
+ addMsg("tool", `${name}(${argStr})`);
227
+ },
228
+ onToolResult: (_name, result) => {
229
+ const numLines = result.split("\n").length;
230
+ const size = result.length > 1024 ? `${(result.length / 1024).toFixed(1)}KB` : `${result.length}B`;
231
+ addMsg("tool-result", `└ ${numLines} lines (${size})`);
232
+ },
233
+ onThinking: (text) => {
234
+ if (text.length > 0) {
235
+ addMsg("info", `💭 Thought for ${text.split(/\s+/).length} words`);
236
+ }
237
+ },
238
+ onGitCommit: (message) => {
239
+ addMsg("info", `📝 Auto-committed: ${message}`);
240
+ },
241
+ onToolApproval: (name, args) => {
242
+ return new Promise((resolve) => {
243
+ setApproval({ tool: name, args, resolve });
244
+ setLoading(false);
245
+ });
246
+ },
247
+ });
248
+
249
+ // Initialize async context (repo map)
250
+ await a.init();
251
+
252
+ setAgent(a);
253
+ setModelName(provider.model);
254
+ providerRef.current = { baseUrl: provider.baseUrl, apiKey: provider.apiKey };
255
+ setReady(true);
256
+ })();
257
+ }, []);
258
+
259
+ function addMsg(type: ChatMessage["type"], text: string) {
260
+ setMessages((prev) => [...prev, { id: msgId++, type, text }]);
261
+ }
262
+
263
+ // Compute matching commands for suggestions
264
+ const cmdMatches = input.startsWith("/")
265
+ ? SLASH_COMMANDS.filter(c => c.cmd.startsWith(input.toLowerCase()))
266
+ : [];
267
+ const showSuggestions = cmdMatches.length > 0 && !loading && !approval && input !== cmdMatches[0]?.cmd;
268
+
269
+ // Refs to avoid stale closures in handleSubmit
270
+ const cmdIndexRef = React.useRef(cmdIndex);
271
+ cmdIndexRef.current = cmdIndex;
272
+ const cmdMatchesRef = React.useRef(cmdMatches);
273
+ cmdMatchesRef.current = cmdMatches;
274
+ const showSuggestionsRef = React.useRef(showSuggestions);
275
+ showSuggestionsRef.current = showSuggestions;
276
+ const pastedChunksRef = React.useRef(pastedChunks);
277
+ pastedChunksRef.current = pastedChunks;
278
+
279
+ const handleSubmit = useCallback(async (value: string) => {
280
+ // Skip autocomplete if input exactly matches a command (e.g. /models vs /model)
281
+ const isExactCommand = SLASH_COMMANDS.some(c => c.cmd === value.trim());
282
+
283
+ // If suggestions are showing and input isn't already an exact command, use autocomplete
284
+ if (showSuggestionsRef.current && !isExactCommand) {
285
+ const matches = cmdMatchesRef.current;
286
+ const idx = cmdIndexRef.current;
287
+ const selected = matches[idx];
288
+ if (selected) {
289
+ // Commands that need args (like /commit, /model) — fill input instead of executing
290
+ if (selected.cmd === "/commit" || selected.cmd === "/model") {
291
+ setInput(selected.cmd + " ");
292
+ setCmdIndex(0);
293
+ setInputKey((k) => k + 1);
294
+ return;
295
+ }
296
+ // Execute the selected command directly
297
+ value = selected.cmd;
298
+ setCmdIndex(0);
299
+ }
300
+ }
301
+
302
+ // Combine typed text with any pasted chunks
303
+ const chunks = pastedChunksRef.current;
304
+ let fullValue = value;
305
+ if (chunks.length > 0) {
306
+ const pasteText = chunks.map(p => p.content).join("\n\n");
307
+ fullValue = value ? `${value}\n\n${pasteText}` : pasteText;
308
+ }
309
+ const trimmed = fullValue.trim();
310
+ setInput("");
311
+ setPastedChunks([]);
312
+ setPasteCount(0);
313
+ if (!trimmed || !agent) return;
314
+
315
+ addMsg("user", trimmed);
316
+
317
+ if (trimmed === "/quit" || trimmed === "/exit") {
318
+ exit();
319
+ return;
320
+ }
321
+ if (trimmed === "/help") {
322
+ addMsg("info", [
323
+ "Commands:",
324
+ " /help — show this",
325
+ " /model — switch model mid-session",
326
+ " /models — list available models",
327
+ " /map — show repository map",
328
+ " /sessions — list past sessions",
329
+ " /resume — resume a past session",
330
+ " /reset — clear conversation",
331
+ " /context — show message count",
332
+ " /diff — show git changes",
333
+ " /undo — revert last codemaxxing commit",
334
+ " /commit — commit all changes",
335
+ " /push — push to remote",
336
+ " /git on — enable auto-commits",
337
+ " /git off — disable auto-commits",
338
+ " /quit — exit",
339
+ ].join("\n"));
340
+ return;
341
+ }
342
+ if (trimmed === "/reset") {
343
+ agent.reset();
344
+ addMsg("info", "✅ Conversation reset.");
345
+ return;
346
+ }
347
+ if (trimmed === "/context") {
348
+ addMsg("info", `Messages in context: ${agent.getContextLength()}`);
349
+ return;
350
+ }
351
+ if (trimmed === "/models") {
352
+ addMsg("info", "Fetching available models...");
353
+ const { baseUrl, apiKey } = providerRef.current;
354
+ const models = await listModels(baseUrl, apiKey);
355
+ if (models.length === 0) {
356
+ addMsg("info", "No models found or couldn't reach provider.");
357
+ } else {
358
+ addMsg("info", "Available models:\n" + models.map(m => ` ${m}`).join("\n"));
359
+ }
360
+ return;
361
+ }
362
+ if (trimmed.startsWith("/model")) {
363
+ const newModel = trimmed.replace("/model", "").trim();
364
+ if (!newModel) {
365
+ addMsg("info", `Current model: ${agent.getModel()}\n Usage: /model <model-name>`);
366
+ return;
367
+ }
368
+ agent.switchModel(newModel);
369
+ setModelName(newModel);
370
+ addMsg("info", `✅ Switched to model: ${newModel}`);
371
+ return;
372
+ }
373
+ if (trimmed === "/map") {
374
+ const map = agent.getRepoMap();
375
+ if (map) {
376
+ addMsg("info", map);
377
+ } else {
378
+ // Map hasn't been built yet, refresh it
379
+ setLoading(true);
380
+ const newMap = await agent.refreshRepoMap();
381
+ addMsg("info", newMap || "No repository map available.");
382
+ setLoading(false);
383
+ }
384
+ return;
385
+ }
386
+ if (trimmed === "/diff") {
387
+ const diff = getDiff(process.cwd());
388
+ addMsg("info", diff);
389
+ return;
390
+ }
391
+ if (trimmed === "/undo") {
392
+ const result = undoLastCommit(process.cwd());
393
+ addMsg("info", result.success ? `✅ ${result.message}` : `✗ ${result.message}`);
394
+ return;
395
+ }
396
+ if (trimmed === "/git on") {
397
+ if (!agent.isGitEnabled()) {
398
+ addMsg("info", "✗ Not a git repository");
399
+ } else {
400
+ agent.setAutoCommit(true);
401
+ addMsg("info", "✅ Auto-commits enabled for this session");
402
+ }
403
+ return;
404
+ }
405
+ if (trimmed === "/git off") {
406
+ agent.setAutoCommit(false);
407
+ addMsg("info", "✅ Auto-commits disabled");
408
+ return;
409
+ }
410
+ if (trimmed === "/sessions") {
411
+ const sessions = listSessions(10);
412
+ if (sessions.length === 0) {
413
+ addMsg("info", "No past sessions found.");
414
+ } else {
415
+ const lines = sessions.map((s, i) => {
416
+ const date = new Date(s.updated_at + "Z");
417
+ const ago = formatTimeAgo(date);
418
+ const dir = s.cwd.split("/").pop() || s.cwd;
419
+ const tokens = s.token_estimate >= 1000
420
+ ? `${(s.token_estimate / 1000).toFixed(1)}k`
421
+ : String(s.token_estimate);
422
+ return ` ${s.id} ${dir}/ ${s.message_count} msgs ~${tokens} tok ${ago} ${s.model}`;
423
+ });
424
+ addMsg("info", "Recent sessions:\n" + lines.join("\n") + "\n\n Use /resume <id> to continue a session");
425
+ }
426
+ return;
427
+ }
428
+ if (trimmed === "/resume") {
429
+ const sessions = listSessions(10);
430
+ if (sessions.length === 0) {
431
+ addMsg("info", "No past sessions to resume.");
432
+ return;
433
+ }
434
+ const items = sessions.map((s) => {
435
+ const date = new Date(s.updated_at + "Z");
436
+ const ago = formatTimeAgo(date);
437
+ const dir = s.cwd.split("/").pop() || s.cwd;
438
+ const tokens = s.token_estimate >= 1000
439
+ ? `${(s.token_estimate / 1000).toFixed(1)}k`
440
+ : String(s.token_estimate);
441
+ return {
442
+ id: s.id,
443
+ display: `${s.id} ${dir}/ ${s.message_count} msgs ~${tokens} tok ${ago} ${s.model}`,
444
+ };
445
+ });
446
+ setSessionPicker(items);
447
+ setSessionPickerIndex(0);
448
+ return;
449
+ }
450
+ if (trimmed === "/push") {
451
+ try {
452
+ const output = execSync("git push", { cwd: process.cwd(), encoding: "utf-8", stdio: "pipe" });
453
+ addMsg("info", `✅ Pushed to remote${output.trim() ? "\n" + output.trim() : ""}`);
454
+ } catch (e: any) {
455
+ addMsg("error", `Push failed: ${e.stderr || e.message}`);
456
+ }
457
+ return;
458
+ }
459
+ if (trimmed.startsWith("/commit")) {
460
+ const msg = trimmed.replace("/commit", "").trim();
461
+ if (!msg) {
462
+ addMsg("info", "Usage: /commit your commit message here");
463
+ return;
464
+ }
465
+ try {
466
+ execSync("git add -A", { cwd: process.cwd(), stdio: "pipe" });
467
+ execSync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, { cwd: process.cwd(), stdio: "pipe" });
468
+ addMsg("info", `✅ Committed: ${msg}`);
469
+ } catch (e: any) {
470
+ addMsg("error", `Commit failed: ${e.stderr || e.message}`);
471
+ }
472
+ return;
473
+ }
474
+
475
+ setLoading(true);
476
+ setStreaming(false);
477
+ setSpinnerMsg(SPINNER_MESSAGES[Math.floor(Math.random() * SPINNER_MESSAGES.length)]);
478
+
479
+ try {
480
+ // Response is built incrementally via onToken callback
481
+ // chat() returns the final text but we don't need to add it again
482
+ await agent.chat(trimmed);
483
+ } catch (err: any) {
484
+ addMsg("error", `Error: ${err.message}`);
485
+ }
486
+
487
+ setLoading(false);
488
+ setStreaming(false);
489
+ }, [agent, exit]);
490
+
491
+ useInput((inputChar, key) => {
492
+ // Handle slash command navigation
493
+ if (showSuggestionsRef.current) {
494
+ const matches = cmdMatchesRef.current;
495
+ if (key.upArrow) {
496
+ setCmdIndex((prev) => (prev - 1 + matches.length) % matches.length);
497
+ return;
498
+ }
499
+ if (key.downArrow) {
500
+ setCmdIndex((prev) => (prev + 1) % matches.length);
501
+ return;
502
+ }
503
+ if (key.tab) {
504
+ const selected = matches[cmdIndexRef.current];
505
+ if (selected) {
506
+ setInput(selected.cmd + (selected.cmd === "/commit" ? " " : ""));
507
+ setCmdIndex(0);
508
+ setInputKey((k) => k + 1);
509
+ }
510
+ return;
511
+ }
512
+ }
513
+
514
+ // Session picker navigation
515
+ if (sessionPicker) {
516
+ if (key.upArrow) {
517
+ setSessionPickerIndex((prev) => (prev - 1 + sessionPicker.length) % sessionPicker.length);
518
+ return;
519
+ }
520
+ if (key.downArrow) {
521
+ setSessionPickerIndex((prev) => (prev + 1) % sessionPicker.length);
522
+ return;
523
+ }
524
+ if (key.return) {
525
+ const selected = sessionPicker[sessionPickerIndex];
526
+ if (selected && agent) {
527
+ const session = getSession(selected.id);
528
+ if (session) {
529
+ agent.resume(selected.id).then(() => {
530
+ const dir = session.cwd.split("/").pop() || session.cwd;
531
+ // Find last user message for context
532
+ const msgs = loadMessages(selected.id);
533
+ const lastUserMsg = [...msgs].reverse().find(m => m.role === "user");
534
+ const lastText = lastUserMsg && typeof lastUserMsg.content === "string"
535
+ ? lastUserMsg.content.slice(0, 80) + (lastUserMsg.content.length > 80 ? "..." : "")
536
+ : null;
537
+ let info = `✅ Resumed session ${selected.id} (${dir}/, ${session.message_count} messages)`;
538
+ if (lastText) info += `\n Last: "${lastText}"`;
539
+ addMsg("info", info);
540
+ }).catch((e: any) => {
541
+ addMsg("error", `Failed to resume: ${e.message}`);
542
+ });
543
+ }
544
+ }
545
+ setSessionPicker(null);
546
+ setSessionPickerIndex(0);
547
+ return;
548
+ }
549
+ if (key.escape) {
550
+ setSessionPicker(null);
551
+ setSessionPickerIndex(0);
552
+ addMsg("info", "Resume cancelled.");
553
+ return;
554
+ }
555
+ return; // Ignore other keys during session picker
556
+ }
557
+
558
+ // Backspace with empty input → remove last paste chunk
559
+ if (key.backspace || key.delete) {
560
+ if (input === "" && pastedChunksRef.current.length > 0) {
561
+ setPastedChunks((prev) => prev.slice(0, -1));
562
+ return;
563
+ }
564
+ }
565
+
566
+ // Handle approval prompts
567
+ if (approval) {
568
+ if (inputChar === "y" || inputChar === "Y") {
569
+ const r = approval.resolve;
570
+ setApproval(null);
571
+ setLoading(true);
572
+ setSpinnerMsg("Executing...");
573
+ r("yes");
574
+ return;
575
+ }
576
+ if (inputChar === "n" || inputChar === "N") {
577
+ const r = approval.resolve;
578
+ setApproval(null);
579
+ addMsg("info", "✗ Denied");
580
+ r("no");
581
+ return;
582
+ }
583
+ if (inputChar === "a" || inputChar === "A") {
584
+ const r = approval.resolve;
585
+ setApproval(null);
586
+ setLoading(true);
587
+ setSpinnerMsg("Executing...");
588
+ addMsg("info", `✔ Always allow ${approval.tool} for this session`);
589
+ r("always");
590
+ return;
591
+ }
592
+ return; // Ignore other keys during approval
593
+ }
594
+
595
+ if (key.ctrl && inputChar === "c") {
596
+ if (ctrlCPressed) {
597
+ exit();
598
+ } else {
599
+ setCtrlCPressed(true);
600
+ addMsg("info", "Press Ctrl+C again to exit.");
601
+ setTimeout(() => setCtrlCPressed(false), 3000);
602
+ }
603
+ }
604
+ });
605
+
606
+ // CODE banner lines
607
+ const codeLines = [
608
+ " _(`-') (`-') _ ",
609
+ " _ .-> ( (OO ).-> ( OO).-/ ",
610
+ " \\-,-----.(`-')----. \\ .'_ (,------. ",
611
+ " | .--./( OO).-. ''`'-..__) | .---' ",
612
+ " /_) (`-')( _) | | || | ' |(| '--. ",
613
+ " || |OO ) \\| |)| || | / : | .--' ",
614
+ "(_' '--'\\ ' '-' '| '-' / | `---. ",
615
+ " `-----' `-----' `------' `------' ",
616
+ ];
617
+ const maxxingLines = [
618
+ "<-. (`-') (`-') _ (`-') (`-') _ <-. (`-')_ ",
619
+ " \\(OO )_ (OO ).-/ (OO )_.-> (OO )_.-> (_) \\( OO) ) .-> ",
620
+ ",--./ ,-.) / ,---. (_| \\_)--. (_| \\_)--.,-(`-'),--./ ,--/ ,---(`-') ",
621
+ "| `.' | | \\ /`.\\ \\ `.' / \\ `.' / | ( OO)| \\ | | ' .-(OO ) ",
622
+ "| |'.'| | '-'|_.' | \\ .') \\ .') | | )| . '| |)| | .-, \\ ",
623
+ "| | | |(| .-. | .' \\ .' \\ (| |_/ | |\\ | | | '.(_/ ",
624
+ "| | | | | | | | / .'. \\ / .'. \\ | |'->| | \\ | | '-' | ",
625
+ "`--' `--' `--' `--'`--' '--'`--' '--'`--' `--' `--' `-----' ",
626
+ ];
627
+
628
+ return (
629
+ <Box flexDirection="column">
630
+ {/* ═══ BANNER BOX ═══ */}
631
+ <Box flexDirection="column" borderStyle="round" borderColor="#00FFFF" paddingX={1}>
632
+ {codeLines.map((line, i) => (
633
+ <Text key={`c${i}`} color="#00FFFF">{line}</Text>
634
+ ))}
635
+ {maxxingLines.map((line, i) => (
636
+ <Text key={`m${i}`} color={i === maxxingLines.length - 1 ? "#CC00CC" : "#FF00FF"}>{line}</Text>
637
+ ))}
638
+ <Text>
639
+ <Text color="#008B8B">{" v" + VERSION}</Text>
640
+ {" "}<Text color="#00FFFF">💪</Text>
641
+ {" "}<Text dimColor>your code. your model. no excuses.</Text>
642
+ </Text>
643
+ <Text dimColor>{" Type "}<Text color="#008B8B">/help</Text>{" for commands · "}<Text color="#008B8B">Ctrl+C</Text>{" twice to exit"}</Text>
644
+ </Box>
645
+
646
+ {/* ═══ CONNECTION INFO BOX ═══ */}
647
+ {connectionInfo.length > 0 && (
648
+ <Box flexDirection="column" borderStyle="single" borderColor="#008B8B" paddingX={1} marginBottom={1}>
649
+ {connectionInfo.map((line, i) => (
650
+ <Text key={i} color={line.startsWith("✔") ? "#00FFFF" : line.startsWith("✗") ? "red" : "#008B8B"}>{line}</Text>
651
+ ))}
652
+ </Box>
653
+ )}
654
+
655
+ {/* ═══ CHAT MESSAGES ═══ */}
656
+ {messages.map((msg) => {
657
+ switch (msg.type) {
658
+ case "user":
659
+ return (
660
+ <Box key={msg.id} marginTop={1}>
661
+ <Text color="#008B8B">{" > "}{msg.text}</Text>
662
+ </Box>
663
+ );
664
+ case "response":
665
+ return (
666
+ <Box key={msg.id} flexDirection="column" marginLeft={2} marginBottom={1}>
667
+ {msg.text.split("\n").map((l, i) => (
668
+ <Text key={i} wrap="wrap">
669
+ {i === 0 ? <Text color="#00FFFF">● </Text> : <Text> </Text>}
670
+ {l.startsWith("```") ? <Text color="#008B8B">{l}</Text> :
671
+ l.startsWith("# ") || l.startsWith("## ") ? <Text bold color="#FF00FF">{l}</Text> :
672
+ l.startsWith("**") ? <Text bold>{l}</Text> :
673
+ <Text>{l}</Text>}
674
+ </Text>
675
+ ))}
676
+ </Box>
677
+ );
678
+ case "tool":
679
+ return (
680
+ <Box key={msg.id}>
681
+ <Text><Text color="#00FFFF"> ● </Text><Text bold color="#FF00FF">{msg.text}</Text></Text>
682
+ </Box>
683
+ );
684
+ case "tool-result":
685
+ return <Text key={msg.id} color="#008B8B"> {msg.text}</Text>;
686
+ case "error":
687
+ return <Text key={msg.id} color="red"> {msg.text}</Text>;
688
+ case "info":
689
+ return <Text key={msg.id} color="#008B8B"> {msg.text}</Text>;
690
+ default:
691
+ return <Text key={msg.id}>{msg.text}</Text>;
692
+ }
693
+ })}
694
+
695
+ {/* ═══ SPINNER ═══ */}
696
+ {loading && !approval && !streaming && <NeonSpinner message={spinnerMsg} />}
697
+ {streaming && !loading && <StreamingIndicator />}
698
+
699
+ {/* ═══ APPROVAL PROMPT ═══ */}
700
+ {approval && (
701
+ <Box flexDirection="column" borderStyle="single" borderColor="#FF8C00" paddingX={1} marginTop={1}>
702
+ <Text bold color="#FF8C00">⚠ Approve {approval.tool}?</Text>
703
+ {approval.tool === "write_file" && approval.args.path ? (
704
+ <Text color="#008B8B">{" 📄 "}{String(approval.args.path)}</Text>
705
+ ) : null}
706
+ {approval.tool === "write_file" && approval.args.content ? (
707
+ <Text color="#008B8B">{" "}{String(approval.args.content).split("\n").length}{" lines, "}{String(approval.args.content).length}{"B"}</Text>
708
+ ) : null}
709
+ {approval.tool === "run_command" && approval.args.command ? (
710
+ <Text color="#008B8B">{" $ "}{String(approval.args.command)}</Text>
711
+ ) : null}
712
+ <Text>
713
+ <Text color="#00FF00" bold> [y]</Text><Text>es </Text>
714
+ <Text color="#FF0000" bold>[n]</Text><Text>o </Text>
715
+ <Text color="#00FFFF" bold>[a]</Text><Text>lways</Text>
716
+ </Text>
717
+ </Box>
718
+ )}
719
+
720
+ {/* ═══ SESSION PICKER ═══ */}
721
+ {sessionPicker && (
722
+ <Box flexDirection="column" borderStyle="single" borderColor="#FF00FF" paddingX={1} marginBottom={0}>
723
+ <Text bold color="#FF00FF">Resume a session:</Text>
724
+ {sessionPicker.map((s, i) => (
725
+ <Text key={s.id}>
726
+ {i === sessionPickerIndex ? <Text color="#FF00FF" bold>{"▸ "}</Text> : <Text>{" "}</Text>}
727
+ <Text color={i === sessionPickerIndex ? "#FF00FF" : "#008B8B"}>{s.display}</Text>
728
+ </Text>
729
+ ))}
730
+ <Text dimColor>{" ↑↓ navigate · Enter select · Esc cancel"}</Text>
731
+ </Box>
732
+ )}
733
+
734
+ {/* ═══ COMMAND SUGGESTIONS ═══ */}
735
+ {showSuggestions && (
736
+ <Box flexDirection="column" borderStyle="single" borderColor="#008B8B" paddingX={1} marginBottom={0}>
737
+ {cmdMatches.slice(0, 6).map((c, i) => (
738
+ <Text key={i}>
739
+ {i === cmdIndex ? <Text color="#FF00FF" bold>{"▸ "}</Text> : <Text>{" "}</Text>}
740
+ <Text color={i === cmdIndex ? "#FF00FF" : "#00FFFF"} bold>{c.cmd}</Text>
741
+ <Text color="#008B8B">{" — "}{c.desc}</Text>
742
+ </Text>
743
+ ))}
744
+ <Text dimColor>{" ↑↓ navigate · Tab select"}</Text>
745
+ </Box>
746
+ )}
747
+
748
+ {/* ═══ INPUT BOX (always at bottom) ═══ */}
749
+ <Box borderStyle="single" borderColor={approval ? "#FF8C00" : "#00FFFF"} paddingX={1}>
750
+ <Text color="#FF00FF" bold>{"> "}</Text>
751
+ {approval ? (
752
+ <Text color="#FF8C00">waiting for approval...</Text>
753
+ ) : ready && !loading ? (
754
+ <Box>
755
+ {pastedChunks.map((p) => (
756
+ <Text key={p.id} color="#008B8B">[Pasted text #{p.id} +{p.lines} lines]</Text>
757
+ ))}
758
+ <TextInput
759
+ key={inputKey}
760
+ value={input}
761
+ onChange={(v) => { setInput(v); setCmdIndex(0); }}
762
+ onSubmit={handleSubmit}
763
+ />
764
+ </Box>
765
+ ) : (
766
+ <Text dimColor>{loading ? "waiting for response..." : "initializing..."}</Text>
767
+ )}
768
+ </Box>
769
+
770
+ {/* ═══ STATUS BAR ═══ */}
771
+ {agent && (
772
+ <Box paddingX={2}>
773
+ <Text dimColor>
774
+ {"💬 "}{agent.getContextLength()}{" messages · ~"}
775
+ {(() => {
776
+ const tokens = agent.estimateTokens();
777
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
778
+ })()}
779
+ {" tokens"}
780
+ {modelName ? ` · 🤖 ${modelName}` : ""}
781
+ </Text>
782
+ </Box>
783
+ )}
784
+ </Box>
785
+ );
786
+ }
787
+
788
+ // Clear screen before render
789
+ process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
790
+
791
+ // Paste event bus — communicates between stdin interceptor and React
792
+ const pasteEvents = new EventEmitter();
793
+
794
+ // Enable bracketed paste mode — terminal wraps pastes in escape sequences
795
+ process.stdout.write("\x1b[?2004h");
796
+
797
+ // Intercept stdin to handle pasted content
798
+ // Bracketed paste: \x1b[200~ ... \x1b[201~
799
+ let pasteBuffer = "";
800
+ let inPaste = false;
801
+
802
+ const origPush = process.stdin.push.bind(process.stdin);
803
+ (process.stdin as any).push = function (chunk: any, encoding?: any) {
804
+ if (chunk === null) return origPush(chunk, encoding);
805
+
806
+ let data = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
807
+
808
+ const hasStart = data.includes("\x1b[200~");
809
+ const hasEnd = data.includes("\x1b[201~");
810
+
811
+ if (hasStart) {
812
+ inPaste = true;
813
+ data = data.replace(/\x1b\[200~/g, "");
814
+ }
815
+
816
+ if (hasEnd) {
817
+ data = data.replace(/\x1b\[201~/g, "");
818
+ pasteBuffer += data;
819
+ inPaste = false;
820
+
821
+ const content = pasteBuffer.trim();
822
+ pasteBuffer = "";
823
+ const lineCount = content.split("\n").length;
824
+
825
+ if (lineCount > 2) {
826
+ // Multi-line paste → store as chunk, don't send to input
827
+ pasteEvents.emit("paste", { content, lines: lineCount });
828
+ return true;
829
+ }
830
+
831
+ // Short paste (1-2 lines) — send as normal input
832
+ const sanitized = content.replace(/\r?\n/g, " ");
833
+ if (sanitized) {
834
+ return origPush(sanitized, "utf-8" as any);
835
+ }
836
+ return true;
837
+ }
838
+
839
+ if (inPaste) {
840
+ pasteBuffer += data;
841
+ return true;
842
+ }
843
+
844
+ data = data.replace(/\x1b\[20[01]~/g, "");
845
+ return origPush(typeof chunk === "string" ? data : Buffer.from(data), encoding);
846
+ };
847
+
848
+ // Disable bracketed paste on exit
849
+ process.on("exit", () => {
850
+ process.stdout.write("\x1b[?2004l");
851
+ });
852
+
853
+ // Handle terminal resize — clear ghost artifacts
854
+ process.stdout.on("resize", () => {
855
+ process.stdout.write("\x1B[2J\x1B[H");
856
+ });
857
+
858
+ render(<App />, { exitOnCtrlC: false });