@namiml/cli 3.4.12-dev.202610051759 → 3.4.12-dev.202610060208

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.
@@ -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
+ }
@@ -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({ kind: "launch", home, placement, composite: (compositeSource ?? compositeForPlacement)(placement.label) });
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
- const count = focusableNodes(state.composite).length;
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
+ }
@@ -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.NO_COLOR)
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.202610051759";
2
+ export const NAMI_CLI_VERSION = "3.4.12-dev.202610060208";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namiml/cli",
3
- "version": "3.4.12-dev.202610051759",
3
+ "version": "3.4.12-dev.202610060208",
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.202610051759",
38
+ "@namiml/sdk-core": "3.4.12-dev.202610060208",
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
  }