codeply-cli 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/lib/tui.mjs ADDED
@@ -0,0 +1,899 @@
1
+ /**
2
+ * Codeply TUI — Google-brand interactive shell.
3
+ *
4
+ * Rendering model (important):
5
+ * Everything that is *finished* (banner + past messages + tool activity) is
6
+ * emitted through Ink's <Static>, which writes each item to stdout exactly
7
+ * once and never touches it again. Only the live area — spinner, approval
8
+ * prompt, command palette, composer, hints — lives in the re-rendered frame.
9
+ *
10
+ * This is what kills the flicker/duplicate-screen bug: Ink redraws a frame by
11
+ * erasing the previous one, but it can only erase lines still on screen. Once
12
+ * the frame grows taller than the terminal, the erase is incomplete and the
13
+ * frame gets appended instead of replaced — you see the conversation twice.
14
+ * Keeping the live frame a handful of lines tall makes the redraw exact.
15
+ *
16
+ * Rule for future edits: nothing tall goes outside <Static>.
17
+ *
18
+ * ┌ scrollback (banner + conversation, via <Static>) ──┐
19
+ * ├ live frame ────────────────────────────────────────┤
20
+ * │ spinner · approval · palette · composer · hints │
21
+ * └────────────────────────────────────────────────────┘
22
+ */
23
+ import React from 'react';
24
+ import { Box, Text, Static, useInput, useApp, useStdout } from 'ink';
25
+ import Spinner from 'ink-spinner';
26
+ import { createRequire } from 'module';
27
+ import path from 'path';
28
+ import os from 'os';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const auth = require('../lib/auth.js');
32
+ const appConfig = require('../lib/config.js');
33
+ const VERSION = require('../package.json').version;
34
+ import {
35
+ renderMarkdown, Panel, hexToAnsi, hexToAnsiBg, fillRow, SURFACE,
36
+ ANSI_RESET, ANSI_BOLD, ANSI_DIM,
37
+ } from '../lib/render-md.mjs';
38
+ import { runAgent } from '../lib/agent.mjs';
39
+ import { TOOL_DISPLAY } from '../lib/tools.mjs';
40
+ import TextArea, { enableModifiedEnter, isTerminalReport } from '../lib/textarea.mjs';
41
+
42
+ // ─── Theme (Google brand) ───────────────────────────────────────────────────
43
+ const G = {
44
+ blue: '#4285F4',
45
+ red: '#EA4335',
46
+ yellow: '#FBBC05',
47
+ green: '#34A853',
48
+ };
49
+
50
+ const T = {
51
+ accent: G.blue,
52
+ text: '#e8eaed',
53
+ secondary: '#9aa0a6',
54
+ dim: '#80868b',
55
+ muted: '#5f6368',
56
+ faint: '#3c4043',
57
+ border: '#3c4043',
58
+ cycle: [G.blue, G.red, G.yellow, G.green],
59
+ };
60
+
61
+ const MODES = ['Build', 'Plan', 'Ask'];
62
+ const MODE_COLOR = { Build: G.blue, Plan: G.yellow, Ask: G.green };
63
+ const MODE_HINT = {
64
+ Build: 'reads, edits and verifies files',
65
+ Plan: 'investigates, then writes a plan',
66
+ Ask: 'answers from the codebase, read-only',
67
+ };
68
+
69
+ // CODEPLY block letters — fixed per-letter widths, one Google color each.
70
+ // C=blue O=red D=yellow E=green P=blue L=red Y=yellow
71
+ const LOGO_SEGMENTS = [
72
+ [
73
+ { t: ' ██████╗ ', c: G.blue }, { t: ' ██████╗ ', c: G.red },
74
+ { t: '██████╗ ', c: G.yellow }, { t: '███████╗ ', c: G.green },
75
+ { t: '██████╗ ', c: G.blue }, { t: '██╗ ', c: G.red },
76
+ { t: '██╗ ██╗', c: G.yellow },
77
+ ],
78
+ [
79
+ { t: '██╔════╝ ', c: G.blue }, { t: '██╔═══██╗', c: G.red },
80
+ { t: '██╔══██╗ ', c: G.yellow }, { t: '██╔════╝ ', c: G.green },
81
+ { t: '██╔══██╗ ', c: G.blue }, { t: '██║ ', c: G.red },
82
+ { t: '╚██╗ ██╔╝', c: G.yellow },
83
+ ],
84
+ [
85
+ { t: '██║ ', c: G.blue }, { t: '██║ ██║', c: G.red },
86
+ { t: '██║ ██║ ', c: G.yellow }, { t: '█████╗ ', c: G.green },
87
+ { t: '██████╔╝ ', c: G.blue }, { t: '██║ ', c: G.red },
88
+ { t: ' ╚████╔╝ ', c: G.yellow },
89
+ ],
90
+ [
91
+ { t: '██║ ', c: G.blue }, { t: '██║ ██║', c: G.red },
92
+ { t: '██║ ██║ ', c: G.yellow }, { t: '██╔══╝ ', c: G.green },
93
+ { t: '██╔═══╝ ', c: G.blue }, { t: '██║ ', c: G.red },
94
+ { t: ' ╚██╔╝ ', c: G.yellow },
95
+ ],
96
+ [
97
+ { t: '╚██████╗ ', c: G.blue }, { t: '╚██████╔╝', c: G.red },
98
+ { t: '██████╔╝ ', c: G.yellow }, { t: '███████╗ ', c: G.green },
99
+ { t: '██║ ', c: G.blue }, { t: '███████╗ ', c: G.red },
100
+ { t: ' ██║ ', c: G.yellow },
101
+ ],
102
+ [
103
+ { t: ' ╚═════╝ ', c: G.blue }, { t: ' ╚═════╝ ', c: G.red },
104
+ { t: '╚═════╝ ', c: G.yellow }, { t: '╚══════╝ ', c: G.green },
105
+ { t: '╚═╝ ', c: G.blue }, { t: '╚══════╝ ', c: G.red },
106
+ { t: ' ╚═╝ ', c: G.yellow },
107
+ ],
108
+ ];
109
+
110
+ // Pre-flattened to one ANSI string per row: 6 <Text> nodes instead of 42.
111
+ const LOGO_ROWS = LOGO_SEGMENTS.map((row) =>
112
+ row.map((s) => hexToAnsi(s.c) + ANSI_BOLD + s.t + ANSI_RESET).join('')
113
+ );
114
+ const LOGO_WIDTH = LOGO_SEGMENTS[0].reduce((n, s) => n + s.t.length, 0);
115
+
116
+ const WORDMARK = hexToAnsi(G.yellow) + ANSI_BOLD + 'codeply' + ANSI_RESET;
117
+
118
+ const COMMANDS = [
119
+ { name: '/login', description: 'Sign in with your Codeply account' },
120
+ { name: '/logout', description: 'Sign out of current session' },
121
+ { name: '/whoami', description: 'Show currently signed-in account' },
122
+ { name: '/help', description: 'Show available commands' },
123
+ { name: '/clear', description: 'Clear conversation and context' },
124
+ { name: '/mode', description: 'Cycle Build → Plan → Ask' },
125
+ { name: '/keys', description: 'Show what your terminal sends for a key' },
126
+ { name: '/quit', description: 'Exit Codeply' },
127
+ ];
128
+
129
+ const TIPS = [
130
+ ['ctrl+j', 'inserts a new line — or end a line with \\ and press enter'],
131
+ ['Press tab', 'to cycle Build → Plan → Ask without leaving the composer'],
132
+ ['Press esc', 'while it is working to interrupt the run'],
133
+ ['Ask it to fix a failing test', 'it can run commands and iterate on the output'],
134
+ ['Run /keys', 'to see exactly what your terminal sends for a key'],
135
+ ];
136
+
137
+ function homePath(cwd) {
138
+ const home = os.homedir();
139
+ if (cwd.startsWith(home)) return '~' + cwd.slice(home.length);
140
+ return cwd;
141
+ }
142
+
143
+ /** Shorten a path from the left so it never blows out the footer. */
144
+ function fitPath(p, max) {
145
+ if (p.length <= max) return p;
146
+ return '…' + p.slice(p.length - max + 1);
147
+ }
148
+
149
+ // ─── Decorative starfield ───────────────────────────────────────────────────
150
+
151
+ function mulberry32(seed) {
152
+ let a = seed >>> 0;
153
+ return function () {
154
+ a = (a + 0x6d2b79f5) | 0;
155
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
156
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
157
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
158
+ };
159
+ }
160
+
161
+ const SPARKS = ['✦', '✧', '·', '✦'];
162
+ const SPARK_COLORS = [T.faint, T.faint, T.muted, '#4a3b1a'];
163
+
164
+ /** One row of scattered sparkles as a single ANSI string. */
165
+ function sparkleRow(width, count, rnd) {
166
+ const cells = new Array(width).fill(null);
167
+ for (let i = 0; i < count; i++) {
168
+ const col = Math.floor(rnd() * width);
169
+ if (cells[col]) continue;
170
+ cells[col] = {
171
+ ch: SPARKS[Math.floor(rnd() * SPARKS.length)],
172
+ c: SPARK_COLORS[Math.floor(rnd() * SPARK_COLORS.length)],
173
+ };
174
+ }
175
+ let out = '';
176
+ for (const cell of cells) {
177
+ out += cell ? hexToAnsi(cell.c) + cell.ch + ANSI_RESET : ' ';
178
+ }
179
+ return out.replace(/\s+$/, '');
180
+ }
181
+
182
+ // ─── Home screen ────────────────────────────────────────────────────────────
183
+
184
+ const LOGO_BLOCK_HEIGHT = LOGO_ROWS.length + 3; // logo + gap + two tagline lines
185
+ const COMPOSER_BLOCK_HEIGHT = 7; // gap + input + meta + gap + hints + tip
186
+
187
+ /**
188
+ * The full-viewport splash.
189
+ *
190
+ * Deliberately sized to the terminal so the app owns the screen instead of
191
+ * looking like output that happened to land in a shell. This is the ONE place a
192
+ * tall live frame is allowed: it only renders while the scrollback is empty, so
193
+ * the frame is still the entire output and Ink's erase stays exact. The moment
194
+ * a message exists we drop to the compact layout — see the note at the top of
195
+ * this file for why that matters.
196
+ */
197
+ /**
198
+ * `n` rows of starfield.
199
+ *
200
+ * Blank rows carry a single space rather than an empty string: Ink measures
201
+ * '' as zero height, so empty sky silently collapses and the screen never
202
+ * fills.
203
+ */
204
+ function SkyField({ cols, n, seed, keyPrefix }) {
205
+ const rnd = mulberry32(seed);
206
+ return Array.from({ length: Math.max(0, n) }, (_, i) =>
207
+ React.createElement(
208
+ Text,
209
+ { key: `${keyPrefix}-${i}` },
210
+ rnd() > 0.55 ? sparkleRow(cols, rnd() > 0.75 ? 2 : 1, rnd) : ' '
211
+ )
212
+ );
213
+ }
214
+
215
+ function HomeScreen({ cols, skyRows }) {
216
+ const wide = cols >= LOGO_WIDTH + 4;
217
+
218
+ return React.createElement(
219
+ Box,
220
+ { flexDirection: 'column', width: cols, alignItems: 'center' },
221
+
222
+ ...SkyField({ cols, n: skyRows, seed: 0xc0de91, keyPrefix: 'sky-top' }),
223
+
224
+ wide
225
+ ? LOGO_ROWS.map((row, i) => React.createElement(Text, { key: `lg-${i}` }, row))
226
+ : React.createElement(Text, { key: 'lg' }, WORDMARK),
227
+
228
+ React.createElement(
229
+ Box,
230
+ { key: 'tag', marginTop: 1, flexDirection: 'column', alignItems: 'center' },
231
+ React.createElement(
232
+ Text,
233
+ null,
234
+ hexToAnsi(T.secondary) + 'An agent that reads your code, edits it, and checks its work.' + ANSI_RESET
235
+ ),
236
+ React.createElement(
237
+ Text,
238
+ null,
239
+ hexToAnsi(T.muted) + 'interactive shell' + ANSI_RESET +
240
+ hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
241
+ hexToAnsi(T.muted) + 'v' + VERSION + ANSI_RESET
242
+ )
243
+ )
244
+ );
245
+ }
246
+
247
+ // ─── Message blocks ─────────────────────────────────────────────────────────
248
+
249
+ /** One line of tool activity in the transcript. */
250
+ function ToolLine({ msg }) {
251
+ const display = TOOL_DISPLAY[msg.name] || { verb: msg.name, icon: '▸' };
252
+ const failed = !msg.ok;
253
+ const wrote = msg.name === 'write_file' || msg.name === 'edit_file';
254
+ const ran = msg.name === 'run';
255
+
256
+ const iconColor = failed ? G.red : wrote ? G.yellow : ran ? G.green : G.blue;
257
+ const detail = [msg.label, msg.detail].filter(Boolean).join(' · ');
258
+
259
+ return React.createElement(
260
+ Text,
261
+ { wrap: 'truncate-end' },
262
+ hexToAnsi(iconColor) + display.icon + ' ' + ANSI_RESET +
263
+ hexToAnsi(failed ? G.red : T.secondary) + display.verb.padEnd(6) + ANSI_RESET +
264
+ hexToAnsi(T.dim) + ' ' + detail + ANSI_RESET
265
+ );
266
+ }
267
+
268
+ function MessageBlock({ msg, cols }) {
269
+ if (msg.role === 'user') {
270
+ return React.createElement(
271
+ Box,
272
+ { flexDirection: 'row', marginBottom: 1, gap: 1 },
273
+ React.createElement(Text, { color: G.blue }, '▌'),
274
+ React.createElement(
275
+ Box,
276
+ { flexDirection: 'column', flexGrow: 1 },
277
+ React.createElement(Text, null, hexToAnsi(T.dim) + ANSI_DIM + 'you' + ANSI_RESET),
278
+ React.createElement(Text, { color: T.text, wrap: 'wrap' }, msg.content)
279
+ )
280
+ );
281
+ }
282
+
283
+ if (msg.role === 'assistant') {
284
+ // A plan is a deliverable, not chatter — give it its own panel so it stands
285
+ // apart from the running commentary above and below it.
286
+ if (msg.plan) {
287
+ return React.createElement(
288
+ Box,
289
+ { flexDirection: 'row', marginBottom: 1, gap: 1 },
290
+ React.createElement(Text, { color: G.yellow }, '▌'),
291
+ React.createElement(
292
+ Box,
293
+ { flexDirection: 'column', flexGrow: 1 },
294
+ React.createElement(Panel, {
295
+ title: 'plan',
296
+ lines: msg.content.split('\n'),
297
+ width: cols - 4,
298
+ accent: G.yellow,
299
+ })
300
+ )
301
+ );
302
+ }
303
+ return React.createElement(
304
+ Box,
305
+ { flexDirection: 'row', marginBottom: 1, gap: 1 },
306
+ React.createElement(Text, { color: G.green }, '▌'),
307
+ React.createElement(
308
+ Box,
309
+ { flexDirection: 'column', flexGrow: 1 },
310
+ React.createElement(Text, null, WORDMARK),
311
+ ...renderMarkdown(msg.content, cols - 4)
312
+ )
313
+ );
314
+ }
315
+
316
+ if (msg.role === 'tool') {
317
+ return React.createElement(
318
+ Box,
319
+ { flexDirection: 'row', marginBottom: 0, paddingLeft: 2 },
320
+ React.createElement(ToolLine, { msg })
321
+ );
322
+ }
323
+
324
+ if (msg.role === 'system') {
325
+ return React.createElement(
326
+ Box,
327
+ { flexDirection: 'row', marginBottom: 1, gap: 1 },
328
+ React.createElement(Text, { color: G.yellow }, '◆'),
329
+ React.createElement(Text, { color: T.secondary, wrap: 'wrap' }, msg.content)
330
+ );
331
+ }
332
+
333
+ if (msg.role === 'error') {
334
+ return React.createElement(
335
+ Box,
336
+ { flexDirection: 'row', marginBottom: 1, gap: 1 },
337
+ React.createElement(Text, { color: G.red }, '✗'),
338
+ React.createElement(Text, { color: G.red, wrap: 'wrap' }, msg.content)
339
+ );
340
+ }
341
+
342
+ return null;
343
+ }
344
+
345
+ // ─── Command palette ────────────────────────────────────────────────────────
346
+
347
+ function CommandsMenu({ commands, selectedIndex }) {
348
+ return React.createElement(
349
+ Box,
350
+ {
351
+ flexDirection: 'column',
352
+ borderStyle: 'round',
353
+ borderColor: T.border,
354
+ paddingX: 1,
355
+ marginBottom: 1,
356
+ flexShrink: 0,
357
+ },
358
+ React.createElement(
359
+ Text,
360
+ null,
361
+ hexToAnsi(T.muted) + '↑↓' + ANSI_RESET + hexToAnsi(T.faint) + ' select ' + ANSI_RESET +
362
+ hexToAnsi(T.muted) + 'tab' + ANSI_RESET + hexToAnsi(T.faint) + ' complete ' + ANSI_RESET +
363
+ hexToAnsi(T.muted) + 'esc' + ANSI_RESET + hexToAnsi(T.faint) + ' dismiss' + ANSI_RESET
364
+ ),
365
+ ...commands.map((cmd, i) => {
366
+ const selected = i === selectedIndex;
367
+ const nameColor = selected ? T.cycle[i % T.cycle.length] : T.secondary;
368
+ return React.createElement(
369
+ Text,
370
+ { key: cmd.name, wrap: 'truncate-end' },
371
+ (selected ? hexToAnsi(G.blue) + '› ' + ANSI_RESET : ' ') +
372
+ hexToAnsi(nameColor) + (selected ? ANSI_BOLD : '') + cmd.name.padEnd(10) + ANSI_RESET +
373
+ hexToAnsi(selected ? T.dim : T.faint) + ' ' + cmd.description + ANSI_RESET
374
+ );
375
+ })
376
+ );
377
+ }
378
+
379
+ // ─── Approval prompt ────────────────────────────────────────────────────────
380
+
381
+ function ApprovalPrompt({ request }) {
382
+ const accent = request.danger ? G.red : G.yellow;
383
+ const lines = [];
384
+
385
+ if (request.diff) {
386
+ const removed = request.diff.search.split('\n').slice(0, 6);
387
+ const added = request.diff.replace.split('\n').slice(0, 6);
388
+ for (const l of removed) lines.push(hexToAnsi(G.red) + '- ' + l + ANSI_RESET);
389
+ for (const l of added) lines.push(hexToAnsi(G.green) + '+ ' + l + ANSI_RESET);
390
+ }
391
+
392
+ return React.createElement(
393
+ Box,
394
+ {
395
+ flexDirection: 'column',
396
+ borderStyle: 'round',
397
+ borderColor: accent,
398
+ paddingX: 1,
399
+ marginBottom: 1,
400
+ flexShrink: 0,
401
+ },
402
+ React.createElement(
403
+ Text,
404
+ { wrap: 'truncate-end' },
405
+ hexToAnsi(accent) + ANSI_BOLD + request.title + ANSI_RESET +
406
+ (request.danger ? hexToAnsi(G.red) + ' ⚠ looks destructive' + ANSI_RESET : '')
407
+ ),
408
+ request.detail && React.createElement(
409
+ Text,
410
+ { wrap: 'truncate-end' },
411
+ hexToAnsi(T.secondary) + request.detail + ANSI_RESET
412
+ ),
413
+ ...lines.map((l, i) => React.createElement(Text, { key: i, wrap: 'truncate-end' }, l)),
414
+ React.createElement(
415
+ Text,
416
+ null,
417
+ hexToAnsi(G.green) + 'y' + ANSI_RESET + hexToAnsi(T.faint) + ' approve ' + ANSI_RESET +
418
+ hexToAnsi(G.blue) + 'a' + ANSI_RESET + hexToAnsi(T.faint) + ' approve all this session ' + ANSI_RESET +
419
+ hexToAnsi(G.red) + 'n' + ANSI_RESET + hexToAnsi(T.faint) + ' decline' + ANSI_RESET
420
+ )
421
+ );
422
+ }
423
+
424
+ // ─── App ────────────────────────────────────────────────────────────────────
425
+
426
+ const App = () => {
427
+ const { exit } = useApp();
428
+ const { stdout } = useStdout();
429
+ const [cols, setCols] = React.useState(stdout?.columns || 80);
430
+ const [rows, setRows] = React.useState(stdout?.rows || 24);
431
+
432
+ const [input, setInput] = React.useState('');
433
+ const [modeIndex, setModeIndex] = React.useState(0);
434
+ const [session, setSession] = React.useState(null);
435
+ const [running, setRunning] = React.useState(false);
436
+ const [statusLine, setStatusLine] = React.useState('');
437
+ const [elapsed, setElapsed] = React.useState(0);
438
+ const [steps, setSteps] = React.useState(0);
439
+ const [chatHistory, setChatHistory] = React.useState([]);
440
+ const [staticKey, setStaticKey] = React.useState(0);
441
+ const [showCommands, setShowCommands] = React.useState(false);
442
+ const [selectedCommand, setSelectedCommand] = React.useState(0);
443
+ const [filteredCommands, setFilteredCommands] = React.useState(COMMANDS);
444
+ const [escArmed, setEscArmed] = React.useState(false);
445
+ const [approval, setApproval] = React.useState(null);
446
+ // Only true once the terminal answers our capability query. Windows Terminal
447
+ // never does, so there we advertise ctrl+j instead of lying about shift+enter.
448
+ const [modifiedEnterWorks, setModifiedEnterWorks] = React.useState(false);
449
+ const [keyProbe, setKeyProbe] = React.useState(false);
450
+
451
+ const nextId = React.useRef(1);
452
+ const abortRef = React.useRef({ aborted: false });
453
+ const alwaysAllow = React.useRef(new Set());
454
+ const convo = React.useRef([]); // {role,content} pairs handed to the agent
455
+
456
+ const cwd = process.cwd();
457
+ const mode = MODES[modeIndex];
458
+ const modeColor = MODE_COLOR[mode];
459
+ const tip = React.useMemo(() => TIPS[Math.floor(Math.random() * TIPS.length)], []);
460
+
461
+ const push = React.useCallback((msg) => {
462
+ setChatHistory((prev) => [...prev, { id: `m-${nextId.current++}`, ...msg }]);
463
+ }, []);
464
+
465
+ React.useEffect(() => {
466
+ auth.getSession().then((s) => setSession(s)).catch(() => {});
467
+ }, []);
468
+
469
+ // Ask the terminal to disambiguate shift+enter from enter (see textarea.mjs).
470
+ React.useEffect(() => enableModifiedEnter(stdout), [stdout]);
471
+
472
+ React.useEffect(() => {
473
+ if (!stdout) return;
474
+ const onResize = () => { setCols(stdout.columns || 80); setRows(stdout.rows || 24); };
475
+ stdout.on('resize', onResize);
476
+ return () => stdout.off('resize', onResize);
477
+ }, [stdout]);
478
+
479
+ React.useEffect(() => {
480
+ if (input.startsWith('/') && !input.includes('\n')) {
481
+ const query = input.toLowerCase().split(/\s/)[0];
482
+ const filtered = COMMANDS.filter((cmd) => cmd.name.startsWith(query));
483
+ setFilteredCommands(filtered);
484
+ setShowCommands(filtered.length > 0);
485
+ setSelectedCommand(0);
486
+ } else {
487
+ setShowCommands(false);
488
+ }
489
+ }, [input]);
490
+
491
+ React.useEffect(() => {
492
+ if (!escArmed) return;
493
+ const t = setTimeout(() => setEscArmed(false), 1500);
494
+ return () => clearTimeout(t);
495
+ }, [escArmed]);
496
+
497
+ // Elapsed counter while the agent works. The spinner already repaints the
498
+ // live frame several times a second, so this ticker costs nothing extra.
499
+ React.useEffect(() => {
500
+ if (!running) { setElapsed(0); return; }
501
+ const startedAt = Date.now();
502
+ const t = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
503
+ return () => clearInterval(t);
504
+ }, [running]);
505
+
506
+ // ── approval bridge: tools await this promise, the keypress handler settles it ──
507
+ const requestApproval = React.useCallback((request) => {
508
+ if (alwaysAllow.current.has(request.tool)) return Promise.resolve('always');
509
+ return new Promise((resolve) => {
510
+ setApproval({ request, resolve });
511
+ });
512
+ }, []);
513
+
514
+ const settleApproval = React.useCallback((verdict) => {
515
+ setApproval((current) => {
516
+ if (!current) return null;
517
+ if (verdict === 'always') alwaysAllow.current.add(current.request.tool);
518
+ current.resolve(verdict);
519
+ return null;
520
+ });
521
+ }, []);
522
+
523
+ useInput((char, key) => {
524
+ // The terminal's answer to our kitty query — proof that modified enter is
525
+ // really distinguishable here. Swallow it so it never reaches the draft.
526
+ if (isTerminalReport(char)) { setModifiedEnterWorks(true); return; }
527
+
528
+ // /keys diagnostic: report exactly what the terminal sent for one keypress.
529
+ if (keyProbe) {
530
+ setKeyProbe(false);
531
+ const seq = JSON.stringify(char);
532
+ const flags = ['ctrl', 'shift', 'meta', 'return', 'tab', 'escape', 'backspace', 'delete']
533
+ .filter((f) => key[f]).join(', ') || 'none';
534
+ push({ role: 'system', content: `Key sent: ${seq}\nFlags: ${flags}` });
535
+ return;
536
+ }
537
+
538
+ // The approval prompt owns the keyboard while it is up.
539
+ if (approval) {
540
+ if (char === 'y' || key.return) { settleApproval('once'); return; }
541
+ if (char === 'a') { settleApproval('always'); return; }
542
+ if (char === 'n' || key.escape) { settleApproval('reject'); return; }
543
+ return;
544
+ }
545
+
546
+ if (key.escape) {
547
+ if (running) { abortRef.current.aborted = true; setStatusLine('interrupting…'); return; }
548
+ if (showCommands) { setShowCommands(false); return; }
549
+ if (input.length > 0) {
550
+ if (escArmed) { setInput(''); setEscArmed(false); }
551
+ else { setEscArmed(true); }
552
+ return;
553
+ }
554
+ if (escArmed) { exit(); } else { setEscArmed(true); }
555
+ return;
556
+ }
557
+
558
+ if (key.ctrl && char === 'c') {
559
+ if (running) { abortRef.current.aborted = true; return; }
560
+ if (input.length > 0) { setInput(''); return; }
561
+ exit();
562
+ return;
563
+ }
564
+
565
+ if (key.tab) {
566
+ if (showCommands && filteredCommands.length > 0) {
567
+ const sel = filteredCommands[selectedCommand];
568
+ if (sel) { setInput(sel.name + ' '); setShowCommands(false); }
569
+ return;
570
+ }
571
+ setModeIndex((i) => (i + 1) % MODES.length);
572
+ return;
573
+ }
574
+
575
+ if (showCommands) {
576
+ if (key.upArrow) { setSelectedCommand((p) => (p > 0 ? p - 1 : filteredCommands.length - 1)); return; }
577
+ if (key.downArrow) { setSelectedCommand((p) => (p < filteredCommands.length - 1 ? p + 1 : 0)); return; }
578
+ }
579
+ });
580
+
581
+ // ── slash commands ──
582
+ const runCommand = async (cmd) => {
583
+ if (cmd === '/login') {
584
+ push({ role: 'system', content: 'Opening sign-in…' });
585
+ try {
586
+ await auth.login();
587
+ const s = await auth.getSession();
588
+ setSession(s);
589
+ push({ role: 'system', content: s ? `Signed in as ${s.user.email}` : 'Login cancelled or failed' });
590
+ } catch (e) {
591
+ push({ role: 'error', content: e.message || 'Login failed' });
592
+ }
593
+ } else if (cmd === '/logout') {
594
+ await auth.logout();
595
+ setSession(null);
596
+ push({ role: 'system', content: 'Signed out' });
597
+ } else if (cmd === '/whoami') {
598
+ const s = await auth.getSession();
599
+ push({ role: 'system', content: s ? s.user.email : 'Not signed in' });
600
+ } else if (cmd === '/help') {
601
+ push({
602
+ role: 'system',
603
+ content: 'Commands\n' + COMMANDS.map((c) => `${c.name.padEnd(10)} ${c.description}`).join('\n') +
604
+ '\n\nKeys\nshift+enter / ctrl+j new line\ntab switch mode\nesc interrupt a run\n' +
605
+ `\nModel\n${appConfig.describeProvider()} — change with \`codeply provider\`\n`,
606
+ });
607
+ } else if (cmd === '/clear') {
608
+ // <Static> never re-renders what it already printed, so a clear means
609
+ // wiping the terminal and remounting it with a fresh key. Empty history
610
+ // also drops us back to the home splash.
611
+ convo.current = [];
612
+ if (stdout) stdout.write('\x1b[2J\x1b[3J\x1b[H');
613
+ setChatHistory([]);
614
+ setStaticKey((k) => k + 1);
615
+ } else if (cmd === '/mode') {
616
+ const next = (modeIndex + 1) % MODES.length;
617
+ setModeIndex(next);
618
+ push({ role: 'system', content: `Mode → ${MODES[next]} — ${MODE_HINT[MODES[next]]}` });
619
+ } else if (cmd === '/quit' || cmd === '/exit') {
620
+ exit();
621
+ } else {
622
+ push({ role: 'error', content: `Unknown command: ${cmd}. Type /help for available commands.` });
623
+ }
624
+ };
625
+
626
+ // ── main turn ──
627
+ const handleSubmit = async (value) => {
628
+ const text = value.trim();
629
+ if (!text || running) return;
630
+
631
+ let submitValue = text;
632
+ if (showCommands && filteredCommands[selectedCommand]) {
633
+ const raw = text.split(/\s+/)[0].toLowerCase();
634
+ if (!COMMANDS.some((c) => c.name === raw)) {
635
+ submitValue = filteredCommands[selectedCommand].name;
636
+ }
637
+ }
638
+ setShowCommands(false);
639
+ setInput('');
640
+
641
+ if (submitValue.startsWith('/')) {
642
+ await runCommand(submitValue.split(/\s+/)[0].toLowerCase());
643
+ return;
644
+ }
645
+
646
+ push({ role: 'user', content: submitValue });
647
+ setRunning(true);
648
+ setStatusLine('thinking…');
649
+ setSteps(0);
650
+ abortRef.current = { aborted: false };
651
+
652
+ const turn = [];
653
+ try {
654
+ const stream = runAgent({
655
+ userMessage: submitValue,
656
+ history: convo.current.slice(-20),
657
+ mode,
658
+ cwd,
659
+ approve: requestApproval,
660
+ signal: abortRef.current,
661
+ });
662
+
663
+ for await (const event of stream) {
664
+ if (event.type === 'text') {
665
+ // In Plan mode the substantial reply is the plan itself; short
666
+ // interstitial notes ("let me check X") stay as ordinary prose.
667
+ const isPlan = mode === 'Plan' && event.text.split('\n').length >= 3;
668
+ push({ role: 'assistant', content: event.text, plan: isPlan });
669
+ turn.push({ role: 'assistant', content: event.text });
670
+ setStatusLine('working…');
671
+ } else if (event.type === 'tool_start') {
672
+ const display = TOOL_DISPLAY[event.name] || { verb: event.name };
673
+ setSteps((n) => n + 1);
674
+ setStatusLine(`${display.verb} ${event.args.path || event.args.command || event.args.pattern || ''}`.trim());
675
+ } else if (event.type === 'tool_end') {
676
+ const meta = event.meta || {};
677
+ const unit = event.name === 'search' ? 'matches'
678
+ : event.name === 'list_dir' ? 'entries'
679
+ : 'lines';
680
+ let detail = '';
681
+ if (meta.count != null) detail = `${meta.count} ${unit}`;
682
+ if (meta.added != null) detail = `-${meta.removed || 0} +${meta.added}`;
683
+ if (meta.exitCode != null) detail = `exit ${meta.exitCode}`;
684
+ if (meta.noop) detail = 'no change';
685
+ if (meta.rejected) detail = 'declined';
686
+ push({
687
+ role: 'tool',
688
+ name: event.name,
689
+ ok: event.ok && !meta.rejected && (meta.exitCode == null || meta.exitCode === 0),
690
+ label: event.summary || '',
691
+ detail,
692
+ });
693
+ setStatusLine('thinking…');
694
+ } else if (event.type === 'aborted') {
695
+ push({ role: 'system', content: 'Interrupted.' });
696
+ } else if (event.type === 'error') {
697
+ push({ role: 'error', content: event.error });
698
+ }
699
+ }
700
+ } catch (e) {
701
+ push({ role: 'error', content: e.message });
702
+ } finally {
703
+ convo.current = [...convo.current, { role: 'user', content: submitValue }, ...turn];
704
+ setRunning(false);
705
+ setStatusLine('');
706
+ // A run that ended while a prompt was up must not leave a dangling promise.
707
+ setApproval((current) => { if (current) current.resolve('reject'); return null; });
708
+ }
709
+ };
710
+
711
+ // Ollama needs no Codeply sign-in, so showing "not signed in" there would be
712
+ // a false alarm — surface the model that is actually answering instead.
713
+ const provider = React.useMemo(() => appConfig.getConfig(), []);
714
+ const usingOllama = provider.provider === 'ollama';
715
+ const account = usingOllama
716
+ ? appConfig.describeProvider(provider)
717
+ : session ? session.user.email : 'not signed in';
718
+
719
+ // Advertise the key that actually inserts a newline in THIS terminal.
720
+ const newlineKey = modifiedEnterWorks ? 'shift+enter' : 'ctrl+j';
721
+
722
+ const isHome = chatHistory.length === 0;
723
+ // The splash needs real estate; in a short or narrow window fall straight
724
+ // through to the compact layout rather than rendering a cramped one.
725
+ const showSplash = isHome && rows >= 22 && cols >= 44;
726
+
727
+ // On home the composer is a centred column like an app window; in
728
+ // conversation it spans the width so long messages have room.
729
+ const shellWidth = showSplash ? Math.min(cols - 4, 92) : cols - 2;
730
+ const inner = Math.max(20, shellWidth);
731
+
732
+ // Two-column rows are laid out by hand: Ink truncates each child against the
733
+ // width it was given, so without explicit widths the columns run into each
734
+ // other on narrow terminals instead of truncating cleanly.
735
+ const rightHint = escArmed
736
+ ? `esc again to ${input.length > 0 ? 'clear' : 'quit'}`
737
+ : running ? 'esc interrupts' : 'enter to send';
738
+ const footRight = fitPath(homePath(cwd), Math.max(12, Math.floor(inner * 0.45))) + ' codeply ' + VERSION;
739
+ const hintsWidth = Math.max(10, inner - rightHint.length - 2);
740
+ const tipWidth = inner - footRight.length - 2;
741
+ const showTip = tipWidth >= 24;
742
+
743
+ const composer = React.createElement(
744
+ Box,
745
+ {
746
+ key: 'composer',
747
+ flexDirection: 'column',
748
+ borderStyle: 'bold',
749
+ borderTop: false,
750
+ borderRight: false,
751
+ borderBottom: false,
752
+ borderLeftColor: running ? T.muted : modeColor,
753
+ paddingLeft: 1,
754
+ width: shellWidth,
755
+ marginTop: showSplash ? 2 : 0,
756
+ flexShrink: 0,
757
+ },
758
+ React.createElement(TextArea, {
759
+ value: input,
760
+ onChange: setInput,
761
+ onSubmit: handleSubmit,
762
+ width: inner - 3,
763
+ focus: !running && !approval,
764
+ allowVertical: !showCommands,
765
+ placeholder: running
766
+ ? 'working — esc to interrupt'
767
+ : 'Ask for a change, a fix, or an explanation… (/ for commands)',
768
+ }),
769
+ React.createElement(
770
+ Text,
771
+ { wrap: 'truncate-end' },
772
+ hexToAnsi(modeColor) + ANSI_BOLD + mode + ANSI_RESET +
773
+ hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
774
+ hexToAnsi(T.muted) + MODE_HINT[mode] + ANSI_RESET +
775
+ hexToAnsi(T.faint) + ' · ' + ANSI_RESET +
776
+ hexToAnsi(usingOllama || session ? T.dim : G.yellow) + account + ANSI_RESET
777
+ )
778
+ );
779
+
780
+ const hints = React.createElement(
781
+ Box,
782
+ { key: 'hints', flexDirection: 'row', justifyContent: 'space-between', width: shellWidth, marginTop: 1, flexShrink: 0 },
783
+ React.createElement(
784
+ Box,
785
+ { width: hintsWidth, flexShrink: 0 },
786
+ React.createElement(
787
+ Text,
788
+ { wrap: 'truncate-end' },
789
+ hexToAnsi(G.blue) + 'tab' + ANSI_RESET + hexToAnsi(T.faint) + ' mode ' + ANSI_RESET +
790
+ hexToAnsi(G.red) + newlineKey + ANSI_RESET + hexToAnsi(T.faint) + ' newline ' + ANSI_RESET +
791
+ hexToAnsi(G.yellow) + '/' + ANSI_RESET + hexToAnsi(T.faint) + ' commands ' + ANSI_RESET +
792
+ hexToAnsi(G.green) + 'esc' + ANSI_RESET + hexToAnsi(T.faint) + ' interrupt' + ANSI_RESET
793
+ )
794
+ ),
795
+ React.createElement(
796
+ Text,
797
+ null,
798
+ (escArmed ? hexToAnsi(G.yellow) : hexToAnsi(T.faint)) + rightHint + ANSI_RESET
799
+ )
800
+ );
801
+
802
+ const tipLine = React.createElement(
803
+ Box,
804
+ { key: 'tip', width: shellWidth, flexShrink: 0 },
805
+ showTip && React.createElement(
806
+ Text,
807
+ { wrap: 'truncate-end' },
808
+ hexToAnsi(G.yellow) + '• ' + ANSI_RESET +
809
+ hexToAnsi(T.muted) + tip[0] + ANSI_RESET +
810
+ hexToAnsi(T.faint) + ' ' + tip[1] + ANSI_RESET
811
+ )
812
+ );
813
+
814
+ const footer = React.createElement(
815
+ Box,
816
+ { key: 'footer', flexDirection: 'row', justifyContent: 'space-between', width: shellWidth, flexShrink: 0 },
817
+ React.createElement(
818
+ Box,
819
+ { width: Math.max(0, tipWidth), flexShrink: 0 },
820
+ showTip && React.createElement(
821
+ Text,
822
+ { wrap: 'truncate-end' },
823
+ hexToAnsi(G.yellow) + '• ' + ANSI_RESET +
824
+ hexToAnsi(T.muted) + tip[0] + ANSI_RESET +
825
+ hexToAnsi(T.faint) + ' ' + tip[1] + ANSI_RESET
826
+ )
827
+ ),
828
+ React.createElement(Text, null, hexToAnsi(T.faint) + footRight + ANSI_RESET)
829
+ );
830
+
831
+ // ── Home: one full-viewport screen, centred like an app ──
832
+ if (showSplash) {
833
+ const avail = rows - 1;
834
+ // Sky is whatever the logo, composer and the pinned bottom bar don't use,
835
+ // split so the logo sits a little above centre — the same weighting the
836
+ // reference app uses.
837
+ const free = Math.max(0, avail - LOGO_BLOCK_HEIGHT - COMPOSER_BLOCK_HEIGHT - 1);
838
+ const skyTop = Math.round(free * 0.42);
839
+ const skyBottom = free - skyTop;
840
+
841
+ return React.createElement(
842
+ Box,
843
+ { flexDirection: 'column', width: cols, height: avail, alignItems: 'center' },
844
+ React.createElement(HomeScreen, { key: 'home', cols, skyRows: skyTop }),
845
+ composer,
846
+ hints,
847
+ tipLine,
848
+ ...SkyField({ cols, n: skyBottom, seed: 0x5eed17, keyPrefix: 'sky-bot' }),
849
+ // Pinned to the last row, like a status bar.
850
+ React.createElement(
851
+ Box,
852
+ { key: 'bar', flexDirection: 'row', justifyContent: 'space-between', width: cols - 2 },
853
+ React.createElement(Text, null, hexToAnsi(T.faint) + fitPath(homePath(cwd), Math.max(12, cols - 24)) + ANSI_RESET),
854
+ React.createElement(Text, null, hexToAnsi(T.faint) + 'codeply ' + VERSION + ANSI_RESET)
855
+ )
856
+ );
857
+ }
858
+
859
+ // ── Conversation: scrollback + a deliberately short live frame ──
860
+ return React.createElement(
861
+ Box,
862
+ { flexDirection: 'column', width: cols, paddingX: 1 },
863
+
864
+ // ── Scrollback: printed once, never redrawn. Keeps the live frame short. ──
865
+ React.createElement(
866
+ Static,
867
+ { key: staticKey, items: chatHistory },
868
+ (msg) => React.createElement(MessageBlock, { key: msg.id, msg, cols: cols - 2 })
869
+ ),
870
+
871
+ // ── Live frame below ──
872
+
873
+ running && React.createElement(
874
+ Box,
875
+ { flexDirection: 'row', gap: 1, marginBottom: 1, flexShrink: 0 },
876
+ React.createElement(Text, { color: T.accent }, React.createElement(Spinner, { type: 'dots' })),
877
+ React.createElement(
878
+ Text,
879
+ { wrap: 'truncate-end' },
880
+ hexToAnsi(T.dim) + (statusLine || 'working…') + ANSI_RESET +
881
+ hexToAnsi(T.faint) + ' ' + elapsed + 's' + ANSI_RESET +
882
+ (steps > 0 ? hexToAnsi(T.faint) + ' · step ' + steps + ANSI_RESET : '')
883
+ )
884
+ ),
885
+
886
+ approval && React.createElement(ApprovalPrompt, { request: approval.request }),
887
+
888
+ showCommands && React.createElement(CommandsMenu, {
889
+ commands: filteredCommands,
890
+ selectedIndex: selectedCommand,
891
+ }),
892
+
893
+ composer,
894
+ hints,
895
+ footer
896
+ );
897
+ };
898
+
899
+ export default App;