limbo-ai 1.9.4 → 1.9.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.
package/cli.js CHANGED
@@ -820,11 +820,33 @@ function applyOpenClawConfig(cfg) {
820
820
  // OSC: ESC ] <any> BEL|ST
821
821
  // Covers private-mode sequences like \x1b[?25l (hide cursor) that the old [0-9;]* missed.
822
822
  const stripAnsi = (str) => str
823
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences (all parameter byte combos)
824
- .replace(/\x1b[@-Z\\-_]/g, '') // two-char ESC sequences
825
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences
823
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences (all parameter byte combos)
824
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (before two-char shares \x1b] prefix)
825
+ .replace(/\x1b[^[\]]/g, '') // two-char ESC sequences (e.g. ESC 7/8 save/restore)
826
826
  .replace(/\r/g, '');
827
827
 
828
+ // Matches OAuth/browser URLs emitted by OpenClaw during auth flows.
829
+ const AUTH_URL_RE = /https?:\/\/[^\s"'<>\]]+/g;
830
+
831
+ // Matches lines consisting entirely of TUI chrome: spinner glyphs, box-drawing chars, and
832
+ // clack/prompt decorations. Used to suppress animation frame scatter from OpenClaw TUI output.
833
+ const TUI_CHROME_RE = /^[\s\u2500-\u257f\u2580-\u259f\u25a0-\u25ff\u2600-\u26ff\u2190-\u21ff\u2700-\u27bf\u2800-\u28ff]*$/u;
834
+
835
+ // Pure helper: flush complete lines from a raw buffer.
836
+ // Normalises \r\n → \n, splits on \n, and within each segment keeps only the LAST
837
+ // \r-separated piece — that's the final rendered state after TUI in-place overwrites.
838
+ // Returns the lines to emit and the leftover incomplete segment.
839
+ function flushStreamLines(buf) {
840
+ const normalized = buf.replace(/\r\n/g, '\n');
841
+ const segments = normalized.split('\n');
842
+ const remaining = segments.pop(); // no trailing \n yet
843
+ const lines = segments.map((seg) => {
844
+ const frames = seg.split('\r');
845
+ return frames[frames.length - 1]; // last frame = final rendered content
846
+ });
847
+ return { lines, remaining };
848
+ }
849
+
828
850
  // Spawn OpenClaw auth with filtered output: extract OAuth URLs, suppress branding.
829
851
  // --tty is required so openclaw sees a TTY inside the container and runs the auth wizard.
830
852
  // We pipe stdout/stderr to filter content while the container gets a proper PTY allocation.
@@ -836,21 +858,22 @@ function streamFilteredAuth(dockerArgs, onUrl = null) {
836
858
  stdio: ['inherit', 'pipe', 'pipe'],
837
859
  });
838
860
 
839
- const urlRe = /https?:\/\/[^\s"'<>\]]+/g;
840
861
  const seenUrls = new Set();
841
862
  let buf = '';
842
863
 
843
864
  const handleData = (data) => {
844
865
  buf += data.toString();
845
- // Split on \r\n, \n, or bare \r TUIs use carriage returns for in-place redraws
846
- const lines = buf.split(/\r?\n|\r/);
847
- buf = lines.pop(); // hold incomplete last line
866
+ // flushStreamLines keeps only the final \r frame per \n-terminated line,
867
+ // discarding all intermediate TUI animation states (character-by-character
868
+ // reveals, spinner frames) that would otherwise scatter across the terminal.
869
+ const { lines, remaining } = flushStreamLines(buf);
870
+ buf = remaining;
848
871
  for (const line of lines) emitLine(line);
849
872
  };
850
873
 
851
874
  const emitLine = (rawLine) => {
852
875
  const line = stripAnsi(rawLine);
853
- const urls = line.match(urlRe) || [];
876
+ const urls = line.match(AUTH_URL_RE) || [];
854
877
  if (urls.length > 0) {
855
878
  for (const url of urls) {
856
879
  if (!seenUrls.has(url)) {
@@ -863,19 +886,19 @@ function streamFilteredAuth(dockerArgs, onUrl = null) {
863
886
  }
864
887
  // Suppress OpenClaw branding
865
888
  if (/openclaw/i.test(line)) return;
866
- // Suppress TUI chrome: lines that are only spinner/decoration/box-drawing chars.
867
- // OpenClaw's TUI writes animation frames separated by \r — after our \r-split, each
868
- // frame becomes a short line (often a single char). We filter these out so they don't
869
- // scatter across the terminal as individual console.log lines.
870
- const tuiChrome = /^[\s\u2500-\u257f\u2580-\u259f\u25a0-\u25ff\u2600-\u26ff◇◆●○◈→←↑↓⠀-\u28ff]*$/u;
871
- if (tuiChrome.test(line)) return;
889
+ // Suppress TUI chrome: lines consisting only of spinner/decoration/box-drawing chars.
890
+ if (TUI_CHROME_RE.test(line)) return;
872
891
  if (line.trim()) console.log(` ${line}`);
873
892
  };
874
893
 
875
894
  proc.stdout.on('data', handleData);
876
895
  proc.stderr.on('data', handleData);
877
896
  proc.on('close', (code) => {
878
- if (buf.trim()) emitLine(buf);
897
+ if (buf.trim()) {
898
+ // Append a synthetic \n to flush the remaining buffer through flushStreamLines.
899
+ const { lines } = flushStreamLines(buf + '\n');
900
+ for (const line of lines) emitLine(line);
901
+ }
879
902
  resolve(code ?? 1);
880
903
  });
881
904
  proc.on('error', () => resolve(1));
@@ -1093,24 +1116,29 @@ ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
1093
1116
 
1094
1117
  // ─── Main ────────────────────────────────────────────────────────────────────
1095
1118
 
1096
- const [,, cmd = 'start'] = process.argv;
1097
-
1098
- (async () => {
1099
- switch (cmd) {
1100
- case 'start':
1101
- case 'install': await cmdStart(); break;
1102
- case 'stop': cmdStop(); break;
1103
- case 'logs': cmdLogs(); break;
1104
- case 'update': cmdUpdate(); break;
1105
- case 'status': cmdStatus(); break;
1106
- case 'help':
1107
- case '--help':
1108
- case '-h': cmdHelp(); break;
1109
- default:
1110
- warn(t('en', 'unknownCommand', cmd));
1111
- cmdHelp();
1112
- process.exit(1);
1113
- }
1114
- })().catch((err) => {
1115
- die(err.message || String(err));
1116
- });
1119
+ if (require.main === module) {
1120
+ const [,, cmd = 'start'] = process.argv;
1121
+
1122
+ (async () => {
1123
+ switch (cmd) {
1124
+ case 'start':
1125
+ case 'install': await cmdStart(); break;
1126
+ case 'stop': cmdStop(); break;
1127
+ case 'logs': cmdLogs(); break;
1128
+ case 'update': cmdUpdate(); break;
1129
+ case 'status': cmdStatus(); break;
1130
+ case 'help':
1131
+ case '--help':
1132
+ case '-h': cmdHelp(); break;
1133
+ default:
1134
+ warn(t('en', 'unknownCommand', cmd));
1135
+ cmdHelp();
1136
+ process.exit(1);
1137
+ }
1138
+ })().catch((err) => {
1139
+ die(err.message || String(err));
1140
+ });
1141
+ } else {
1142
+ // Exported for unit testing — not part of the public CLI API.
1143
+ module.exports = { stripAnsi, AUTH_URL_RE, TUI_CHROME_RE, flushStreamLines };
1144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.9.4",
3
+ "version": "1.9.5",
4
4
  "description": "Your personal AI memory agent — install and manage Limbo via npx",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -13,7 +13,8 @@
13
13
  "@clack/prompts": "^1.1.0"
14
14
  },
15
15
  "scripts": {
16
- "start": "node cli.js start"
16
+ "start": "node cli.js start",
17
+ "test": "node --test test/**/*.test.js"
17
18
  },
18
19
  "keywords": [
19
20
  "limbo",
@@ -0,0 +1,250 @@
1
+ // test/cli-auth.test.js
2
+ // Unit tests for the streamFilteredAuth pure-logic functions exported from cli.js.
3
+ // Run with: node --test test/cli-auth.test.js
4
+ 'use strict';
5
+
6
+ const { test } = require('node:test');
7
+ const assert = require('node:assert/strict');
8
+
9
+ const { stripAnsi, AUTH_URL_RE, TUI_CHROME_RE, flushStreamLines } = require('../cli.js');
10
+
11
+ // ─── stripAnsi ────────────────────────────────────────────────────────────────
12
+
13
+ test('stripAnsi: strips standard CSI sequences', () => {
14
+ assert.equal(stripAnsi('\x1b[32mgreen\x1b[0m'), 'green');
15
+ assert.equal(stripAnsi('\x1b[1;31mbold red\x1b[0m'), 'bold red');
16
+ assert.equal(stripAnsi('\x1b[2Kclear line'), 'clear line');
17
+ });
18
+
19
+ test('stripAnsi: strips ?-prefixed private-mode CSI sequences', () => {
20
+ // \x1b[?25l hide cursor, \x1b[?25h show cursor
21
+ assert.equal(stripAnsi('\x1b[?25lhello\x1b[?25h'), 'hello');
22
+ // \x1b[?2004h / \x1b[?2004l bracketed paste mode
23
+ assert.equal(stripAnsi('\x1b[?2004htext\x1b[?2004l'), 'text');
24
+ });
25
+
26
+ test('stripAnsi: strips two-char ESC sequences (0x40-0x5F range)', () => {
27
+ // ESC M (0x4D) — reverse index (cursor up with scroll)
28
+ assert.equal(stripAnsi('\x1bMline'), 'line');
29
+ // ESC E (0x45) — next line
30
+ assert.equal(stripAnsi('text\x1bEafter'), 'textafter');
31
+ // ESC ^ (0x5E) — privacy message (PM)
32
+ assert.equal(stripAnsi('before\x1b^after'), 'beforeafter');
33
+ });
34
+
35
+ test('stripAnsi: strips OSC sequences (BEL-terminated)', () => {
36
+ // OSC 0 ; title BEL — window title sequence
37
+ assert.equal(stripAnsi('\x1b]0;My Terminal Title\x07visible'), 'visible');
38
+ });
39
+
40
+ test('stripAnsi: strips OSC sequences (ST-terminated)', () => {
41
+ assert.equal(stripAnsi('\x1b]0;title\x1b\\visible'), 'visible');
42
+ });
43
+
44
+ test('stripAnsi: strips bare carriage returns', () => {
45
+ assert.equal(stripAnsi('line1\rline2'), 'line1line2');
46
+ assert.equal(stripAnsi('\r'), '');
47
+ });
48
+
49
+ test('stripAnsi: leaves plain text untouched', () => {
50
+ const plain = 'Hello, world! 123 !@#';
51
+ assert.equal(stripAnsi(plain), plain);
52
+ });
53
+
54
+ test('stripAnsi: handles empty string', () => {
55
+ assert.equal(stripAnsi(''), '');
56
+ });
57
+
58
+ test('stripAnsi: strips mixed sequences in one pass', () => {
59
+ // CSI (?25l hide cursor) + CSI (32m color) + OSC (window title) + bare CR
60
+ const input = '\x1b[?25l\x1b[32mProcessing\x1b[0m...\x1b]0;term\x07done\r';
61
+ assert.equal(stripAnsi(input), 'Processing...done');
62
+ // CSI + two-char ESC (M) mixed with real text
63
+ const input2 = '\x1b[1mbold\x1b[0m\x1bMnext';
64
+ assert.equal(stripAnsi(input2), 'boldnext');
65
+ });
66
+
67
+ // ─── TUI_CHROME_RE ────────────────────────────────────────────────────────────
68
+
69
+ test('TUI_CHROME_RE: suppresses Braille spinner chars', () => {
70
+ // Common clack/openclaw spinner frames
71
+ for (const ch of ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']) {
72
+ assert.equal(TUI_CHROME_RE.test(ch), true, `expected ${ch} to match TUI_CHROME_RE`);
73
+ }
74
+ });
75
+
76
+ test('TUI_CHROME_RE: suppresses box-drawing chars', () => {
77
+ assert.equal(TUI_CHROME_RE.test('─'), true);
78
+ assert.equal(TUI_CHROME_RE.test('│'), true);
79
+ assert.equal(TUI_CHROME_RE.test('┌┐└┘'), true);
80
+ assert.equal(TUI_CHROME_RE.test('═══'), true);
81
+ });
82
+
83
+ test('TUI_CHROME_RE: suppresses clack decoration chars', () => {
84
+ assert.equal(TUI_CHROME_RE.test('◇'), true);
85
+ assert.equal(TUI_CHROME_RE.test('●'), true);
86
+ assert.equal(TUI_CHROME_RE.test('◆'), true);
87
+ assert.equal(TUI_CHROME_RE.test('○'), true);
88
+ });
89
+
90
+ test('TUI_CHROME_RE: suppresses whitespace-only lines', () => {
91
+ assert.equal(TUI_CHROME_RE.test(' '), true);
92
+ assert.equal(TUI_CHROME_RE.test(''), true);
93
+ assert.equal(TUI_CHROME_RE.test('\t'), true);
94
+ });
95
+
96
+ test('TUI_CHROME_RE: suppresses mixed chrome lines (spinner + whitespace)', () => {
97
+ assert.equal(TUI_CHROME_RE.test(' ⠋ '), true);
98
+ assert.equal(TUI_CHROME_RE.test('◇ ─ ◇'), true);
99
+ });
100
+
101
+ test('TUI_CHROME_RE: passes lines with real text content', () => {
102
+ assert.equal(TUI_CHROME_RE.test('Please open this URL'), false);
103
+ assert.equal(TUI_CHROME_RE.test('Authenticating...'), false);
104
+ assert.equal(TUI_CHROME_RE.test('Press Enter to continue'), false);
105
+ assert.equal(TUI_CHROME_RE.test('Error: invalid token'), false);
106
+ });
107
+
108
+ test('TUI_CHROME_RE: passes lines starting with decoration but containing text', () => {
109
+ // clack prompts often have a leading decoration glyph followed by text
110
+ assert.equal(TUI_CHROME_RE.test('◇ Enter your API key'), false);
111
+ assert.equal(TUI_CHROME_RE.test('● Model selected: claude-opus-4-6'), false);
112
+ });
113
+
114
+ // ─── AUTH_URL_RE (URL extraction) ─────────────────────────────────────────────
115
+
116
+ test('AUTH_URL_RE: detects http OAuth URLs', () => {
117
+ const line = 'Open this URL to authenticate: http://localhost:3000/oauth/callback?code=abc123';
118
+ const matches = line.match(AUTH_URL_RE);
119
+ assert.ok(matches, 'expected URL match');
120
+ assert.equal(matches[0], 'http://localhost:3000/oauth/callback?code=abc123');
121
+ });
122
+
123
+ test('AUTH_URL_RE: detects https OAuth URLs', () => {
124
+ const line = 'Visit https://auth.anthropic.com/oauth2/authorize?client_id=limbo&state=xyz to login';
125
+ const matches = line.match(AUTH_URL_RE);
126
+ assert.ok(matches, 'expected URL match');
127
+ assert.equal(matches[0], 'https://auth.anthropic.com/oauth2/authorize?client_id=limbo&state=xyz');
128
+ });
129
+
130
+ test('AUTH_URL_RE: does not match plain text without URL', () => {
131
+ const line = 'Waiting for authentication...';
132
+ const matches = line.match(AUTH_URL_RE);
133
+ assert.equal(matches, null);
134
+ });
135
+
136
+ test('AUTH_URL_RE: stops URL at whitespace', () => {
137
+ const line = 'URL: https://example.com/auth and then some text';
138
+ const matches = line.match(AUTH_URL_RE);
139
+ assert.ok(matches);
140
+ assert.equal(matches[0], 'https://example.com/auth');
141
+ });
142
+
143
+ test('AUTH_URL_RE: stops URL at angle bracket', () => {
144
+ const line = 'Go to <https://example.com/auth>';
145
+ const matches = line.match(AUTH_URL_RE);
146
+ assert.ok(matches);
147
+ assert.equal(matches[0], 'https://example.com/auth');
148
+ });
149
+
150
+ test('AUTH_URL_RE: extracts multiple URLs from a single line', () => {
151
+ const line = 'Primary: https://example.com/a Fallback: https://example.com/b';
152
+ const matches = line.match(AUTH_URL_RE);
153
+ assert.ok(matches);
154
+ assert.equal(matches.length, 2);
155
+ assert.equal(matches[0], 'https://example.com/a');
156
+ assert.equal(matches[1], 'https://example.com/b');
157
+ });
158
+
159
+ test('AUTH_URL_RE: URL deduplication — same URL seen twice is emitted once', () => {
160
+ // This tests the seenUrls Set logic conceptually — we verify that running AUTH_URL_RE
161
+ // against the same URL twice and filtering via a Set yields a single emission.
162
+ const url = 'https://auth.openai.com/oauth/callback?code=abc';
163
+ const lines = [
164
+ `Open: ${url}`,
165
+ `Retry: ${url}`,
166
+ 'Different: https://example.com/other',
167
+ ];
168
+
169
+ const emitted = [];
170
+ const seenUrls = new Set();
171
+
172
+ for (const line of lines) {
173
+ const urls = line.match(AUTH_URL_RE) || [];
174
+ for (const u of urls) {
175
+ if (!seenUrls.has(u)) {
176
+ seenUrls.add(u);
177
+ emitted.push(u);
178
+ }
179
+ }
180
+ }
181
+
182
+ assert.equal(emitted.length, 2, 'duplicate URL should only be emitted once');
183
+ assert.equal(emitted[0], url);
184
+ assert.equal(emitted[1], 'https://example.com/other');
185
+ });
186
+
187
+ // ─── flushStreamLines (animation scatter regression) ──────────────────────────
188
+
189
+ test('flushStreamLines: emits only final \\r frame — suppresses scatter', () => {
190
+ // This is the exact pattern that caused the diagonal scatter seen in the screenshot:
191
+ // clack's character-by-character reveal writes each progressive state separated by \r.
192
+ // Before the fix, every frame was emitted as a separate line causing staircase output.
193
+ const buf = '│ Y\r│ Yo\r│ You\r│ Your URL: https://auth.example.com\n';
194
+ const { lines, remaining } = flushStreamLines(buf);
195
+ assert.equal(remaining, '');
196
+ assert.equal(lines.length, 1, 'only the final frame should be emitted');
197
+ assert.equal(lines[0], '│ Your URL: https://auth.example.com');
198
+ });
199
+
200
+ test('flushStreamLines: handles spinner animation (pure chrome final frame)', () => {
201
+ // Spinner that completes and clears the line — final state is empty/chrome
202
+ const buf = '⠋\r⠙\r⠹\r⠸\r \n';
203
+ const { lines } = flushStreamLines(buf);
204
+ assert.equal(lines.length, 1);
205
+ assert.equal(lines[0], ' '); // final frame (space = cleared); TUI_CHROME_RE will suppress it
206
+ });
207
+
208
+ test('flushStreamLines: normalises \\r\\n to single newline', () => {
209
+ const buf = 'line one\r\nline two\r\n';
210
+ const { lines, remaining } = flushStreamLines(buf);
211
+ assert.equal(remaining, '');
212
+ assert.deepEqual(lines, ['line one', 'line two']);
213
+ });
214
+
215
+ test('flushStreamLines: holds incomplete segment in remaining', () => {
216
+ const buf = 'complete line\nstill coming';
217
+ const { lines, remaining } = flushStreamLines(buf);
218
+ assert.deepEqual(lines, ['complete line']);
219
+ assert.equal(remaining, 'still coming');
220
+ });
221
+
222
+ test('flushStreamLines: accumulating chunks yields same result as one chunk', () => {
223
+ // Simulate data arriving in two chunks mid-animation-frame
224
+ const chunk1 = '│ Y\r│ Yo\r';
225
+ const chunk2 = '│ You\r│ Your URL: https://auth.example.com\n';
226
+
227
+ // Chunk 1 alone: no complete \n-terminated line yet
228
+ const r1 = flushStreamLines(chunk1);
229
+ assert.deepEqual(r1.lines, []);
230
+ assert.equal(r1.remaining, '│ Y\r│ Yo\r');
231
+
232
+ // Chunk 2 appended to remaining: yields only the final frame
233
+ const r2 = flushStreamLines(r1.remaining + chunk2);
234
+ assert.equal(r2.lines.length, 1);
235
+ assert.equal(r2.lines[0], '│ Your URL: https://auth.example.com');
236
+ assert.equal(r2.remaining, '');
237
+ });
238
+
239
+ test('flushStreamLines: multiple \\n-terminated lines processed independently', () => {
240
+ // Two different lines, each with animation frames
241
+ const buf = '⠋ Loading...\r⠙ Loading...\r Done!\nEnter code: \r\n';
242
+ const { lines } = flushStreamLines(buf);
243
+ assert.deepEqual(lines, [' Done!', 'Enter code: ']);
244
+ });
245
+
246
+ test('flushStreamLines: empty buffer returns no lines', () => {
247
+ const { lines, remaining } = flushStreamLines('');
248
+ assert.deepEqual(lines, []);
249
+ assert.equal(remaining, '');
250
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Unit tests for the CLI auth output filter logic.
3
+ *
4
+ * Tests stripAnsi, processLine (carriage-return collapse), and the emitLine
5
+ * filtering decisions in streamFilteredAuth. Zero external dependencies —
6
+ * uses Node.js built-in test runner (node:test, node >= 18).
7
+ *
8
+ * Run: node --test test/cli-filter.test.js
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { test } = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+
16
+ // ─── Replicated filter logic (must stay in sync with cli.js) ─────────────────
17
+ // If these start diverging, extract to a shared module.
18
+
19
+ const stripAnsi = (str) => str
20
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences (all parameter byte combos)
21
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (before two-char — shares \x1b] prefix)
22
+ .replace(/\x1b[^[\]]/g, '') // two-char ESC sequences (e.g. ESC 7/8 save/restore)
23
+ .replace(/\r/g, '');
24
+
25
+ const urlRe = /https?:\/\/[^\s"'<>\]]+/g;
26
+ const tuiChrome = /^[\s\u2500-\u257f\u2580-\u259f\u25a0-\u25ff\u2600-\u26ff\u2190-\u21ff\u2700-\u27bf\u2800-\u28ff]*$/u;
27
+
28
+ /** Returns last \r-frame — the final visual state of any carriage-return animation. */
29
+ function processLine(raw) {
30
+ return raw.split('\r').pop() || '';
31
+ }
32
+
33
+ /**
34
+ * Models the emitLine decision: 'url' | 'show' | 'suppress'.
35
+ * Reset urlRe.lastIndex before each call since it's a global regex.
36
+ */
37
+ function classify(rawLine) {
38
+ urlRe.lastIndex = 0;
39
+ const line = stripAnsi(rawLine);
40
+ if (urlRe.test(line)) return 'url';
41
+ if (/openclaw/i.test(line)) return 'suppress';
42
+ if (tuiChrome.test(line)) return 'suppress';
43
+ if (!line.trim()) return 'suppress';
44
+ return 'show';
45
+ }
46
+
47
+ // ─── stripAnsi ────────────────────────────────────────────────────────────────
48
+
49
+ test('stripAnsi: removes standard SGR color codes', () => {
50
+ assert.equal(stripAnsi('\x1b[31mRed text\x1b[0m'), 'Red text');
51
+ assert.equal(stripAnsi('\x1b[1;32mBold green\x1b[0m'), 'Bold green');
52
+ });
53
+
54
+ test('stripAnsi: removes private-mode sequences (the original bug — ?25l hide cursor)', () => {
55
+ assert.equal(stripAnsi('\x1b[?25l'), '');
56
+ assert.equal(stripAnsi('\x1b[?25lhello\x1b[?25h'), 'hello');
57
+ assert.equal(stripAnsi('\x1b[?2004h bracketed paste mode \x1b[?2004l'), ' bracketed paste mode ');
58
+ });
59
+
60
+ test('stripAnsi: removes cursor movement and erase sequences', () => {
61
+ assert.equal(stripAnsi('\x1b[5;10HY'), 'Y'); // cursor to row 5, col 10, then char
62
+ assert.equal(stripAnsi('\x1b[2K'), ''); // erase entire line
63
+ assert.equal(stripAnsi('\x1b[1A'), ''); // cursor up 1
64
+ assert.equal(stripAnsi('\x1b[G'), ''); // cursor to column 1
65
+ });
66
+
67
+ test('stripAnsi: removes two-char ESC sequences (save/restore cursor)', () => {
68
+ assert.equal(stripAnsi('\x1b7saved\x1b8'), 'saved');
69
+ assert.equal(stripAnsi('\x1bcReset'), 'Reset');
70
+ });
71
+
72
+ test('stripAnsi: removes OSC sequences (window title)', () => {
73
+ assert.equal(stripAnsi('\x1b]0;My Terminal\x07normal'), 'normal');
74
+ assert.equal(stripAnsi('\x1b]2;Title\x1b\\text'), 'text');
75
+ });
76
+
77
+ test('stripAnsi: strips \\r (carriage return)', () => {
78
+ assert.equal(stripAnsi('hello\rworld'), 'helloworld');
79
+ });
80
+
81
+ test('stripAnsi: passes through plain text unchanged', () => {
82
+ assert.equal(stripAnsi('Auth complete.'), 'Auth complete.');
83
+ assert.equal(stripAnsi(''), '');
84
+ });
85
+
86
+ // ─── processLine (carriage-return collapse) ───────────────────────────────────
87
+
88
+ test('processLine: returns last \\r-frame (final typewriter state)', () => {
89
+ // This is the core fix. Typewriter animation builds text with \r between frames.
90
+ const input = '│ Y\r│ Yo\r│ You\r│ You \r│ You are running in a remote environment';
91
+ assert.equal(processLine(input), '│ You are running in a remote environment');
92
+ });
93
+
94
+ test('processLine: collapses spinner animation to final frame', () => {
95
+ assert.equal(processLine('⠋ Waiting\r⠙ Waiting\r⠹ Waiting\r⠸ Done'), '⠸ Done');
96
+ });
97
+
98
+ test('processLine: returns line unchanged if no \\r present', () => {
99
+ assert.equal(processLine('Auth complete.'), 'Auth complete.');
100
+ assert.equal(processLine(''), '');
101
+ });
102
+
103
+ test('processLine: handles trailing \\r (line ending with empty final frame)', () => {
104
+ // \r at end → final frame is empty string; processLine returns ''
105
+ assert.equal(processLine('clear this\r'), '');
106
+ });
107
+
108
+ // ─── emitLine classification ──────────────────────────────────────────────────
109
+
110
+ test('classify: detects OAuth URLs', () => {
111
+ assert.equal(classify('https://auth.openai.com/oauth/authorize?foo=bar'), 'url');
112
+ assert.equal(classify(' → https://auth.openai.com/oauth/authorize?foo=bar '), 'url');
113
+ assert.equal(classify('\x1b[36mhttps://example.com/auth\x1b[0m'), 'url');
114
+ });
115
+
116
+ test('classify: suppresses OpenClaw branding', () => {
117
+ assert.equal(classify('Starting OpenClaw gateway...'), 'suppress');
118
+ assert.equal(classify('openclaw v1.2.3'), 'suppress');
119
+ assert.equal(classify(' OpenClaw ready '), 'suppress');
120
+ });
121
+
122
+ test('classify: suppresses pure TUI chrome — spinner chars', () => {
123
+ assert.equal(classify('⠋'), 'suppress');
124
+ assert.equal(classify('⠙'), 'suppress');
125
+ assert.equal(classify('⠹ '), 'suppress'); // spinner + whitespace
126
+ });
127
+
128
+ test('classify: suppresses pure TUI chrome — box-drawing and clack decorations', () => {
129
+ assert.equal(classify('│'), 'suppress'); // box drawing
130
+ assert.equal(classify('◇'), 'suppress'); // clack diamond
131
+ assert.equal(classify('●'), 'suppress'); // clack bullet
132
+ assert.equal(classify('─────'), 'suppress'); // horizontal rule
133
+ assert.equal(classify(' '), 'suppress'); // whitespace only
134
+ assert.equal(classify(''), 'suppress'); // empty
135
+ });
136
+
137
+ test('classify: suppresses lines with ANSI that reduce to chrome', () => {
138
+ // \x1b[?25l is "hide cursor" — stripping it leaves empty string
139
+ assert.equal(classify('\x1b[?25l'), 'suppress');
140
+ assert.equal(classify('\x1b[?25l◇\x1b[?25h'), 'suppress');
141
+ });
142
+
143
+ test('classify: shows actual text prompts (the lines we want to preserve)', () => {
144
+ assert.equal(classify('│ You are running in a remote environment'), 'show');
145
+ assert.equal(classify('Auth complete. Model connected.'), 'show');
146
+ assert.equal(classify('If the browser did not open, paste the callback URL:'), 'show');
147
+ assert.equal(classify('✓ Authentication successful'), 'show');
148
+ });
149
+
150
+ // ─── Full pipeline: typewriter animation ─────────────────────────────────────
151
+
152
+ test('full pipeline: typewriter animation collapses to single clean line', () => {
153
+ // Simulate the exact failure pattern from the bug report.
154
+ // OpenClaw writes text character-by-character with \r between frames,
155
+ // terminated by \n when the message is complete.
156
+ const buffer = '│ Y\r│ Yo\r│ You\r│ You \r│ You are running in a remote environment\n';
157
+
158
+ // handleData splits on \n only
159
+ const lines = buffer.split(/\r?\n/);
160
+ lines.pop(); // drop trailing empty after final \n
161
+
162
+ const results = lines.map((raw) => {
163
+ const frame = processLine(raw);
164
+ return { frame, decision: classify(frame) };
165
+ });
166
+
167
+ // Should produce exactly one output, fully formed
168
+ assert.equal(results.length, 1);
169
+ assert.equal(results[0].frame, '│ You are running in a remote environment');
170
+ assert.equal(results[0].decision, 'show');
171
+ });
172
+
173
+ test('full pipeline: spinner-only animation is suppressed after collapse', () => {
174
+ // Spinner that ends without a meaningful final state (pure decoration)
175
+ const buffer = '⠋\r⠙\r⠹\r⠸\r⠼\r\n';
176
+
177
+ const lines = buffer.split(/\r?\n/);
178
+ lines.pop();
179
+
180
+ const results = lines.map((raw) => {
181
+ const frame = processLine(raw);
182
+ return classify(frame);
183
+ });
184
+
185
+ assert.deepEqual(results, ['suppress']);
186
+ });
187
+
188
+ test('full pipeline: URL line is extracted and not double-printed', () => {
189
+ const buffer = 'Open: https://auth.openai.com/oauth/authorize?response_type=code&client_id=app\n';
190
+
191
+ const lines = buffer.split(/\r?\n/);
192
+ lines.pop();
193
+
194
+ const [raw] = lines;
195
+ const frame = processLine(raw);
196
+ assert.equal(classify(frame), 'url');
197
+ });