@namiml/cli 3.4.12-dev.202609302217 → 3.4.12-dev.202610051802
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/placements/page.js +34 -0
- package/dist/render/capabilities.js +63 -0
- package/dist/render/graphics.js +203 -0
- package/dist/render/image.js +362 -0
- package/dist/render/layout.js +544 -0
- package/dist/render/page-model.js +610 -0
- package/dist/render/paint.js +625 -0
- package/dist/render/style.js +413 -0
- package/dist/render/surface.js +377 -0
- package/dist/render/text.js +145 -0
- package/dist/screens/LaunchScreen.js +85 -14
- package/dist/screens/PageRender.js +273 -0
- package/dist/screens/Shell.js +24 -5
- package/dist/screens/useTerminalImages.js +129 -0
- package/dist/utils/terminal.js +17 -1
- package/dist/version.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { decodeImage, fetchImage, halfBlockRows, quadrantRows, sextantRows } from "../render/image.js";
|
|
5
|
+
import { blockMode } from "../render/capabilities.js";
|
|
6
|
+
import { fitToBox } from "../render/graphics.js";
|
|
7
|
+
import { useTerminalImages } from "./useTerminalImages.js";
|
|
8
|
+
import { paintFooter, paintPage } from "../render/paint.js";
|
|
9
|
+
import { DEFAULT_VIEWPORT, frameRows } from "../render/layout.js";
|
|
10
|
+
import { Surface } from "../render/surface.js";
|
|
11
|
+
import { palette } from "../theme.js";
|
|
12
|
+
export { DEFAULT_VIEWPORT };
|
|
13
|
+
/**
|
|
14
|
+
* A launched page, drawn as the paywall rather than outlined as a tree.
|
|
15
|
+
*
|
|
16
|
+
* **This screen does not lay anything out.** `src/render/layout.ts` asks Yoga
|
|
17
|
+
* for coordinates, `src/render/paint.ts` paints them into a character grid where
|
|
18
|
+
* every cell owns its own colours, and this file prints that grid one row at a
|
|
19
|
+
* time. Ink is the printer, not the layout engine.
|
|
20
|
+
*
|
|
21
|
+
* That is the whole point of the compositor: a terminal has no z-order and an
|
|
22
|
+
* Ink `Box` carries one background colour, so the earlier flexbox version drew
|
|
23
|
+
* a background layer *above* the page, flattened a gradient to one colour and
|
|
24
|
+
* could never put an image under text. Painting cells fixes all three at once.
|
|
25
|
+
*
|
|
26
|
+
* The frame is drawn in two pieces, because the footer does not scroll. The
|
|
27
|
+
* page surface gives the scrolled window; the footer is painted on its own and
|
|
28
|
+
* its rows replace the window's last rows, so it stays against the bottom of
|
|
29
|
+
* the frame with the content running under it — the terminal's version of
|
|
30
|
+
* `sdk/web`'s `position: absolute; bottom: 0` footer over a scrolling content
|
|
31
|
+
* container. The layout has already inset the content by the footer's height,
|
|
32
|
+
* so nothing is left hiding behind it.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* The widest a portrait device's frame may get, however much room there is.
|
|
36
|
+
*
|
|
37
|
+
* 44 is where a 393×852 phone comes out about 48 rows: a phone-sized inset
|
|
38
|
+
* beside the events, which is what this pane is for. It was briefly 88, to buy
|
|
39
|
+
* an image more cells to be drawn in — at 44 a logo laid out into a box one row
|
|
40
|
+
* tall, and one row cannot show a shape however finely a glyph subdivides it.
|
|
41
|
+
* That is no longer the trade: a terminal that speaks a graphics protocol draws
|
|
42
|
+
* the image at its own resolution, so how many cells the box spans stops
|
|
43
|
+
* deciding how much of the image survives, and the frame can be the size that
|
|
44
|
+
* reads best again.
|
|
45
|
+
*
|
|
46
|
+
* It still decides on a terminal that draws glyphs, where cells *are* the
|
|
47
|
+
* resolution. If images there want more room, this is the number to raise —
|
|
48
|
+
* `frameCols` keeps the device's shape at any value.
|
|
49
|
+
*/
|
|
50
|
+
export const PORTRAIT_FRAME_COLS = 44;
|
|
51
|
+
/**
|
|
52
|
+
* The narrowest the frame will shrink to fit a short terminal. Below this the
|
|
53
|
+
* page's own text stops wrapping into anything readable, so the frame keeps
|
|
54
|
+
* this width and the window scrolls instead.
|
|
55
|
+
*/
|
|
56
|
+
export const MIN_FRAME_COLS = 32;
|
|
57
|
+
/** The one line under the frame saying what the render is not. */
|
|
58
|
+
export const APPROXIMATION_NOTE = "Approximation · no per-character font sizes or custom fonts · grids and carousels render once, not per row or slide";
|
|
59
|
+
/**
|
|
60
|
+
* Why a product card can read `${sku.displayText}` instead of a price.
|
|
61
|
+
*
|
|
62
|
+
* A paywall's own SKUs carry `displayText` as smart text of their own — the real
|
|
63
|
+
* staging value is `**${sku.price} per ${sku.period}**` — so resolving it needs
|
|
64
|
+
* store product data. The CLI registers no store, and a live configure against
|
|
65
|
+
* staging Test Nami reports 0 products, so the price tokens have nothing to
|
|
66
|
+
* resolve from. Leaving the token visible is deliberate; without this line it
|
|
67
|
+
* reads as a rendering bug rather than a missing catalogue.
|
|
68
|
+
*/
|
|
69
|
+
export const NO_PRODUCTS_NOTE = "No store products, so ${sku.price} and the display text built on it stay unresolved — the CLI has no store to price against";
|
|
70
|
+
/** True when the page shows product cards, which is when the note above is worth saying. */
|
|
71
|
+
export function hasProductCards(node) {
|
|
72
|
+
if (node.kind === "product")
|
|
73
|
+
return true;
|
|
74
|
+
return node.kind === "box" ? node.children.some(hasProductCards) : false;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* How many columns the frame takes for a device viewport in a pane this size.
|
|
78
|
+
*
|
|
79
|
+
* Width alone is the wrong question, and getting that wrong is what made an
|
|
80
|
+
* 88-column phone look nothing like a phone. The frame's height comes from its
|
|
81
|
+
* width (`frameRows`), so widening a 393×852 phone to 88 columns makes it 95
|
|
82
|
+
* rows tall — and a terminal showing 50 of them displays the top half of a
|
|
83
|
+
* phone, which reads as a squat, wrong-shaped box no matter how exactly the
|
|
84
|
+
* model preserved the aspect. The aspect is only right where it is *seen*.
|
|
85
|
+
*
|
|
86
|
+
* So when a row budget is given the frame is the largest one that fits it
|
|
87
|
+
* whole: the device's own shape, as large as the window allows, growing by
|
|
88
|
+
* itself when the window grows or the font shrinks. `MIN_FRAME_COLS` is the
|
|
89
|
+
* floor — below it the frame stops shrinking and the window scrolls, which is
|
|
90
|
+
* the old behaviour and better than an unreadable page.
|
|
91
|
+
*
|
|
92
|
+
* Without a row budget it is width alone, which is what a caller measuring
|
|
93
|
+
* columns before it knows its height wants.
|
|
94
|
+
*/
|
|
95
|
+
export function frameCols(viewport, width, rows) {
|
|
96
|
+
const available = Math.max(1, Math.floor(width));
|
|
97
|
+
const widest = viewport.height > viewport.width ? Math.min(available, PORTRAIT_FRAME_COLS) : available;
|
|
98
|
+
if (rows === undefined)
|
|
99
|
+
return widest;
|
|
100
|
+
const budget = Math.max(1, Math.floor(rows));
|
|
101
|
+
// `frameRows` rises with `cols`, so the first width that fits walking down is
|
|
102
|
+
// the widest that fits. It is at most a few dozen steps of arithmetic.
|
|
103
|
+
let cols = widest;
|
|
104
|
+
while (cols > MIN_FRAME_COLS && frameRows(viewport, cols) > budget)
|
|
105
|
+
cols -= 1;
|
|
106
|
+
return Math.min(cols, available);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* What the arrows move between: the nodes the model marked focusable, in source
|
|
110
|
+
* order. A product card is one stop, not one per line, and a container the
|
|
111
|
+
* template made pressable is one stop too.
|
|
112
|
+
*/
|
|
113
|
+
export function focusablePageNodes(page) {
|
|
114
|
+
const out = [];
|
|
115
|
+
walk(page.root, (node) => {
|
|
116
|
+
if ("focusable" in node && node.focusable)
|
|
117
|
+
out.push(node);
|
|
118
|
+
});
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function walk(node, visit) {
|
|
122
|
+
visit(node);
|
|
123
|
+
if (node.kind === "box")
|
|
124
|
+
for (const child of node.children)
|
|
125
|
+
walk(child, visit);
|
|
126
|
+
}
|
|
127
|
+
export function PageRender({ page, caps, width, height, viewport = DEFAULT_VIEWPORT, focused, scrollTop = 0, loadImage, }) {
|
|
128
|
+
// Two columns go to the frame's own border, and four rows to the page name,
|
|
129
|
+
// the border and the scroll hint — the same budget `visible` spends below.
|
|
130
|
+
const cols = frameCols(viewport, Math.max(width - 2, 1), Math.max(height - 4, 1));
|
|
131
|
+
const images = useImages(page, caps, loadImage);
|
|
132
|
+
// Decoding is the expensive half of drawing an image and the page is repainted
|
|
133
|
+
// on every keystroke, so each url is decoded once and kept.
|
|
134
|
+
const decoded = useRef(new Map());
|
|
135
|
+
const decode = (url, bytes) => {
|
|
136
|
+
if (!decoded.current.has(url))
|
|
137
|
+
decoded.current.set(url, decodeImage(bytes));
|
|
138
|
+
return decoded.current.get(url) ?? null;
|
|
139
|
+
};
|
|
140
|
+
// A terminal that draws pixels takes the image boxes away from the painter.
|
|
141
|
+
// The painter reports where they are as it goes, and the hook draws them
|
|
142
|
+
// there; the surface keeps the holes, so nothing shows through a picture.
|
|
143
|
+
const pixels = caps.imageTier === "kitty" && caps.trueColor;
|
|
144
|
+
const reserved = useRef([]);
|
|
145
|
+
// Where the page's reservations stop and the footer's begin. The two are
|
|
146
|
+
// painted into different surfaces and composited at different rows, so their
|
|
147
|
+
// boxes cannot be read in the same coordinates.
|
|
148
|
+
const footerFrom = useRef(0);
|
|
149
|
+
const frameRef = useRef(null);
|
|
150
|
+
const deps = useMemo(() => ({
|
|
151
|
+
focused,
|
|
152
|
+
reserveImage: pixels
|
|
153
|
+
? (area) => {
|
|
154
|
+
// The fit is done here, not in the hook, because the painter needs
|
|
155
|
+
// the answer: it clears exactly what the picture will cover, and an
|
|
156
|
+
// image keeps its own shape rather than filling the box it was
|
|
157
|
+
// given. A url that will not decode is left to the painter.
|
|
158
|
+
const raw = images.get(area.url);
|
|
159
|
+
const source = raw ? decode(area.url, raw) : null;
|
|
160
|
+
if (!source)
|
|
161
|
+
return null;
|
|
162
|
+
const fitted = fitToBox(source, area, area.cropping, area.align);
|
|
163
|
+
// `paintPage` and `paintFooter` both walk with these deps, so the
|
|
164
|
+
// list is reset by the render that is about to use it, not by a
|
|
165
|
+
// caller who would have to remember to.
|
|
166
|
+
reserved.current.push({
|
|
167
|
+
url: area.url,
|
|
168
|
+
x: fitted.x,
|
|
169
|
+
y: fitted.y,
|
|
170
|
+
cols: fitted.cols,
|
|
171
|
+
rows: fitted.rows,
|
|
172
|
+
crop: fitted.crop,
|
|
173
|
+
section: area.section,
|
|
174
|
+
backdrop: area.backdrop,
|
|
175
|
+
});
|
|
176
|
+
return { x: fitted.x, y: fitted.y, w: fitted.cols, h: fitted.rows };
|
|
177
|
+
}
|
|
178
|
+
: undefined,
|
|
179
|
+
image: (url, w, h, backdrop, cropping) => {
|
|
180
|
+
const bytes = images.get(url);
|
|
181
|
+
if (!bytes || !caps.trueColor)
|
|
182
|
+
return null;
|
|
183
|
+
const pixels = decodeImage(bytes);
|
|
184
|
+
if (!pixels)
|
|
185
|
+
return null;
|
|
186
|
+
const draw = { sextant: sextantRows, quadrant: quadrantRows, half: halfBlockRows }[blockMode()];
|
|
187
|
+
return draw(pixels, w, h, backdrop, cropping);
|
|
188
|
+
},
|
|
189
|
+
}), [focused, images, caps.trueColor, pixels]);
|
|
190
|
+
const surface = useMemo(() => {
|
|
191
|
+
reserved.current = [];
|
|
192
|
+
return paintPage(page, cols, frameRows(viewport, cols), viewport, deps);
|
|
193
|
+
}, [page, cols, viewport, deps]);
|
|
194
|
+
// The footer is not in that surface: it does not scroll with the page.
|
|
195
|
+
const footer = useMemo(() => {
|
|
196
|
+
footerFrom.current = reserved.current.length;
|
|
197
|
+
return paintFooter(page, cols, viewport, deps);
|
|
198
|
+
}, [page, cols, viewport, deps]);
|
|
199
|
+
// One row for the page name, two for the border, one for the scroll hint.
|
|
200
|
+
const visible = Math.max(1, Math.min(surface.rows, Math.max(height - 4, 1)));
|
|
201
|
+
const hidden = Math.max(0, surface.rows - visible);
|
|
202
|
+
const offset = Math.max(0, Math.min(Math.floor(scrollTop), hidden));
|
|
203
|
+
// Compose the visible window as cells, not as rows of spans: the footer must
|
|
204
|
+
// cover the page where it has a fill and let the page through where it does
|
|
205
|
+
// not, the way an absolutely positioned footer does on the web. Replacing
|
|
206
|
+
// whole rows punched a hole wherever the footer was transparent.
|
|
207
|
+
const window = useMemo(() => {
|
|
208
|
+
const frame = new Surface(cols, visible);
|
|
209
|
+
frame.blit(surface, 0, -offset);
|
|
210
|
+
if (footer)
|
|
211
|
+
frame.blit(footer, 0, visible - footer.rows);
|
|
212
|
+
return frame.toRows();
|
|
213
|
+
}, [surface, footer, cols, visible, offset]);
|
|
214
|
+
// Both sets of boxes, in the window's own rows: the page's scroll under the
|
|
215
|
+
// window, the footer's are pinned to the bottom of it.
|
|
216
|
+
const imageAreas = useMemo(() => reserved.current.map((area, index) => index < footerFrom.current
|
|
217
|
+
? { ...area, y: area.y - offset }
|
|
218
|
+
: { ...area, y: visible - (footer?.rows ?? 0) + area.y }), [surface, footer, offset, visible]);
|
|
219
|
+
useTerminalImages({ enabled: pixels, frameRef, areas: imageAreas, bytes: images, windowRows: visible });
|
|
220
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Page" }), " ", _jsx(Text, { children: page.pageName || "—" })] }), _jsx(Box, { ref: frameRef, width: cols + 2, borderStyle: "round", borderColor: caps.trueColor ? palette.aqua : undefined, flexDirection: "column", children: window.map((spans, index) => (_jsx(Text, { children: spans.map((span, i) => _jsx(Text, { ...inkProps(span, caps), children: span.text }, i)) }, index))) }), hidden > 0 && (_jsxs(Text, { dimColor: true, children: ["\u25BE ", hidden - offset, " more rows below \u00B7 ", offset, " above \u00B7 PgUp/PgDn"] }))] }));
|
|
221
|
+
}
|
|
222
|
+
/** A painted span's styling, dropped entirely when the terminal has no true colour. */
|
|
223
|
+
function inkProps(span, caps) {
|
|
224
|
+
return {
|
|
225
|
+
color: caps.trueColor ? span.fg : undefined,
|
|
226
|
+
backgroundColor: caps.trueColor ? span.bg : undefined,
|
|
227
|
+
bold: span.bold,
|
|
228
|
+
italic: span.italic,
|
|
229
|
+
underline: span.underline,
|
|
230
|
+
dimColor: span.dim,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Every image on the page, fetched once and kept by url.
|
|
235
|
+
*
|
|
236
|
+
* Nothing is fetched at the `placeholder` tier, which is what a non-TTY (and so
|
|
237
|
+
* every test render) reports — a screen test never touches the network by
|
|
238
|
+
* accident. A url that will not load stays out of the map and draws as its own
|
|
239
|
+
* name, because one unreachable asset must never take the page down.
|
|
240
|
+
*/
|
|
241
|
+
function useImages(page, caps, loadImage) {
|
|
242
|
+
const urls = useMemo(() => imageUrls(page), [page]);
|
|
243
|
+
const [loaded, setLoaded] = useState(() => new Map());
|
|
244
|
+
const key = urls.join("\u0000");
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (caps.imageTier === "placeholder" || urls.length === 0)
|
|
247
|
+
return;
|
|
248
|
+
const load = loadImage ?? ((url) => fetchImage(url));
|
|
249
|
+
let live = true;
|
|
250
|
+
void Promise.all(urls.map(async (url) => [url, await load(url)])).then((pairs) => {
|
|
251
|
+
if (!live)
|
|
252
|
+
return;
|
|
253
|
+
const next = new Map();
|
|
254
|
+
for (const [url, bytes] of pairs)
|
|
255
|
+
if (bytes)
|
|
256
|
+
next.set(url, bytes);
|
|
257
|
+
setLoaded(next);
|
|
258
|
+
});
|
|
259
|
+
return () => {
|
|
260
|
+
live = false;
|
|
261
|
+
};
|
|
262
|
+
// `key` stands in for `urls`, which is rebuilt whenever the page changes.
|
|
263
|
+
}, [key, caps.imageTier]);
|
|
264
|
+
return loaded;
|
|
265
|
+
}
|
|
266
|
+
function imageUrls(page) {
|
|
267
|
+
const urls = [];
|
|
268
|
+
walk(page.root, (node) => {
|
|
269
|
+
if (node.kind === "image" && node.url && !urls.includes(node.url))
|
|
270
|
+
urls.push(node.url);
|
|
271
|
+
});
|
|
272
|
+
return urls;
|
|
273
|
+
}
|
package/dist/screens/Shell.js
CHANGED
|
@@ -12,10 +12,13 @@ import { NewToNami } from "./NewToNami.js";
|
|
|
12
12
|
import { SuccessScreen } from "./SuccessScreen.js";
|
|
13
13
|
import { PickPlacement } from "./PickPlacement.js";
|
|
14
14
|
import { LaunchScreen, focusableNodes } from "./LaunchScreen.js";
|
|
15
|
+
import { focusablePageNodes } from "./PageRender.js";
|
|
15
16
|
import { corePlacements } from "../placements/list.js";
|
|
16
17
|
import { compositeForPlacement, pressNode, startLaunch as startLaunchFn } from "../placements/launch.js";
|
|
18
|
+
import { renderPageForPlacement } from "../placements/page.js";
|
|
19
|
+
import { detectCaps } from "../render/capabilities.js";
|
|
17
20
|
import { EventBus } from "../events/bus.js";
|
|
18
|
-
import { terminalColumns } from "../utils/terminal.js";
|
|
21
|
+
import { terminalColumns, terminalRows } from "../utils/terminal.js";
|
|
19
22
|
import { deviceById, presetsFor, resolveDevice, toSelection } from "../devices/presets.js";
|
|
20
23
|
import { SelectionHeader } from "./SelectionHeader.js";
|
|
21
24
|
import { Thinking } from "../components/Thinking.js";
|
|
@@ -37,7 +40,7 @@ import TextInput from "ink-text-input";
|
|
|
37
40
|
* pasted App Platform ID. Every path funnels back through `configure`, so the
|
|
38
41
|
* SDK is re-configured in place and each platform keeps its own storage file.
|
|
39
42
|
*/
|
|
40
|
-
export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, hostWasExplicit = false, verify, configure, placementsSource, compositeSource, startLaunch }) {
|
|
43
|
+
export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitWhenDone = true, phase, hostWasExplicit = false, verify, configure, placementsSource, compositeSource, pageSource, startLaunch }) {
|
|
41
44
|
const { exit } = useApp();
|
|
42
45
|
const { stdout } = useStdout();
|
|
43
46
|
const [state, setState] = useState({ kind: "boot" });
|
|
@@ -55,6 +58,9 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
55
58
|
const [paused, setPaused] = useState(false);
|
|
56
59
|
const [expanded, setExpanded] = useState(false);
|
|
57
60
|
const [focusIndex, setFocusIndex] = useState(0);
|
|
61
|
+
// The launch pane opens on the render; `t` swaps it for NAM-4542's outline.
|
|
62
|
+
const [view, setView] = useState("render");
|
|
63
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
58
64
|
const [notice, setNotice] = useState(undefined);
|
|
59
65
|
// The bus subscriber runs outside React's render, so pause is read from a ref.
|
|
60
66
|
const pausedRef = useRef(false);
|
|
@@ -179,9 +185,17 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
179
185
|
setFocusIndex(0);
|
|
180
186
|
setExpanded(false);
|
|
181
187
|
setNotice(undefined);
|
|
188
|
+
setView("render");
|
|
189
|
+
setScrollTop(0);
|
|
182
190
|
pausedRef.current = false;
|
|
183
191
|
setPaused(false);
|
|
184
|
-
setState({
|
|
192
|
+
setState({
|
|
193
|
+
kind: "launch",
|
|
194
|
+
home,
|
|
195
|
+
placement,
|
|
196
|
+
composite: (compositeSource ?? compositeForPlacement)(placement.label),
|
|
197
|
+
page: (pageSource ?? renderPageForPlacement)(placement.label),
|
|
198
|
+
});
|
|
185
199
|
};
|
|
186
200
|
// Subscriptions live and die with the launch state, so leaving the screen
|
|
187
201
|
// unregisters every handler it added.
|
|
@@ -350,8 +364,13 @@ export function Shell({ ctx, appQuery, platformQuery, deviceQuery, onExit, exitW
|
|
|
350
364
|
.then(() => ctx.config.markOnboardingDone())
|
|
351
365
|
.catch(() => { });
|
|
352
366
|
setState({ kind: "home", app: state.app, platform: state.platform, device: state.device, result });
|
|
353
|
-
}, onBack: () => chooseDevice(state.app, state.platform, state.device ?? undefined) })), state.kind === "home" && (_jsx(SuccessScreen, { app: state.app, platform: state.platform, device: state.device, result: state.result, host: ctx.host, canSwitchDevice: presetsFor(state.platform.type).length > 1 })), state.kind === "pick-placement" && (_jsx(PickPlacement, { placements: state.placements, onSelect: (placement) => openLaunch(state.home, placement), onCancel: () => setState(state.home) })), state.kind === "launch" && (_jsx(LaunchScreen, { placement: state.placement, composite: state.composite, deviceName: state.home.device?.displayName, events: events, width: Math.max(terminalColumns(stdout) - 2, 40), paused: paused, expanded: expanded, selectedSeq: events.at(-1)?.seq, focusIndex: focusIndex, onPress: press, onMove: (delta) => {
|
|
354
|
-
|
|
367
|
+
}, onBack: () => chooseDevice(state.app, state.platform, state.device ?? undefined) })), state.kind === "home" && (_jsx(SuccessScreen, { app: state.app, platform: state.platform, device: state.device, result: state.result, host: ctx.host, canSwitchDevice: presetsFor(state.platform.type).length > 1 })), state.kind === "pick-placement" && (_jsx(PickPlacement, { placements: state.placements, onSelect: (placement) => openLaunch(state.home, placement), onCancel: () => setState(state.home) })), state.kind === "launch" && (_jsx(LaunchScreen, { placement: state.placement, composite: state.composite, page: state.page, caps: detectCaps(ctx.env, Boolean(stdout?.isTTY)), deviceName: state.home.device?.displayName, viewport: state.home.device ?? undefined, events: events, width: Math.max(terminalColumns(stdout) - 2, 40), height: Math.max(terminalRows(stdout) - 6, 8), view: view, paused: paused, expanded: expanded, selectedSeq: events.at(-1)?.seq, focusIndex: focusIndex, scrollTop: scrollTop, onPress: press, onMove: (delta) => {
|
|
368
|
+
// Each view has its own focusable list, so the count follows the view.
|
|
369
|
+
const count = view === "render" ? focusablePageNodes(state.page).length : focusableNodes(state.composite).length;
|
|
355
370
|
setFocusIndex((index) => (count === 0 ? 0 : (index + delta + count) % count));
|
|
371
|
+
}, onScroll: (delta) => setScrollTop((top) => Math.max(0, top + delta)), onToggleView: () => {
|
|
372
|
+
// The two views focus different node lists, so the index cannot carry over.
|
|
373
|
+
setFocusIndex(0);
|
|
374
|
+
setView((current) => (current === "render" ? "outline" : "render"));
|
|
356
375
|
}, onTogglePause: togglePause, onClear: clearEvents, onToggleExpand: () => setExpanded((on) => !on), onBack: () => setState(state.home), notice: notice })), state.kind === "welcome" && (_jsx(WelcomeScreen, { host: ctx.host, onChoose: onWelcomeChoice, onCancel: () => finish(0) })), state.kind === "new-to-nami" && (_jsx(NewToNami, { host: ctx.host, onContinue: () => { step("configure"); setState({ kind: "paste-id", home: null }); }, onBack: () => setState({ kind: "welcome" }) })), state.kind === "switch" && (_jsx(SwitchScreen, { recent: state.recent, current: state.current, orgName: orgName, canSwitchDevice: presetsFor(state.home.platform.type).length > 1, canSwitchOrg: true, onChoose: (action) => onSwitchChoice(state.home, action), onCancel: () => setState(state.home) })), state.kind === "pick-org" && (_jsx(PickOrg, { orgs: state.orgs, currentOrgId: orgId, onSelect: (org) => void switchOrg(org), onCancel: () => setState(state.home) })), state.kind === "paste-id" && (_jsxs(Box, { flexDirection: "column", children: [state.notice && _jsx(Text, { color: palette.coral, children: state.notice }), _jsxs(Box, { children: [_jsx(Text, { children: "App Platform ID: " }), _jsx(TextInput, { value: pasted, onChange: setPasted, onSubmit: (v) => { setPasted(""); void usePastedId(state.home, v); } })] }), _jsxs(Text, { dimColor: true, children: ["Checking ", pasteHost.name, ". nami platforms --app <app> lists them. Esc to go back."] })] })), state.kind === "fatal" && _jsx(Text, { color: palette.coral, children: state.message })] })] }));
|
|
357
376
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drawing the page's images as pixels, past Ink.
|
|
3
|
+
*
|
|
4
|
+
* `src/render/graphics.ts` builds the escapes; this is the part that has to
|
|
5
|
+
* deal with Ink, and there are three things to know about that.
|
|
6
|
+
*
|
|
7
|
+
* 1. **Ink deletes an image put in its tree.** Its `sanitizeAnsi` strips the
|
|
8
|
+
* protocol's envelope out of any `<Text>`, so the pixels must be written
|
|
9
|
+
* straight to the stream. That is what makes this a hook with a side effect
|
|
10
|
+
* rather than a component.
|
|
11
|
+
* 2. **The position has to come from Ink's own layout**, since the escape is
|
|
12
|
+
* placed by moving the cursor. Every Ink element carries the Yoga node it
|
|
13
|
+
* was laid out with, so walking up from the frame's box and adding the
|
|
14
|
+
* offsets gives its cell on screen — the same route `ink-picture` takes.
|
|
15
|
+
* 3. **Ink writes its frame asynchronously.** An escape emitted from an effect
|
|
16
|
+
* can reach the terminal *before* the frame it was positioned against, and
|
|
17
|
+
* Ink's next redraw erases what is under it. So the placement repeats on a
|
|
18
|
+
* timer: it is a handful of bytes, it always lands after the newest frame,
|
|
19
|
+
* and a redraw that clears an image restores it within one tick. Placement
|
|
20
|
+
* is deliberately separate from transmission for exactly this reason —
|
|
21
|
+
* re-sending the pixels this often would be visible.
|
|
22
|
+
*/
|
|
23
|
+
import { useEffect, useRef } from "react";
|
|
24
|
+
import { useStdout } from "ink";
|
|
25
|
+
import { decodeImage } from "../render/image.js";
|
|
26
|
+
import { BACKGROUND_Z, UNDER_FILLS, at, clipToWindow, forget, place, rgbaFor, transmit } from "../render/graphics.js";
|
|
27
|
+
/** Ids are ours to choose; this keeps them clear of whatever else is on screen. */
|
|
28
|
+
const FIRST_ID = 7100;
|
|
29
|
+
/** How often a placement is re-asserted. Fast enough not to be seen, cheap enough not to matter. */
|
|
30
|
+
const REDRAW_MS = 120;
|
|
31
|
+
/** Everything about a placement that changes the pixels the terminal must hold. */
|
|
32
|
+
const identity = (a) => `${a.url}:${a.cols}x${a.rows}:${a.crop ? `${a.crop.x},${a.crop.y},${a.crop.w},${a.crop.h}` : ""}`;
|
|
33
|
+
export function useTerminalImages({ enabled, frameRef, areas, bytes, windowRows, redrawMs = REDRAW_MS, }) {
|
|
34
|
+
const { stdout } = useStdout();
|
|
35
|
+
const sent = useRef(new Map());
|
|
36
|
+
const nextId = useRef(FIRST_ID);
|
|
37
|
+
// What to draw, rebuilt whenever the page, the window or the scroll changes.
|
|
38
|
+
const key = `${areas.map(identity).join("|")}#${windowRows}#${bytes.size}`;
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!enabled || areas.length === 0)
|
|
41
|
+
return;
|
|
42
|
+
const drawing = areas.flatMap((area) => {
|
|
43
|
+
const raw = bytes.get(area.url);
|
|
44
|
+
if (!raw)
|
|
45
|
+
return [];
|
|
46
|
+
// Keyed by everything that changes the pixels, not by url: the same asset
|
|
47
|
+
// can appear twice on a page at different sizes, and one entry keyed by
|
|
48
|
+
// url alone would hand the second box the first one's image.
|
|
49
|
+
const id = identity(area);
|
|
50
|
+
let image = sent.current.get(id);
|
|
51
|
+
if (!image) {
|
|
52
|
+
const pixels = decodeImage(raw);
|
|
53
|
+
if (!pixels)
|
|
54
|
+
return [];
|
|
55
|
+
// The crop happens here rather than in the placement, so the pixels the
|
|
56
|
+
// terminal holds are exactly what should appear in those cells.
|
|
57
|
+
const scaled = rgbaFor(pixels, area.cols, area.rows, area.backdrop, area.crop);
|
|
58
|
+
image = { id: nextId.current++, width: scaled.width, height: scaled.height };
|
|
59
|
+
sent.current.set(id, image);
|
|
60
|
+
for (const chunk of transmit(image.id, scaled.data, scaled.width, scaled.height))
|
|
61
|
+
stdout.write(chunk);
|
|
62
|
+
}
|
|
63
|
+
const clip = clipToWindow({ y: area.y, rows: area.rows }, 0, windowRows, image.height, image.width);
|
|
64
|
+
return clip ? [{ area, image, clip }] : [];
|
|
65
|
+
});
|
|
66
|
+
if (drawing.length === 0)
|
|
67
|
+
return;
|
|
68
|
+
const paint = () => {
|
|
69
|
+
const frame = absolutePosition(frameRef.current);
|
|
70
|
+
if (!frame)
|
|
71
|
+
return;
|
|
72
|
+
const terminalHeight = stdout.rows ?? frame.appHeight;
|
|
73
|
+
for (const { area, image, clip } of drawing) {
|
|
74
|
+
// One row and one column in from the frame's border.
|
|
75
|
+
const row = frame.row + 1 + clip.row;
|
|
76
|
+
const col = frame.col + 1 + area.x;
|
|
77
|
+
// Ink prints a newline after its output unless the app fills the screen,
|
|
78
|
+
// so the cursor is one row below the app in the usual case.
|
|
79
|
+
const up = frame.appHeight - row - (frame.appHeight >= terminalHeight ? 1 : 0);
|
|
80
|
+
stdout.write(at(col, up, place(image.id, {
|
|
81
|
+
cols: area.cols,
|
|
82
|
+
rows: clip.rows,
|
|
83
|
+
source: clip.source,
|
|
84
|
+
z: area.section === "background" ? BACKGROUND_Z : UNDER_FILLS,
|
|
85
|
+
})));
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
paint();
|
|
89
|
+
const timer = setInterval(paint, redrawMs);
|
|
90
|
+
// `unref` so a placement timer can never be the reason the process stays up.
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
return () => clearInterval(timer);
|
|
93
|
+
// `key` stands for `areas`, which is rebuilt on every render.
|
|
94
|
+
}, [enabled, key, stdout, frameRef, redrawMs]);
|
|
95
|
+
// Everything this page put on screen goes when the screen does, or the
|
|
96
|
+
// pictures outlive the page that asked for them.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const live = sent.current;
|
|
99
|
+
return () => {
|
|
100
|
+
for (const { id } of live.values())
|
|
101
|
+
stdout.write(forget(id));
|
|
102
|
+
live.clear();
|
|
103
|
+
};
|
|
104
|
+
}, [stdout]);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* An element's cell position in the app's output, and the app's own height.
|
|
108
|
+
*
|
|
109
|
+
* Yoga reports each node relative to its parent, so the absolute position is
|
|
110
|
+
* the sum up the tree — the same accumulation `src/render/layout.ts` does for
|
|
111
|
+
* the page's own boxes, on Ink's tree instead of ours.
|
|
112
|
+
*/
|
|
113
|
+
function absolutePosition(node) {
|
|
114
|
+
if (!node?.yogaNode)
|
|
115
|
+
return null;
|
|
116
|
+
let col = 0;
|
|
117
|
+
let row = 0;
|
|
118
|
+
let appHeight = 0;
|
|
119
|
+
let at = node;
|
|
120
|
+
while (at) {
|
|
121
|
+
if (at.yogaNode) {
|
|
122
|
+
col += at.yogaNode.getComputedLeft();
|
|
123
|
+
row += at.yogaNode.getComputedTop();
|
|
124
|
+
appHeight = at.yogaNode.getComputedHeight();
|
|
125
|
+
}
|
|
126
|
+
at = at.parentNode ?? undefined;
|
|
127
|
+
}
|
|
128
|
+
return { col, row, appHeight };
|
|
129
|
+
}
|
package/dist/utils/terminal.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one NO_COLOR rule for the whole CLI: any non-empty value means the user
|
|
3
|
+
* asked for a quiet terminal. `shouldAnimate` and `detectCaps`
|
|
4
|
+
* (`src/render/capabilities.ts`) both defer to it so the two cannot drift.
|
|
5
|
+
*/
|
|
6
|
+
export function colorDisabled(env = process.env) {
|
|
7
|
+
return Boolean(env.NO_COLOR);
|
|
8
|
+
}
|
|
1
9
|
/**
|
|
2
10
|
* Whether it is appropriate to animate: an interactive TTY, not CI, and the
|
|
3
11
|
* user has not asked for a quiet terminal via NO_COLOR (any non-empty value).
|
|
@@ -8,10 +16,18 @@ export function shouldAnimate(env = process.env, stdout = process.stdout) {
|
|
|
8
16
|
return false;
|
|
9
17
|
if (env.CI)
|
|
10
18
|
return false;
|
|
11
|
-
if (env
|
|
19
|
+
if (colorDisabled(env))
|
|
12
20
|
return false;
|
|
13
21
|
return true;
|
|
14
22
|
}
|
|
15
23
|
export function terminalColumns(stdout = process.stdout, fallback = 80) {
|
|
16
24
|
return stdout.columns && stdout.columns > 0 ? stdout.columns : fallback;
|
|
17
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Rows the terminal has. The page render caps its frame to this and scrolls
|
|
28
|
+
* rather than squashing the page, so a terminal that never reports a height
|
|
29
|
+
* gets the conventional 24 rather than an unbounded frame.
|
|
30
|
+
*/
|
|
31
|
+
export function terminalRows(stdout = process.stdout, fallback = 24) {
|
|
32
|
+
return stdout.rows && stdout.rows > 0 ? stdout.rows : fallback;
|
|
33
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** CLI package version. Stamped by scripts/version.sh from the repo VERSION file — do not hand-edit. */
|
|
2
|
-
export const NAMI_CLI_VERSION = "3.4.12-dev.
|
|
2
|
+
export const NAMI_CLI_VERSION = "3.4.12-dev.202610051802";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@namiml/cli",
|
|
3
|
-
"version": "3.4.12-dev.
|
|
3
|
+
"version": "3.4.12-dev.202610051802",
|
|
4
4
|
"description": "Nami SDK developer CLI — terminal test app, implementation and flow checks",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nami",
|
|
@@ -35,12 +35,14 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@namiml/sdk-core": "3.4.12-dev.
|
|
38
|
+
"@namiml/sdk-core": "3.4.12-dev.202610051802",
|
|
39
39
|
"commander": "^15",
|
|
40
40
|
"env-paths": "^4",
|
|
41
41
|
"ink": "^6.0.0",
|
|
42
42
|
"ink-select-input": "^6",
|
|
43
43
|
"ink-text-input": "^6",
|
|
44
|
+
"jpeg-js": "^0.4.4",
|
|
45
|
+
"pngjs": "^7.0.0",
|
|
44
46
|
"react": "^19.0.0"
|
|
45
47
|
}
|
|
46
48
|
}
|