@devmarketplacenpm/devmp 0.1.1-beta.5

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,167 @@
1
+ "use strict";
2
+
3
+ const { cursor, rowsFor, totalRows, displayWidth } = require("./ansi");
4
+
5
+ // The screen owns every byte written to the terminal during an interactive
6
+ // session, and splits it into two regions:
7
+ //
8
+ // scrollback — finished output. Painted exactly once, never redrawn, so the
9
+ // user keeps native terminal scroll, copy and search over it.
10
+ // live — the bottom few rows (status line, composer, queued messages).
11
+ // Erased and repainted on every frame.
12
+ //
13
+ // Anything that writes to stdout behind the screen's back will corrupt the
14
+ // row accounting, which is why the shell routes all output through `write`.
15
+
16
+ function createScreen({ stream = process.stdout } = {}) {
17
+ const isTty = Boolean(stream.isTTY);
18
+
19
+ let liveLines = [];
20
+ let liveCaret = null;
21
+ // Rows the live region currently occupies, and where the cursor sits within
22
+ // it (0 = first row of the live region).
23
+ let paintedRows = 0;
24
+ let cursorRow = 0;
25
+ let closed = false;
26
+
27
+ // Row accounting must use the terminal's real width. Clamping it to some
28
+ // comfortable minimum would make us predict a different wrap point than the
29
+ // terminal actually uses, and every erase after that would be off by rows.
30
+ const columns = () => Math.max(1, stream.columns || 80);
31
+ const rows = () => Math.max(2, stream.rows || 24);
32
+
33
+ /** Keep the live region strictly shorter than the viewport so the cursor-up
34
+ * arithmetic can never address a row that has already scrolled away. */
35
+ function clamp(lines) {
36
+ const limit = rows() - 1;
37
+ const width = columns();
38
+ let visible = lines;
39
+ while (visible.length > 1 && totalRows(visible, width) > limit) {
40
+ visible = visible.slice(1);
41
+ }
42
+ return visible;
43
+ }
44
+
45
+ /** Wrapped-row offset and column of the caret inside the live region. */
46
+ function caretPosition(lines, caret) {
47
+ if (!caret) return null;
48
+ const width = columns();
49
+ const lineIndex = Math.min(Math.max(caret.line | 0, 0), lines.length - 1);
50
+ let row = 0;
51
+ for (let i = 0; i < lineIndex; i += 1) row += rowsFor(lines[i], width);
52
+ const col = Math.max(0, caret.column | 0);
53
+ return { row: row + Math.floor(col / width), column: col % width };
54
+ }
55
+
56
+ function erase() {
57
+ if (paintedRows === 0) return;
58
+ stream.write(cursor.toColumn0 + cursor.up(cursorRow) + cursor.eraseDown);
59
+ paintedRows = 0;
60
+ cursorRow = 0;
61
+ }
62
+
63
+ function paint() {
64
+ if (liveLines.length === 0) {
65
+ paintedRows = 0;
66
+ cursorRow = 0;
67
+ return;
68
+ }
69
+ const visible = clamp(liveLines);
70
+ const width = columns();
71
+
72
+ // No trailing newline: the last row must not reserve an extra blank line
73
+ // at the bottom of the viewport.
74
+ stream.write(cursor.hide + visible.join("\n"));
75
+ paintedRows = totalRows(visible, width);
76
+ cursorRow = paintedRows - 1;
77
+
78
+ const caret = caretPosition(visible, liveCaret);
79
+ if (caret) {
80
+ const up = Math.max(0, paintedRows - 1 - caret.row);
81
+ stream.write(cursor.up(up) + cursor.toColumn0 + cursor.right(caret.column));
82
+ cursorRow = caret.row;
83
+ stream.write(cursor.show);
84
+ }
85
+ }
86
+
87
+ const api = {
88
+ get isTty() {
89
+ return isTty;
90
+ },
91
+ get columns() {
92
+ return columns();
93
+ },
94
+ get rows() {
95
+ return rows();
96
+ },
97
+
98
+ /** Commit finished output to scrollback, above the live region. */
99
+ write(text) {
100
+ if (closed) return;
101
+ const body = String(text);
102
+ if (!body) return;
103
+ if (!isTty) {
104
+ stream.write(body.endsWith("\n") ? body : `${body}\n`);
105
+ return;
106
+ }
107
+ erase();
108
+ stream.write(body.endsWith("\n") ? body : `${body}\n`);
109
+ paint();
110
+ },
111
+
112
+ /** Commit a list of already-rendered logical lines to scrollback. */
113
+ writeLines(lines) {
114
+ if (!lines || lines.length === 0) return;
115
+ api.write(lines.join("\n"));
116
+ },
117
+
118
+ /**
119
+ * Replace the live region. `caret` is `{ line, column }` in logical-line
120
+ * coordinates with `column` measured in display cells.
121
+ */
122
+ setLive(lines, caret = null) {
123
+ if (closed || !isTty) return;
124
+ liveLines = Array.isArray(lines) ? lines : [lines];
125
+ liveCaret = caret;
126
+ erase();
127
+ paint();
128
+ },
129
+
130
+ clearLive() {
131
+ if (closed || !isTty) return;
132
+ liveLines = [];
133
+ liveCaret = null;
134
+ erase();
135
+ },
136
+
137
+ /** Repaint after a terminal resize; the old geometry is no longer valid. */
138
+ resize() {
139
+ if (closed || !isTty || liveLines.length === 0) return;
140
+ // `paintedRows` was measured at the old width. The terminal has since
141
+ // reflowed the same content into a different number of rows, so erasing
142
+ // with the stale count clears too few of them and leaves a torn copy of
143
+ // the live region above the new one. Re-measure against the new width
144
+ // first, and assume the cursor rides at the bottom of the reflowed
145
+ // block — which is where it sits in every terminal that reflows at all.
146
+ const visible = clamp(liveLines);
147
+ paintedRows = totalRows(visible, columns());
148
+ cursorRow = Math.max(0, paintedRows - 1);
149
+ erase();
150
+ paint();
151
+ },
152
+
153
+ close() {
154
+ if (closed) return;
155
+ closed = true;
156
+ if (!isTty) return;
157
+ liveLines = [];
158
+ liveCaret = null;
159
+ erase();
160
+ stream.write(cursor.show);
161
+ },
162
+ };
163
+
164
+ return api;
165
+ }
166
+
167
+ module.exports = { createScreen, displayWidth };
package/lib/ui.js ADDED
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+
3
+ // Minimal ANSI styling. Disabled when NO_COLOR is set or output is not a TTY,
4
+ // so piped/CI output stays clean.
5
+ //
6
+ // Two families live here on purpose:
7
+ // `fmt.*` build strings, for anything routed through the interactive
8
+ // screen (which must own every write to keep its row accounting).
9
+ // bare fns print directly, for the one-shot commands that have no screen.
10
+
11
+ const { displayWidth, stripAnsi } = require("./tty/ansi");
12
+
13
+ const enabled = !process.env.NO_COLOR && process.stdout.isTTY;
14
+
15
+ function style(open, close) {
16
+ return (text) =>
17
+ enabled ? `\x1b[${open}m${text}\x1b[${close}m` : String(text);
18
+ }
19
+
20
+ const color = {
21
+ bold: style(1, 22),
22
+ dim: style(2, 22),
23
+ red: style(31, 39),
24
+ green: style(32, 39),
25
+ yellow: style(33, 39),
26
+ blue: style(34, 39),
27
+ cyan: style(36, 39),
28
+ gray: style(90, 39),
29
+ };
30
+
31
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
32
+
33
+ const fmt = {
34
+ banner: () =>
35
+ color.bold(color.cyan("devmp")) + color.dim(" — server-side coding agent"),
36
+ success: (msg) => `${color.green("✓")} ${msg}`,
37
+ warn: (msg) => `${color.yellow("!")} ${msg}`,
38
+ fail: (msg) => `${color.red("✗")} ${msg}`,
39
+ line: (label, value) => ` ${color.dim(String(label).padEnd(11))} ${value}`,
40
+ // Same pair, without the leading indent — for rows inside a box, which
41
+ // supplies its own padding.
42
+ pair: (label, value) => `${color.dim(String(label).padEnd(10))} ${value}`,
43
+ section: (title) => ["", color.bold(title)],
44
+ };
45
+
46
+ /**
47
+ * Render a bordered card. Returns lines rather than printing so the caller can
48
+ * route them through the screen. `width` is measured in display columns, so
49
+ * styled labels inside the card still line up.
50
+ */
51
+ /** Cut a row down to `width` columns. */
52
+ function fitToWidth(value, width) {
53
+ const text = String(value);
54
+ if (displayWidth(text) <= width) return text;
55
+ // A hard cut, not a word wrap: these rows are label/value pairs, and wrapping
56
+ // drops the value entirely when it is a single long token such as a path.
57
+ // Styling goes with the cut, because slicing through an escape sequence would
58
+ // spill raw codes onto the screen — and a trimmed row is only ever the
59
+ // fallback for a terminal too narrow to show it in full.
60
+ const plain = stripAnsi(text);
61
+ const limit = Math.max(1, width - 1); // leave a column for the ellipsis
62
+ let out = "";
63
+ let used = 0;
64
+ for (const char of plain) {
65
+ const charWidth = displayWidth(char);
66
+ if (used + charWidth > limit) break;
67
+ out += char;
68
+ used += charWidth;
69
+ }
70
+ return `${out}…`;
71
+ }
72
+
73
+ function box(lines, { title, minWidth = 24, maxWidth = 76 } = {}) {
74
+ // Follow the terminal when it is narrower than the default. Rows were never
75
+ // trimmed, so a long workspace path pushed its own border off the right edge
76
+ // and the card read as broken — which is exactly where users look when
77
+ // something has already gone wrong.
78
+ const available = process.stdout.columns
79
+ ? Math.max(minWidth, process.stdout.columns - 4)
80
+ : maxWidth;
81
+ const cap = Math.min(maxWidth, available);
82
+
83
+ const fitted = lines.map((item) => fitToWidth(item, cap));
84
+ const widest = Math.max(
85
+ minWidth,
86
+ title ? displayWidth(title) + 4 : 0,
87
+ ...fitted.map((item) => displayWidth(item))
88
+ );
89
+ const width = Math.min(cap, widest);
90
+ lines = fitted;
91
+ // Body rows are `│ ` + content + padding + ` │`, i.e. width + 4 columns. The
92
+ // titled top edge is `╭─ ` + title + ` ` + dashes + `╮`, so it needs
93
+ // width - title - 1 dashes to land on the same column as everything else.
94
+ const top = title
95
+ ? `╭─ ${title} ${"─".repeat(Math.max(1, width - displayWidth(title) - 1))}╮`
96
+ : `╭${"─".repeat(width + 2)}╮`;
97
+ const out = [color.dim(top)];
98
+ for (const item of lines) {
99
+ const pad = Math.max(0, width - displayWidth(item));
100
+ out.push(`${color.dim("│")} ${item}${" ".repeat(pad)} ${color.dim("│")}`);
101
+ }
102
+ out.push(color.dim(`╰${"─".repeat(width + 2)}╯`));
103
+ return out;
104
+ }
105
+
106
+ function formatNumber(value) {
107
+ const n = Number(value);
108
+ if (!Number.isFinite(n)) return "—";
109
+ return Math.max(0, Math.round(n)).toLocaleString("en-US");
110
+ }
111
+
112
+ function formatDate(value) {
113
+ if (!value) return "unknown";
114
+ const date = new Date(value);
115
+ if (Number.isNaN(date.getTime())) return "unknown";
116
+ return date.toLocaleDateString("en-US", {
117
+ month: "long",
118
+ day: "numeric",
119
+ year: "numeric",
120
+ });
121
+ }
122
+
123
+ function formatDuration(ms) {
124
+ const seconds = Math.max(0, Math.round(ms / 1000));
125
+ if (seconds < 60) return `${seconds}s`;
126
+ const minutes = Math.floor(seconds / 60);
127
+ return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
128
+ }
129
+
130
+ function banner() {
131
+ console.log(fmt.banner());
132
+ }
133
+
134
+ function section(title) {
135
+ console.log("");
136
+ console.log(color.bold(title));
137
+ }
138
+
139
+ function info(msg) {
140
+ console.log(msg);
141
+ }
142
+
143
+ function success(msg) {
144
+ console.log(fmt.success(msg));
145
+ }
146
+
147
+ function warn(msg) {
148
+ console.log(fmt.warn(msg));
149
+ }
150
+
151
+ function fail(msg) {
152
+ console.error(fmt.fail(msg));
153
+ }
154
+
155
+ function line(label, value) {
156
+ console.log(fmt.line(label, value));
157
+ }
158
+
159
+ module.exports = {
160
+ color,
161
+ fmt,
162
+ box,
163
+ SPINNER_FRAMES,
164
+ formatNumber,
165
+ formatDate,
166
+ formatDuration,
167
+ banner,
168
+ section,
169
+ info,
170
+ success,
171
+ warn,
172
+ fail,
173
+ line,
174
+ };
package/lib/version.js ADDED
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+
3
+ // Client version negotiation.
4
+ //
5
+ // The agent lives on the server, so most releases reach users the moment the
6
+ // server deploys — nothing to install. Only this client (terminal UI, file
7
+ // writing, tunnel protocol) ships through npm, and CLI users famously never
8
+ // run `npm update -g`. So the server tells us, on every authenticated call and
9
+ // on every session handshake, which client it expects:
10
+ //
11
+ // latest — newest published client; we nudge, nothing more
12
+ // min — oldest client this server still speaks to; below it we refuse
13
+ //
14
+ // The refusal is the important half: when the tunnel protocol changes, an old
15
+ // client would otherwise fail in some unreadable way mid-turn. Better to say
16
+ // so up front, in one line the user can act on.
17
+ //
18
+ // Both fields are optional. A server that sends neither (an older deploy) is
19
+ // handled exactly as before — no notice, no gate.
20
+
21
+ const { version: CURRENT, name: PACKAGE } = require("../package.json");
22
+
23
+ /** Split "1.2.3-beta.4" into comparable parts. Returns null if unparseable. */
24
+ function parse(value) {
25
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(
26
+ String(value || "").trim(),
27
+ );
28
+ if (!match) return null;
29
+ return {
30
+ main: [Number(match[1]), Number(match[2]), Number(match[3])],
31
+ pre: match[4] ? match[4].split(".") : [],
32
+ };
33
+ }
34
+
35
+ /** Compare two prerelease identifiers the way semver precedence requires. */
36
+ function comparePreParts(a, b) {
37
+ const aNum = /^\d+$/.test(a);
38
+ const bNum = /^\d+$/.test(b);
39
+ if (aNum && bNum) return Math.sign(Number(a) - Number(b));
40
+ if (aNum) return -1; // numeric identifiers rank below alphanumeric
41
+ if (bNum) return 1;
42
+ return a < b ? -1 : a > b ? 1 : 0;
43
+ }
44
+
45
+ /**
46
+ * Semver precedence: -1 if a < b, 1 if a > b, 0 if equal.
47
+ * Returns 0 for anything unparseable so a malformed server value can never
48
+ * lock a working client out.
49
+ */
50
+ function compare(a, b) {
51
+ const left = parse(a);
52
+ const right = parse(b);
53
+ if (!left || !right) return 0;
54
+
55
+ for (let i = 0; i < 3; i += 1) {
56
+ if (left.main[i] !== right.main[i]) {
57
+ return left.main[i] < right.main[i] ? -1 : 1;
58
+ }
59
+ }
60
+
61
+ // 1.0.0-beta precedes 1.0.0; a release outranks any prerelease of itself.
62
+ if (left.pre.length && !right.pre.length) return -1;
63
+ if (!left.pre.length && right.pre.length) return 1;
64
+
65
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
66
+ const l = left.pre[i];
67
+ const r = right.pre[i];
68
+ if (l === undefined) return -1; // fewer identifiers = lower precedence
69
+ if (r === undefined) return 1;
70
+ const cmp = comparePreParts(l, r);
71
+ if (cmp !== 0) return cmp;
72
+ }
73
+ return 0;
74
+ }
75
+
76
+ // What the server last told us. Any transport may fill this in — the HTTP
77
+ // usage call, the WebSocket handshake — and every surface reads the same copy.
78
+ let reported = null;
79
+
80
+ /** Record a `{ latest, min }` block from a server payload. Ignores junk. */
81
+ function record(info) {
82
+ if (!info || typeof info !== "object") return;
83
+ const latest = typeof info.latest === "string" ? info.latest : null;
84
+ const min = typeof info.min === "string" ? info.min : null;
85
+ if (!latest && !min) return;
86
+ reported = { latest, min };
87
+ }
88
+
89
+ /** Test seam: drop anything a previous call recorded. */
90
+ function reset() {
91
+ reported = null;
92
+ }
93
+
94
+ function state() {
95
+ const latest = reported?.latest ?? null;
96
+ const min = reported?.min ?? null;
97
+ return {
98
+ current: CURRENT,
99
+ latest,
100
+ min,
101
+ updateAvailable: Boolean(latest) && compare(CURRENT, latest) < 0,
102
+ blocked: Boolean(min) && compare(CURRENT, min) < 0,
103
+ };
104
+ }
105
+
106
+ /** One line for the end of a session. Null when there is nothing to say. */
107
+ function updateNotice() {
108
+ const { current, latest, updateAvailable, blocked } = state();
109
+ if (blocked || !updateAvailable) return null;
110
+ return `Update available: ${current} → ${latest} npm i -g ${PACKAGE}`;
111
+ }
112
+
113
+ /**
114
+ * The hard gate. Returns a ready-to-print message when this client is too old
115
+ * for the server, otherwise null.
116
+ */
117
+ function blockedMessage() {
118
+ const { current, min, blocked } = state();
119
+ if (!blocked) return null;
120
+ return (
121
+ `This devmp is too old for the server (you have ${current}, ` +
122
+ `it needs ${min} or newer).\n Update with: npm i -g ${PACKAGE}`
123
+ );
124
+ }
125
+
126
+ module.exports = {
127
+ CURRENT,
128
+ compare,
129
+ record,
130
+ reset,
131
+ state,
132
+ updateNotice,
133
+ blockedMessage,
134
+ };