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/bin/codeply.js +259 -0
- package/lib/agent.mjs +533 -0
- package/lib/ai.js +181 -0
- package/lib/ai.mjs +6 -0
- package/lib/apply-limit.js +41 -0
- package/lib/auth.js +112 -0
- package/lib/auth.mjs +6 -0
- package/lib/config.js +89 -0
- package/lib/edit-engine.js +207 -0
- package/lib/render-md.mjs +617 -0
- package/lib/textarea.mjs +258 -0
- package/lib/tools.mjs +376 -0
- package/lib/tui.js +7 -0
- package/lib/tui.mjs +899 -0
- package/package.json +45 -0
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;
|