@tryfridayai/cli 0.2.1 → 0.2.2

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.1",
6
+ "version": "0.2.2",
7
7
  "description": "Friday AI — autonomous agent for your terminal. Chat, build apps, research, automate tasks.",
8
8
  "type": "module",
9
9
  "bin": {
@@ -38,7 +38,8 @@
38
38
  ],
39
39
  "author": "Friday AI <hello@tryfriday.ai>",
40
40
  "dependencies": {
41
- "friday-runtime": "^0.2.0"
41
+ "friday-runtime": "^0.2.0",
42
+ "keytar": "^7.9.0"
42
43
  },
43
44
  "engines": {
44
45
  "node": ">=18.0.0"
@@ -0,0 +1,510 @@
1
+ /**
2
+ * chat/inputLine.js — Bottom-pinned input bar for Friday CLI chat
3
+ *
4
+ * Replaces Node's readline with a custom input line pinned to the bottom
5
+ * of the terminal. Output scrolls above a separator line; the user always
6
+ * types on the last row.
7
+ *
8
+ * Fixes:
9
+ * - Multi-line paste: pasted text (one data event with newlines) is joined
10
+ * into a single message instead of discarding all but the first line.
11
+ * - Input area: output and input no longer intermix — the prompt is always
12
+ * visible at the bottom of the terminal.
13
+ *
14
+ * How it works:
15
+ * - ANSI scroll region confines output to rows 1..N-2
16
+ * - Row N-1: dim separator line
17
+ * - Row N: input prompt with editable text
18
+ * - stdout is patched: writes are redirected into the scroll region using
19
+ * SCO cursor save/restore (\x1b[s / \x1b[u) to track the output position,
20
+ * then cursor jumps back to the input line.
21
+ * - The scroll region handles line wrapping and scrolling natively —
22
+ * no explicit \n is injected per write.
23
+ */
24
+
25
+ import { PURPLE, RESET, BOLD, DIM } from './ui.js';
26
+
27
+ const HISTORY_MAX = 50;
28
+
29
+ export default class InputLine {
30
+ constructor() {
31
+ this._buf = ''; // current input buffer
32
+ this._cursor = 0; // cursor position within _buf
33
+ this._history = []; // command history ring
34
+ this._historyIdx = -1; // -1 = current input, 0..N-1 = history
35
+ this._savedBuf = ''; // buffer saved when browsing history
36
+ this._submitCb = null; // onSubmit callback
37
+ this._active = false; // true when input line is live
38
+ this._paused = false; // true when paused for selectOption/askSecret
39
+ this._rows = 0; // terminal rows
40
+ this._cols = 0; // terminal cols
41
+ this._onData = null; // stdin data handler ref
42
+ this._onResize = null; // SIGWINCH handler ref
43
+ this._originalWrite = null; // original process.stdout.write
44
+ this._promptStr = `${BOLD}>${RESET} `; // visible prompt
45
+ this._promptLen = 2; // visible character length of prompt ("> ")
46
+ }
47
+
48
+ // ── Public API ──────────────────────────────────────────────────────
49
+
50
+ init() {
51
+ if (this._active) return;
52
+ this._active = true;
53
+
54
+ this._rows = process.stdout.rows || 24;
55
+ this._cols = process.stdout.columns || 80;
56
+
57
+ // Set scroll region (rows 1..N-2) — output is confined here
58
+ this._setScrollRegion();
59
+
60
+ // Draw separator and clear input row
61
+ this._drawChrome();
62
+
63
+ // Position output cursor at bottom of scroll region and save it
64
+ // using SCO save (\x1b[s). The patched stdout will restore/save
65
+ // this position on every write so the output cursor tracks correctly.
66
+ const scrollEnd = Math.max(1, this._rows - 2);
67
+ this._writeRaw(`\x1b[${scrollEnd};1H`);
68
+ this._writeRaw('\x1b[s'); // SCO save — output cursor position
69
+
70
+ // Patch stdout.write to redirect output into the scroll region
71
+ this._patchStdout();
72
+
73
+ // Listen for terminal resize
74
+ this._onResize = () => {
75
+ this._rows = process.stdout.rows || 24;
76
+ this._cols = process.stdout.columns || 80;
77
+ this._setScrollRegion();
78
+ this._drawChrome();
79
+ // Re-save output cursor at bottom of new scroll region
80
+ const end = Math.max(1, this._rows - 2);
81
+ this._writeRaw(`\x1b[${end};1H`);
82
+ this._writeRaw('\x1b[s');
83
+ this._renderInput();
84
+ };
85
+ process.stdout.on('resize', this._onResize);
86
+
87
+ // Start raw-mode keystroke handling
88
+ this._startRawInput();
89
+ }
90
+
91
+ destroy() {
92
+ if (!this._active) return;
93
+ this._active = false;
94
+
95
+ this._stopRawInput();
96
+
97
+ if (this._onResize) {
98
+ process.stdout.removeListener('resize', this._onResize);
99
+ this._onResize = null;
100
+ }
101
+
102
+ this._unpatchStdout();
103
+
104
+ // Reset scroll region to full terminal and move cursor to bottom
105
+ this._writeRaw('\x1b[r');
106
+ this._writeRaw(`\x1b[${this._rows};1H\n`);
107
+ }
108
+
109
+ onSubmit(cb) {
110
+ this._submitCb = cb;
111
+ }
112
+
113
+ prompt() {
114
+ if (!this._active || this._paused) return;
115
+ this._renderInput();
116
+ }
117
+
118
+ pause() {
119
+ if (this._paused) return;
120
+ this._paused = true;
121
+ this._stopRawInput();
122
+ // Reset scroll region so selectOption / askSecret can render anywhere
123
+ this._writeRaw('\x1b[r');
124
+ // Clear separator and input line, move cursor there so
125
+ // selectOption / askSecret renders in visible space
126
+ this._writeRaw(`\x1b[${this._rows - 1};1H\x1b[J`);
127
+ }
128
+
129
+ resume() {
130
+ if (!this._paused) return;
131
+ this._paused = false;
132
+
133
+ this._rows = process.stdout.rows || 24;
134
+ this._cols = process.stdout.columns || 80;
135
+
136
+ this._setScrollRegion();
137
+ this._drawChrome();
138
+
139
+ // Re-save output cursor at bottom of scroll region
140
+ const scrollEnd = Math.max(1, this._rows - 2);
141
+ this._writeRaw(`\x1b[${scrollEnd};1H`);
142
+ this._writeRaw('\x1b[s');
143
+
144
+ this._startRawInput();
145
+ this._renderInput();
146
+ }
147
+
148
+ getLine() {
149
+ return this._buf;
150
+ }
151
+
152
+ close() {
153
+ this.destroy();
154
+ process.exit(0);
155
+ }
156
+
157
+ // ── Scroll region / chrome ─────────────────────────────────────────
158
+
159
+ _setScrollRegion() {
160
+ const scrollEnd = Math.max(1, this._rows - 2);
161
+ this._writeRaw(`\x1b[1;${scrollEnd}r`);
162
+ }
163
+
164
+ /**
165
+ * Draw the separator line on row N-1 and clear the input row N.
166
+ * These rows are OUTSIDE the scroll region, so scrolling never touches them.
167
+ * Only needs to be called on init, resize, and resume — not on every write.
168
+ */
169
+ _drawChrome() {
170
+ const sepRow = this._rows - 1;
171
+ const inputRow = this._rows;
172
+
173
+ this._writeRaw(`\x1b[${sepRow};1H\x1b[2K`);
174
+ this._writeRaw(`${DIM}${'─'.repeat(this._cols)}${RESET}`);
175
+ this._writeRaw(`\x1b[${inputRow};1H\x1b[2K`);
176
+ }
177
+
178
+ /**
179
+ * Render the input prompt and buffer on row N, then place the cursor there.
180
+ * Uses absolute positioning only — does not disturb the SCO-saved output cursor.
181
+ */
182
+ _renderInput() {
183
+ if (!this._active || this._paused) return;
184
+ const inputRow = this._rows;
185
+
186
+ // Clear input row and draw prompt + buffer
187
+ this._writeRaw(`\x1b[${inputRow};1H\x1b[2K`);
188
+
189
+ const maxBufLen = this._cols - this._promptLen - 1;
190
+ let displayBuf = this._buf;
191
+ let displayCursor = this._cursor;
192
+
193
+ if (displayBuf.length > maxBufLen) {
194
+ const start = Math.max(0, this._cursor - Math.floor(maxBufLen / 2));
195
+ displayBuf = displayBuf.slice(start, start + maxBufLen);
196
+ displayCursor = this._cursor - start;
197
+ }
198
+
199
+ this._writeRaw(this._promptStr + displayBuf);
200
+
201
+ // Place visible cursor on input line
202
+ const cursorCol = this._promptLen + displayCursor + 1;
203
+ this._writeRaw(`\x1b[${inputRow};${cursorCol}H`);
204
+ }
205
+
206
+ // ── stdout interception ────────────────────────────────────────────
207
+
208
+ /**
209
+ * Patch process.stdout.write so that ALL output is redirected into the
210
+ * scroll region while the visible cursor stays on the input line.
211
+ *
212
+ * Uses SCO save/restore (\x1b[s / \x1b[u) to track the output cursor
213
+ * position inside the scroll region across writes. This allows:
214
+ * - Spinner: writes \r to overwrite in place → cursor stays on same row
215
+ * - Streaming: partial writes accumulate on same row
216
+ * - console.log: trailing \n causes the scroll region to scroll naturally
217
+ */
218
+ _patchStdout() {
219
+ if (this._originalWrite) return;
220
+ this._originalWrite = process.stdout.write.bind(process.stdout);
221
+
222
+ const self = this;
223
+ process.stdout.write = function (data, encoding, callback) {
224
+ if (!self._active || self._paused) {
225
+ return self._originalWrite(data, encoding, callback);
226
+ }
227
+
228
+ // Restore the output cursor in the scroll region (SCO restore)
229
+ self._originalWrite('\x1b[u');
230
+
231
+ // Write the data at the output cursor position
232
+ const result = self._originalWrite(data, encoding, callback);
233
+
234
+ // Save the new output cursor position (SCO save)
235
+ self._originalWrite('\x1b[s');
236
+
237
+ // Move visible cursor back to the input line
238
+ const cursorCol = self._promptLen + self._cursor + 1;
239
+ self._originalWrite(`\x1b[${self._rows};${cursorCol}H`);
240
+
241
+ return result;
242
+ };
243
+ }
244
+
245
+ _unpatchStdout() {
246
+ if (this._originalWrite) {
247
+ process.stdout.write = this._originalWrite;
248
+ this._originalWrite = null;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Write directly to the terminal, bypassing the stdout patch.
254
+ * Used for chrome drawing and cursor positioning.
255
+ */
256
+ _writeRaw(data) {
257
+ const writer = this._originalWrite || process.stdout.write.bind(process.stdout);
258
+ writer(data);
259
+ }
260
+
261
+ // ── Raw keystroke handling ─────────────────────────────────────────
262
+
263
+ _startRawInput() {
264
+ if (this._onData) return;
265
+
266
+ if (process.stdin.isTTY) {
267
+ process.stdin.setRawMode(true);
268
+ }
269
+ process.stdin.resume();
270
+
271
+ this._onData = (data) => this._handleData(data);
272
+ process.stdin.on('data', this._onData);
273
+ }
274
+
275
+ _stopRawInput() {
276
+ if (this._onData) {
277
+ process.stdin.removeListener('data', this._onData);
278
+ this._onData = null;
279
+ }
280
+ if (process.stdin.isTTY) {
281
+ process.stdin.setRawMode(false);
282
+ }
283
+ }
284
+
285
+ _handleData(data) {
286
+ const str = data.toString('utf8');
287
+
288
+ // Multi-line paste detection: pasted text arrives as one data event
289
+ // with embedded newline characters.
290
+ if (str.includes('\n') || str.includes('\r')) {
291
+ // Single Enter keypress
292
+ if (str === '\r' || str === '\n' || str === '\r\n') {
293
+ this._submit();
294
+ return;
295
+ }
296
+
297
+ // Multi-line paste: join all lines into one message
298
+ const joined = str
299
+ .replace(/\r\n/g, '\n')
300
+ .replace(/\r/g, '\n')
301
+ .split('\n')
302
+ .map(l => l.trimEnd())
303
+ .filter(l => l.length > 0)
304
+ .join(' ');
305
+
306
+ if (joined.length > 0) {
307
+ this._buf = joined;
308
+ this._cursor = joined.length;
309
+ this._renderInput();
310
+ this._submit();
311
+ }
312
+ return;
313
+ }
314
+
315
+ // Process individual keystrokes
316
+ let i = 0;
317
+ while (i < str.length) {
318
+ const ch = str[i];
319
+ const code = ch.charCodeAt(0);
320
+
321
+ // Ctrl+C
322
+ if (code === 3) {
323
+ if (this._buf.length > 0) {
324
+ this._buf = '';
325
+ this._cursor = 0;
326
+ this._historyIdx = -1;
327
+ this._renderInput();
328
+ } else {
329
+ this.destroy();
330
+ process.exit(0);
331
+ }
332
+ i++;
333
+ continue;
334
+ }
335
+
336
+ // Ctrl+A — home
337
+ if (code === 1) {
338
+ this._cursor = 0;
339
+ this._renderInput();
340
+ i++;
341
+ continue;
342
+ }
343
+
344
+ // Ctrl+E — end
345
+ if (code === 5) {
346
+ this._cursor = this._buf.length;
347
+ this._renderInput();
348
+ i++;
349
+ continue;
350
+ }
351
+
352
+ // Ctrl+U — clear line
353
+ if (code === 21) {
354
+ this._buf = '';
355
+ this._cursor = 0;
356
+ this._renderInput();
357
+ i++;
358
+ continue;
359
+ }
360
+
361
+ // Ctrl+K — kill to end of line
362
+ if (code === 11) {
363
+ this._buf = this._buf.slice(0, this._cursor);
364
+ this._renderInput();
365
+ i++;
366
+ continue;
367
+ }
368
+
369
+ // Ctrl+W — delete word backwards
370
+ if (code === 23) {
371
+ const before = this._buf.slice(0, this._cursor);
372
+ const trimmed = before.replace(/\s+$/, '');
373
+ const lastSpace = trimmed.lastIndexOf(' ');
374
+ const newEnd = lastSpace >= 0 ? lastSpace + 1 : 0;
375
+ this._buf = this._buf.slice(0, newEnd) + this._buf.slice(this._cursor);
376
+ this._cursor = newEnd;
377
+ this._renderInput();
378
+ i++;
379
+ continue;
380
+ }
381
+
382
+ // Backspace
383
+ if (code === 127 || code === 8) {
384
+ if (this._cursor > 0) {
385
+ this._buf = this._buf.slice(0, this._cursor - 1) + this._buf.slice(this._cursor);
386
+ this._cursor--;
387
+ this._renderInput();
388
+ }
389
+ i++;
390
+ continue;
391
+ }
392
+
393
+ // Escape sequences
394
+ if (code === 0x1b) {
395
+ i++;
396
+ if (i < str.length && str[i] === '[') {
397
+ i++;
398
+ let param = '';
399
+ while (i < str.length && str.charCodeAt(i) >= 0x30 && str.charCodeAt(i) <= 0x3f) {
400
+ param += str[i];
401
+ i++;
402
+ }
403
+ if (i < str.length) {
404
+ const final = str[i];
405
+ i++;
406
+
407
+ switch (final) {
408
+ case 'A': this._historyUp(); break;
409
+ case 'B': this._historyDown(); break;
410
+ case 'C':
411
+ if (this._cursor < this._buf.length) {
412
+ this._cursor++;
413
+ this._renderInput();
414
+ }
415
+ break;
416
+ case 'D':
417
+ if (this._cursor > 0) {
418
+ this._cursor--;
419
+ this._renderInput();
420
+ }
421
+ break;
422
+ case 'H':
423
+ this._cursor = 0;
424
+ this._renderInput();
425
+ break;
426
+ case 'F':
427
+ this._cursor = this._buf.length;
428
+ this._renderInput();
429
+ break;
430
+ case '~':
431
+ if (param === '3' && this._cursor < this._buf.length) {
432
+ this._buf = this._buf.slice(0, this._cursor) + this._buf.slice(this._cursor + 1);
433
+ this._renderInput();
434
+ }
435
+ break;
436
+ default: break;
437
+ }
438
+ }
439
+ } else if (i < str.length) {
440
+ i++; // skip Alt+key / other ESC sequences
441
+ }
442
+ continue;
443
+ }
444
+
445
+ // Skip other control characters
446
+ if (code < 0x20) {
447
+ i++;
448
+ continue;
449
+ }
450
+
451
+ // Printable character — insert at cursor
452
+ this._buf = this._buf.slice(0, this._cursor) + ch + this._buf.slice(this._cursor);
453
+ this._cursor++;
454
+ this._renderInput();
455
+ i++;
456
+ }
457
+ }
458
+
459
+ // ── History ────────────────────────────────────────────────────────
460
+
461
+ _historyUp() {
462
+ if (this._history.length === 0) return;
463
+ if (this._historyIdx === -1) {
464
+ this._savedBuf = this._buf;
465
+ }
466
+ if (this._historyIdx < this._history.length - 1) {
467
+ this._historyIdx++;
468
+ this._buf = this._history[this._historyIdx];
469
+ this._cursor = this._buf.length;
470
+ this._renderInput();
471
+ }
472
+ }
473
+
474
+ _historyDown() {
475
+ if (this._historyIdx <= -1) return;
476
+ this._historyIdx--;
477
+ if (this._historyIdx === -1) {
478
+ this._buf = this._savedBuf;
479
+ } else {
480
+ this._buf = this._history[this._historyIdx];
481
+ }
482
+ this._cursor = this._buf.length;
483
+ this._renderInput();
484
+ }
485
+
486
+ // ── Submit ─────────────────────────────────────────────────────────
487
+
488
+ _submit() {
489
+ const line = this._buf.trim();
490
+ this._buf = '';
491
+ this._cursor = 0;
492
+ this._historyIdx = -1;
493
+ this._savedBuf = '';
494
+
495
+ if (line.length > 0) {
496
+ if (this._history[0] !== line) {
497
+ this._history.unshift(line);
498
+ if (this._history.length > HISTORY_MAX) {
499
+ this._history.pop();
500
+ }
501
+ }
502
+ }
503
+
504
+ this._renderInput();
505
+
506
+ if (this._submitCb) {
507
+ this._submitCb(line);
508
+ }
509
+ }
510
+ }