nexrall-code 0.5.106 → 0.5.108
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/dist/audit.js +102 -0
- package/dist/commands/auth.js +134 -0
- package/dist/commands/chat.js +2715 -0
- package/dist/commands/plugin.js +393 -0
- package/dist/commands/sessions.js +62 -0
- package/dist/commands/update.js +174 -0
- package/dist/index.js +1708 -63
- package/dist/permissions/handler.js +332 -0
- package/dist/terminal/terminalSetup.js +395 -0
- package/dist/trust.js +137 -0
- package/dist/ui/diff.js +51 -0
- package/dist/ui/inkReadlineAdapter.js +116 -0
- package/dist/ui/inkTerminal.js +1097 -0
- package/dist/ui/resizeRepaint.js +36 -0
- package/dist/ui/screen.js +482 -0
- package/dist/ui/theme.js +385 -0
- package/dist/ui/trustPrompt.js +126 -0
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const defaultDeps = {
|
|
2
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
3
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Fires `onSettle` once, `settleMs` after the LAST `notify()` call — i.e.
|
|
7
|
+
* once resize events have stopped arriving for that long. Any `notify()`
|
|
8
|
+
* that arrives before the window elapses restarts it, so a continuous drag
|
|
9
|
+
* (events firing every few milliseconds) never triggers `onSettle` until the
|
|
10
|
+
* user actually lets go.
|
|
11
|
+
*
|
|
12
|
+
* `deps` defaults to the real timers; tests inject fakes so the debounce
|
|
13
|
+
* window can be exercised deterministically without real elapsed time.
|
|
14
|
+
*/
|
|
15
|
+
export function createResizeDebouncer(settleMs, onSettle, deps = defaultDeps) {
|
|
16
|
+
let timer = null;
|
|
17
|
+
let disposed = false;
|
|
18
|
+
const notify = () => {
|
|
19
|
+
if (disposed)
|
|
20
|
+
return;
|
|
21
|
+
if (timer !== null)
|
|
22
|
+
deps.clearTimeout(timer);
|
|
23
|
+
timer = deps.setTimeout(() => {
|
|
24
|
+
timer = null;
|
|
25
|
+
onSettle();
|
|
26
|
+
}, settleMs);
|
|
27
|
+
};
|
|
28
|
+
const dispose = () => {
|
|
29
|
+
disposed = true;
|
|
30
|
+
if (timer !== null) {
|
|
31
|
+
deps.clearTimeout(timer);
|
|
32
|
+
timer = null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return { notify, dispose };
|
|
36
|
+
}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { visibleLen, padVis } from './theme';
|
|
3
|
+
// ─── Session Screen Setup ──────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// The session runs in the terminal's NORMAL screen buffer, and starts by
|
|
6
|
+
// clearing both the screen and the scrollback. It deliberately does NOT use
|
|
7
|
+
// the alternate screen buffer (\x1b[?1049h) that vim/less/htop use, which is
|
|
8
|
+
// what this did previously.
|
|
9
|
+
//
|
|
10
|
+
// The alternate buffer is the wrong tool for a scrolling chat transcript, for
|
|
11
|
+
// two reasons that show up immediately in normal use:
|
|
12
|
+
//
|
|
13
|
+
// • It has no scrollback of its own. Once the conversation grows past one
|
|
14
|
+
// screenful the earlier turns — and the banner — are gone for good, not
|
|
15
|
+
// merely off-screen. There is nothing to scroll back to.
|
|
16
|
+
// • Because it doesn't scroll, terminals including macOS Terminal.app route
|
|
17
|
+
// a mouse-wheel scroll to the MAIN buffer sitting underneath instead. So
|
|
18
|
+
// scrolling up during a session shows whatever was on screen before `nex`
|
|
19
|
+
// started — typically a stack of empty `user@host ~ %` shell prompts —
|
|
20
|
+
// which is precisely the reported symptom.
|
|
21
|
+
//
|
|
22
|
+
// Clearing the normal buffer instead gives the desired behaviour directly and
|
|
23
|
+
// with far less machinery: the session begins on a blank screen with the
|
|
24
|
+
// banner at the top, and as the conversation grows it scrolls naturally, so
|
|
25
|
+
// scrolling up reveals the banner and earlier turns and nothing else —
|
|
26
|
+
// because after the clear that is genuinely all the scrollback contains.
|
|
27
|
+
//
|
|
28
|
+
// It also means the session's output is still there after exit, which is
|
|
29
|
+
// usually what you want from a CLI (you can scroll back to what the agent
|
|
30
|
+
// said), whereas the alternate buffer discarded all of it on quit.
|
|
31
|
+
//
|
|
32
|
+
// \x1b[2J clears the visible screen; \x1b[3J clears the scrollback (xterm's
|
|
33
|
+
// XTCLEARSB — supported by Terminal.app, iTerm2, Ghostty, kitty, WezTerm,
|
|
34
|
+
// VS Code's terminal and Windows Terminal); \x1b[H then homes the cursor so
|
|
35
|
+
// output starts at row 1 rather than wherever the shell prompt left it.
|
|
36
|
+
// Terminals that don't implement 3J simply keep their scrollback: the session
|
|
37
|
+
// still renders correctly, it just doesn't get the clean-slate scrollback.
|
|
38
|
+
export function prepareSessionScreen() {
|
|
39
|
+
if (!process.stdout.isTTY)
|
|
40
|
+
return;
|
|
41
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
42
|
+
}
|
|
43
|
+
// ─── Pinning the bottom chrome to the last row ─────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Rows Ink's persistent bottom chrome occupies: top separator, input line,
|
|
46
|
+
* bottom separator, footer.
|
|
47
|
+
*
|
|
48
|
+
* This is the MINIMUM (one row each) — it is only correct while every one of
|
|
49
|
+
* those four rows fits on a single terminal line. Prefer bottomChromeRows()
|
|
50
|
+
* below, which accounts for the two ways that assumption breaks.
|
|
51
|
+
*/
|
|
52
|
+
export const BOTTOM_CHROME_ROWS = 4;
|
|
53
|
+
/**
|
|
54
|
+
* Rows the bottom chrome ACTUALLY occupies, given the terminal width and the
|
|
55
|
+
* text currently in the input box.
|
|
56
|
+
*
|
|
57
|
+
* `BOTTOM_CHROME_ROWS` is a hardcoded 4, which silently under-counts in two
|
|
58
|
+
* situations that are both easy to hit — and under-counting makes padToBottom
|
|
59
|
+
* push the chrome PAST the last row, scrolling the top of the banner away:
|
|
60
|
+
*
|
|
61
|
+
* • A narrow terminal wraps the footer. The footer string
|
|
62
|
+
* ("auto mode on · /help for shortcuts · /agents for agents") is 55 cells,
|
|
63
|
+
* so at any width below ~56 columns it takes two rows, not one — measured
|
|
64
|
+
* at 40 columns: actual chrome 5, constant 4.
|
|
65
|
+
* • Shift+Enter puts real newlines IN the input line (a feature this same
|
|
66
|
+
* UI added), so the input row becomes as many rows as there are lines —
|
|
67
|
+
* measured: one Shift+Enter → 5, three → 7, against a constant of 4.
|
|
68
|
+
*
|
|
69
|
+
* Deriving the number from the same strings the component renders means the
|
|
70
|
+
* two cannot drift apart the way a hand-maintained constant did.
|
|
71
|
+
*/
|
|
72
|
+
export function bottomChromeRows(opts = {
|
|
73
|
+
footerText: '',
|
|
74
|
+
}) {
|
|
75
|
+
const columns = opts.columns ?? process.stdout.columns ?? 80;
|
|
76
|
+
// Two full-width rules now bound the input box: one above (existing) and
|
|
77
|
+
// one below (added so the footer hints sit visually separated from the
|
|
78
|
+
// input, matching the separator already drawn above the box) — see
|
|
79
|
+
// inkTerminal.tsx's second <Box borderBottom> rule.
|
|
80
|
+
const separatorRows = 2;
|
|
81
|
+
const inputRows = rowsOccupied(' '.repeat(opts.promptWidth ?? 2) + (opts.inputText ?? ''), columns);
|
|
82
|
+
const footerRows = rowsOccupied(opts.footerText, columns);
|
|
83
|
+
return separatorRows + inputRows + footerRows;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Terminal rows a printed string occupies, accounting for long lines
|
|
87
|
+
* soft-wrapping at the terminal's width. Counting '\n' alone undercounts (and
|
|
88
|
+
* so under-pads) whenever a line is wider than the terminal — e.g. a long
|
|
89
|
+
* working-directory path on a narrow window. Measured in cells via visibleLen,
|
|
90
|
+
* so CJK and emoji are counted correctly.
|
|
91
|
+
*/
|
|
92
|
+
export function rowsOccupied(text, columns = process.stdout.columns || 80) {
|
|
93
|
+
let rows = 0;
|
|
94
|
+
for (const line of text.split('\n')) {
|
|
95
|
+
const width = visibleLen(line);
|
|
96
|
+
rows += width === 0 ? 1 : Math.ceil(width / columns);
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Pure row-count arithmetic behind padToBottom() below — extracted so callers
|
|
102
|
+
* that need to RECOMPUTE the same filler amount without printing anything can
|
|
103
|
+
* do so. This exists for exactly one reason: inkTerminal.tsx's
|
|
104
|
+
* `forceFullRedraw` (resize) and `collapseStartupPadding` both raw-clear the
|
|
105
|
+
* screen (`\x1b[2J\x1b[H`) and remount `<Static>`, which replays only what is
|
|
106
|
+
* in the `items` array — any padding padToBottom printed via plain
|
|
107
|
+
* `console.log` is NOT in `items`, so it is wiped by that clear and never
|
|
108
|
+
* replayed, leaving the chrome stranded wherever the replayed items end
|
|
109
|
+
* instead of at the last row (this was a real, reproducible bug: verified via
|
|
110
|
+
* pty capture that the footer floated mid-screen, sometimes permanently, on
|
|
111
|
+
* both the very first resize AND the very first message of every session,
|
|
112
|
+
* because both paths share this exact redraw mechanism). The fix is for
|
|
113
|
+
* those callers to push a FILLER ITEM into `items` itself instead of calling
|
|
114
|
+
* `console.log` — see inkTerminal.tsx's `repadBottomChrome` — which requires
|
|
115
|
+
* the same arithmetic padToBottom uses, minus the actual printing.
|
|
116
|
+
*
|
|
117
|
+
* See padToBottom's own doc for why every constant below is what it is —
|
|
118
|
+
* this is the identical math, just factored out so both call sites can't
|
|
119
|
+
* drift apart the way a second hand-copied formula would.
|
|
120
|
+
*/
|
|
121
|
+
export function fillerRowCount(rowsAlreadyPrinted, footerTextValue = '', columns = process.stdout.columns ?? 80, rows = process.stdout.rows) {
|
|
122
|
+
if (!rows)
|
|
123
|
+
return 0;
|
|
124
|
+
// Two rows of headroom, not one. The first is the cursor row Ink keeps
|
|
125
|
+
// below its frame. The second is a safety margin: the tally can only measure
|
|
126
|
+
// what went through console.log, and mis-counting by even one row overflows
|
|
127
|
+
// the viewport and scrolls the top of the banner away permanently. Erring
|
|
128
|
+
// one row short is invisible (the footer sits one line above the bottom);
|
|
129
|
+
// erring one row long destroys the banner. The asymmetry justifies the
|
|
130
|
+
// margin.
|
|
131
|
+
//
|
|
132
|
+
// The chrome height is MEASURED (bottomChromeRows) rather than assumed to be
|
|
133
|
+
// 3: on a terminal narrower than the footer string the footer wraps onto a
|
|
134
|
+
// second row, and the old constant then under-counted by one and pushed the
|
|
135
|
+
// banner's top row off the screen. The input box is empty at startup, which
|
|
136
|
+
// is when this runs, so only the footer can wrap here.
|
|
137
|
+
const chromeRows = bottomChromeRows({ footerText: footerTextValue, columns });
|
|
138
|
+
const filler = rows - rowsAlreadyPrinted - chromeRows - 2;
|
|
139
|
+
// Skip padding entirely unless there is a comfortable amount of slack.
|
|
140
|
+
//
|
|
141
|
+
// When startup output nearly fills the terminal there is no gap worth
|
|
142
|
+
// closing, but there IS a real risk: any timing jitter in Ink's frame
|
|
143
|
+
// commits can then push the total one row past the viewport and scroll the
|
|
144
|
+
// banner's top away. Measured on a 24-row terminal, where startup already
|
|
145
|
+
// occupies 21 rows, padding failed roughly 1 run in 8 for exactly this
|
|
146
|
+
// reason, while adding at most two blank rows of benefit.
|
|
147
|
+
//
|
|
148
|
+
// The padding exists to close an 8-19 row gap on a roomy terminal. Below
|
|
149
|
+
// this threshold the gap is already negligible, so declining to pad trades
|
|
150
|
+
// nothing for determinism.
|
|
151
|
+
const MIN_WORTHWHILE_FILLER = 3;
|
|
152
|
+
return filler < MIN_WORTHWHILE_FILLER ? 0 : filler;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Prints blank rows so the input line and footer come to rest on the LAST row
|
|
156
|
+
* of the terminal, instead of floating directly beneath the banner with dead
|
|
157
|
+
* space below them.
|
|
158
|
+
*
|
|
159
|
+
* The blanks MUST go through `console.log`, not `process.stdout.write`. Ink
|
|
160
|
+
* patches console.log (patchConsole) to erase its current frame, emit the
|
|
161
|
+
* line above it, then repaint the frame below — which is exactly the "push
|
|
162
|
+
* the chrome down" behaviour wanted. A raw stdout write bypasses that
|
|
163
|
+
* bookkeeping: the text lands wherever the cursor happens to be, Ink's idea
|
|
164
|
+
* of its own frame position goes stale, and the next repaint draws the
|
|
165
|
+
* chrome back over the padding. An earlier attempt did precisely this and
|
|
166
|
+
* left the footer stranded mid-screen.
|
|
167
|
+
*
|
|
168
|
+
* This runs once at startup. Once the conversation grows past one screenful
|
|
169
|
+
* the padding is irrelevant: content then fills the viewport and the chrome
|
|
170
|
+
* stays on the last row through ordinary terminal scrolling (verified by
|
|
171
|
+
* streaming 30 lines after padding — the footer did not move).
|
|
172
|
+
*
|
|
173
|
+
* Returns whether it actually printed filler rows. A terminal is append-only:
|
|
174
|
+
* these blank rows can never be "filled in" by later output, only appended
|
|
175
|
+
* after — so the caller (chat.ts) uses this to know whether it must later
|
|
176
|
+
* collapse them via inkTerminal.tsx's `collapseStartupPadding()` once real
|
|
177
|
+
* conversation content starts, instead of leaving them permanently wedged
|
|
178
|
+
* between the banner and the first turn (see that function's own doc for the
|
|
179
|
+
* full mechanism and the pty capture that found this).
|
|
180
|
+
*/
|
|
181
|
+
export function padToBottom(rowsAlreadyPrinted, footerText = '') {
|
|
182
|
+
if (!process.stdout.isTTY)
|
|
183
|
+
return false;
|
|
184
|
+
const filler = fillerRowCount(rowsAlreadyPrinted, footerText);
|
|
185
|
+
if (filler <= 0)
|
|
186
|
+
return false;
|
|
187
|
+
// Emitted as ONE console.log, not `filler` separate calls. Ink's patched
|
|
188
|
+
// console.log erases its frame, writes the line, then repaints the frame
|
|
189
|
+
// below — so N calls mean N erase/repaint cycles in a tight loop, and on
|
|
190
|
+
// taller terminals the incremental erase falls behind, leaving stray
|
|
191
|
+
// separator fragments on screen and scrolling the banner's top row away.
|
|
192
|
+
// A single call is one erase/repaint for the whole block.
|
|
193
|
+
//
|
|
194
|
+
// console.log appends its own trailing newline, so `filler - 1` newlines
|
|
195
|
+
// produce exactly `filler` rows.
|
|
196
|
+
console.log('\n'.repeat(filler - 1));
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Tallies the terminal rows consumed by everything written through
|
|
201
|
+
* `console.log` between `startRowCount()` and `stopRowCount()`.
|
|
202
|
+
*
|
|
203
|
+
* Startup output is conditional in several places — permission rules, loaded
|
|
204
|
+
* skills, connected MCP servers, resumed-session notices — so a hardcoded
|
|
205
|
+
* "the banner is N rows tall" constant would drift out of date the next time
|
|
206
|
+
* one of those notices is added or reworded, silently breaking the alignment
|
|
207
|
+
* again. Measuring what was actually printed cannot drift.
|
|
208
|
+
*
|
|
209
|
+
* Must be started AFTER Ink's render() has run: patchConsole replaces
|
|
210
|
+
* console.log at that point, and starting first would have Ink's patch
|
|
211
|
+
* overwrite the counting wrapper, leaving the count at zero.
|
|
212
|
+
*/
|
|
213
|
+
let rowCounter = null;
|
|
214
|
+
export function startRowCount() {
|
|
215
|
+
if (rowCounter)
|
|
216
|
+
return;
|
|
217
|
+
const columns = process.stdout.columns || 80;
|
|
218
|
+
const originalLog = console.log;
|
|
219
|
+
const originalError = console.error;
|
|
220
|
+
const state = { rows: 0, originalLog, originalError };
|
|
221
|
+
rowCounter = state;
|
|
222
|
+
// console.log() with no arguments prints a single blank line.
|
|
223
|
+
const measure = (args) => args.length === 0 ? 1 : rowsOccupied(args.map(String).join(' '), columns);
|
|
224
|
+
console.log = (...args) => {
|
|
225
|
+
state.rows += measure(args);
|
|
226
|
+
originalLog(...args);
|
|
227
|
+
};
|
|
228
|
+
// console.error is counted too, and it is not a theoretical case: Ink patches
|
|
229
|
+
// BOTH (patchConsole), so an error printed during startup lands on screen and
|
|
230
|
+
// pushes the chrome down exactly like a console.log does — but it used to go
|
|
231
|
+
// untallied, so padToBottom under-measured and over-padded.
|
|
232
|
+
//
|
|
233
|
+
// Startup really does write here. A failing MCP server logs
|
|
234
|
+
// `[MCP] Failed to connect to server "…": …` via console.error, and it is
|
|
235
|
+
// long: measured at 277 characters, which is 3 rows at 100 columns, none of
|
|
236
|
+
// which the tally saw. Every startup error path (session-not-found, compact
|
|
237
|
+
// failures) has the same shape.
|
|
238
|
+
console.error = (...args) => {
|
|
239
|
+
state.rows += measure(args);
|
|
240
|
+
originalError(...args);
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** Stops tallying and returns the row count. Safe to call when not started. */
|
|
244
|
+
export function stopRowCount() {
|
|
245
|
+
if (!rowCounter)
|
|
246
|
+
return 0;
|
|
247
|
+
const { rows, originalLog, originalError } = rowCounter;
|
|
248
|
+
console.log = originalLog;
|
|
249
|
+
console.error = originalError;
|
|
250
|
+
rowCounter = null;
|
|
251
|
+
return rows;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Manually adds `text`'s row height to the running tally, for output that
|
|
255
|
+
* bypasses console.log/console.error entirely and so is invisible to the
|
|
256
|
+
* patched functions above.
|
|
257
|
+
*
|
|
258
|
+
* The interactive-path welcome banner is exactly this case: it goes through
|
|
259
|
+
* `getInkTerminal()!.print()` (an Ink `<Static>` list item, so it can be
|
|
260
|
+
* regenerated on resize — see setBannerRegenerator's doc), never through
|
|
261
|
+
* console.log. Before this existed, padToBottom() was blind to the banner's
|
|
262
|
+
* ~15-18 rows entirely: it only tallied the handful of console.log lines
|
|
263
|
+
* printed AFTER the banner (permission rules, skills, "Type a message…"),
|
|
264
|
+
* computed a filler far larger than the real gap, and pushed the input
|
|
265
|
+
* box/footer clean off the bottom of the viewport — scrolling the banner's
|
|
266
|
+
* own top rows away and leaving a large empty gap above the chrome. Callers
|
|
267
|
+
* must invoke this with the exact string passed to `print()`, immediately
|
|
268
|
+
* after printing it, so the count reflects what is really on screen.
|
|
269
|
+
*
|
|
270
|
+
* A no-op when counting hasn't started (mirrors stopRowCount's safety) so
|
|
271
|
+
* call sites don't need to guard on `interactive` themselves.
|
|
272
|
+
*/
|
|
273
|
+
export function addRowCount(text) {
|
|
274
|
+
if (!rowCounter)
|
|
275
|
+
return;
|
|
276
|
+
const columns = process.stdout.columns || 80;
|
|
277
|
+
rowCounter.rows += rowsOccupied(text, columns);
|
|
278
|
+
}
|
|
279
|
+
// ─── Welcome Banner (box-drawn "Welcome back" card) ────────────────────────
|
|
280
|
+
//
|
|
281
|
+
// Mirrors Claude Code's startup screen: a rounded box with version header,
|
|
282
|
+
// centered greeting + logo on the left, "Tips" / "What's new" on the right.
|
|
283
|
+
// Pure chalk + Unicode box-drawing — no React/Ink dependency needed here,
|
|
284
|
+
// since this only runs ONCE at session start, before Ink's own render() has
|
|
285
|
+
// booted (see chat.ts's startChatSession: the banner call is deliberately
|
|
286
|
+
// ordered after startInkTerminal() so Ink's patchConsole picks it up like
|
|
287
|
+
// any other console.log call).
|
|
288
|
+
const NEX_LOGO = [
|
|
289
|
+
'███╗ ██╗███████╗██╗ ██╗',
|
|
290
|
+
'████╗ ██║██╔════╝╚██╗██╔╝',
|
|
291
|
+
'██╔██╗ ██║█████╗ ╚███╔╝ ',
|
|
292
|
+
'██║╚██╗██║██╔══╝ ██╔██╗ ',
|
|
293
|
+
'██║ ╚████║███████╗██╔╝ ██╗',
|
|
294
|
+
'╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝',
|
|
295
|
+
];
|
|
296
|
+
/** Center `s` (already chalk-styled OK) within visible width `w`. */
|
|
297
|
+
function center(s, w) {
|
|
298
|
+
const len = visibleLen(s);
|
|
299
|
+
if (len >= w)
|
|
300
|
+
return s;
|
|
301
|
+
const left = Math.floor((w - len) / 2);
|
|
302
|
+
return ' '.repeat(left) + s;
|
|
303
|
+
}
|
|
304
|
+
// Both truncators below iterate over grapheme-ish units and measure with
|
|
305
|
+
// visibleLen (terminal cells) rather than slicing by String index, so a
|
|
306
|
+
// wide CJK character is never cut in half and a two-cell glyph is never
|
|
307
|
+
// counted as one cell — either mistake would leave the column a cell over
|
|
308
|
+
// budget and tear the frame.
|
|
309
|
+
const graphemesOf = (s) => [...s];
|
|
310
|
+
/** Trim `s` to at most `w` cells, marking the cut with a trailing ellipsis. */
|
|
311
|
+
function truncateEnd(s, w) {
|
|
312
|
+
if (visibleLen(s) <= w)
|
|
313
|
+
return s;
|
|
314
|
+
let out = '';
|
|
315
|
+
for (const g of graphemesOf(s)) {
|
|
316
|
+
if (visibleLen(out + g) > w - 1)
|
|
317
|
+
break;
|
|
318
|
+
out += g;
|
|
319
|
+
}
|
|
320
|
+
return out + '…';
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Trim `s` to at most `w` cells from the START, marking the cut with a leading
|
|
324
|
+
* ellipsis — used for filesystem paths, where the tail identifies the project
|
|
325
|
+
* and the head is usually a long, uninformative home-directory prefix.
|
|
326
|
+
*/
|
|
327
|
+
function truncateStart(s, w) {
|
|
328
|
+
if (visibleLen(s) <= w)
|
|
329
|
+
return s;
|
|
330
|
+
const gs = graphemesOf(s);
|
|
331
|
+
let out = '';
|
|
332
|
+
for (let i = gs.length - 1; i >= 0; i--) {
|
|
333
|
+
if (visibleLen(gs[i] + out) > w - 1)
|
|
334
|
+
break;
|
|
335
|
+
out = gs[i] + out;
|
|
336
|
+
}
|
|
337
|
+
return '…' + out;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Renders the two-column bordered welcome card. Returns the fully composed
|
|
341
|
+
* string (caller does the single console.log) so callers/tests can inspect
|
|
342
|
+
* it without capturing stdout.
|
|
343
|
+
*/
|
|
344
|
+
export function renderBanner(info) {
|
|
345
|
+
// ─── Geometry ────────────────────────────────────────────────────────────
|
|
346
|
+
//
|
|
347
|
+
// The card spans the FULL terminal width and is split into two columns by a
|
|
348
|
+
// vertical rule, so the layout is derived from the terminal size rather than
|
|
349
|
+
// from fixed column widths:
|
|
350
|
+
//
|
|
351
|
+
// │ <left column> │ <right column> │
|
|
352
|
+
// ^ ^ ^ ^ ^ ^
|
|
353
|
+
// │ └ PAD │ └ PAD │ └ PAD (1 cell each side of a column)
|
|
354
|
+
// └ frame └ divider └ frame
|
|
355
|
+
//
|
|
356
|
+
// Every row is exactly `totalW` cells, so the frame, the divider and the
|
|
357
|
+
// horizontal rules all line up by construction instead of by three separate
|
|
358
|
+
// hand-maintained arithmetic expressions that can drift apart (the previous
|
|
359
|
+
// revision's rules were 2 cells wider than its content rows for exactly
|
|
360
|
+
// that reason).
|
|
361
|
+
const PAD = 1;
|
|
362
|
+
// Non-content cells in a row: the 3 frame/divider glyphs (│ … │ … │) plus
|
|
363
|
+
// PAD cells on each side of each of the 2 columns — i.e. 4 pads, not 6.
|
|
364
|
+
// Overstating this by 2 made the card render 2 cells narrower than the
|
|
365
|
+
// terminal instead of spanning it fully.
|
|
366
|
+
const CHROME = 3 + 4 * PAD;
|
|
367
|
+
// The card spans the full terminal width. A floor keeps it from collapsing
|
|
368
|
+
// below what the logo plus a usable tips column need on a very narrow
|
|
369
|
+
// window — in that case the card is wider than the terminal and the
|
|
370
|
+
// terminal soft-wraps it, which is still more legible than a crushed or
|
|
371
|
+
// torn layout.
|
|
372
|
+
const LOGO_W = Math.max(...NEX_LOGO.map(visibleLen));
|
|
373
|
+
const termW = info.columns ?? process.stdout.columns ?? 100;
|
|
374
|
+
// One cell short of the terminal, for the same reason as the input
|
|
375
|
+
// separator in inkTerminal.tsx: a row that fills the final column makes many
|
|
376
|
+
// terminals wrap immediately, turning each banner row into two and doubling
|
|
377
|
+
// the card's height (which then scrolls its own top off the screen).
|
|
378
|
+
const totalW = Math.max(LOGO_W + CHROME + 24, termW - 1);
|
|
379
|
+
// The left column hugs its content (the logo is the widest fixed element);
|
|
380
|
+
// the right column takes everything that's left, so the divider sits at a
|
|
381
|
+
// stable position and the tips get the larger share on wide terminals.
|
|
382
|
+
const headerText = `Nexrall Code v${info.version}`;
|
|
383
|
+
const LEFT_W = Math.max(LOGO_W, visibleLen(headerText));
|
|
384
|
+
const RIGHT_W = totalW - CHROME - LEFT_W;
|
|
385
|
+
const leftLines = [];
|
|
386
|
+
leftLines.push(chalk.bold.hex('#3b82f6')('Nexrall Code ') + chalk.dim(`v${info.version}`));
|
|
387
|
+
leftLines.push('');
|
|
388
|
+
leftLines.push(center(chalk.bold(`Welcome back ${info.userLabel}!`), LEFT_W));
|
|
389
|
+
leftLines.push('');
|
|
390
|
+
// Logo lines are all LOGO_W wide and LEFT_W is >= LOGO_W, so centering here
|
|
391
|
+
// keeps the logo optically centred over the column without ever shifting it
|
|
392
|
+
// off the left edge.
|
|
393
|
+
for (const l of NEX_LOGO)
|
|
394
|
+
leftLines.push(center(chalk.hex('#3b82f6')(l), LEFT_W));
|
|
395
|
+
leftLines.push('');
|
|
396
|
+
// The left column is sized to the logo, so anything longer — most often a
|
|
397
|
+
// deeply nested working directory — has to be truncated or it would push
|
|
398
|
+
// past the divider and tear the frame. Paths are truncated from the LEFT so
|
|
399
|
+
// the meaningful tail (the project directory) survives.
|
|
400
|
+
leftLines.push(chalk.dim(truncateEnd(info.modelLabel, LEFT_W)));
|
|
401
|
+
leftLines.push(chalk.dim(truncateStart(info.workDir, LEFT_W)));
|
|
402
|
+
if (info.nexrallMdLoaded)
|
|
403
|
+
leftLines.push(chalk.dim('nexrall.md loaded'));
|
|
404
|
+
const rightLines = [];
|
|
405
|
+
rightLines.push(chalk.bold.yellow('Tips for getting started'));
|
|
406
|
+
const tips = info.tips ?? [
|
|
407
|
+
'Run /init to create a nexrall.md file with instructions for Nexrall Code.',
|
|
408
|
+
'Use /help to see all slash commands.',
|
|
409
|
+
];
|
|
410
|
+
for (const t of tips)
|
|
411
|
+
rightLines.push(...wrapText(t, RIGHT_W));
|
|
412
|
+
const whatsNew = info.whatsNew ?? [];
|
|
413
|
+
if (whatsNew.length) {
|
|
414
|
+
rightLines.push('');
|
|
415
|
+
rightLines.push(chalk.bold.yellow("What's new"));
|
|
416
|
+
for (const t of whatsNew)
|
|
417
|
+
rightLines.push(...wrapText(t, RIGHT_W));
|
|
418
|
+
rightLines.push(chalk.dim.italic('/release-notes for more'));
|
|
419
|
+
}
|
|
420
|
+
const rowCount = Math.max(leftLines.length, rightLines.length);
|
|
421
|
+
// Nexrall brand blue — deliberately NOT Claude Code's orange (#d97757):
|
|
422
|
+
// this UI mirrors Claude Code's LAYOUT (box banner, two columns) but must
|
|
423
|
+
// stay visually distinct as Nexrall's own product.
|
|
424
|
+
const border = chalk.hex('#3b82f6');
|
|
425
|
+
// Each column's full span including its padding on both sides. The
|
|
426
|
+
// horizontal rules are built from these same two numbers as the content
|
|
427
|
+
// rows, so the T-junctions (┬ ┴) always land exactly on the divider (│)
|
|
428
|
+
// rather than being positioned by separate arithmetic that can drift.
|
|
429
|
+
const leftSpan = LEFT_W + PAD * 2;
|
|
430
|
+
const rightSpan = RIGHT_W + PAD * 2;
|
|
431
|
+
const pad = ' '.repeat(PAD);
|
|
432
|
+
const rule = (l, mid, r) => border(l + '─'.repeat(leftSpan) + mid + '─'.repeat(rightSpan) + r);
|
|
433
|
+
const lines = [];
|
|
434
|
+
lines.push(rule('╭', '┬', '╮'));
|
|
435
|
+
for (let i = 0; i < rowCount; i++) {
|
|
436
|
+
const l = padVis(leftLines[i] ?? '', LEFT_W);
|
|
437
|
+
const r = padVis(rightLines[i] ?? '', RIGHT_W);
|
|
438
|
+
lines.push(border('│') + pad + l + pad + border('│') + pad + r + pad + border('│'));
|
|
439
|
+
}
|
|
440
|
+
lines.push(rule('╰', '┴', '╯'));
|
|
441
|
+
return lines.join('\n');
|
|
442
|
+
}
|
|
443
|
+
/** Naive word-wrap that respects visible (ANSI-stripped) width. */
|
|
444
|
+
function wrapText(text, width) {
|
|
445
|
+
const words = text.split(' ');
|
|
446
|
+
const out = [];
|
|
447
|
+
let cur = '';
|
|
448
|
+
for (const w of words) {
|
|
449
|
+
const candidate = cur ? cur + ' ' + w : w;
|
|
450
|
+
if (visibleLen(candidate) > width && cur) {
|
|
451
|
+
out.push(cur);
|
|
452
|
+
cur = w;
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
cur = candidate;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (cur)
|
|
459
|
+
out.push(cur);
|
|
460
|
+
// A single "word" longer than the column (a URL, or any run of CJK, which
|
|
461
|
+
// has no spaces to break on at all) can't be wrapped by the loop above and
|
|
462
|
+
// would otherwise overflow the column and tear the frame. Hard-break any
|
|
463
|
+
// such line at the column width.
|
|
464
|
+
const fitted = [];
|
|
465
|
+
for (const line of out) {
|
|
466
|
+
if (visibleLen(line) <= width) {
|
|
467
|
+
fitted.push(line);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
let chunk = '';
|
|
471
|
+
for (const g of graphemesOf(line)) {
|
|
472
|
+
if (visibleLen(chunk + g) > width) {
|
|
473
|
+
fitted.push(chunk);
|
|
474
|
+
chunk = '';
|
|
475
|
+
}
|
|
476
|
+
chunk += g;
|
|
477
|
+
}
|
|
478
|
+
if (chunk)
|
|
479
|
+
fitted.push(chunk);
|
|
480
|
+
}
|
|
481
|
+
return fitted;
|
|
482
|
+
}
|