@tryfridayai/cli 0.2.1 → 0.2.4
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/README.md +136 -0
- package/package.json +3 -2
- package/src/commands/chat/inputLine.js +510 -0
- package/src/commands/chat/slashCommands.js +417 -133
- package/src/commands/chat/smartAffordances.js +5 -0
- package/src/commands/chat/welcomeScreen.js +56 -88
- package/src/commands/chat.js +249 -113
- package/src/commands/install.js +46 -16
- package/src/commands/plugins.js +1 -10
- package/src/commands/schedule.js +1 -10
- package/src/commands/setup.js +49 -5
- package/src/commands/uninstall.js +1 -10
- package/src/resolveRuntime.js +31 -0
- package/src/secureKeyStore.js +220 -0
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Friday CLI
|
|
2
|
+
|
|
3
|
+
Autonomous AI agent for your terminal. Chat, generate images, create videos, produce voice — all from the command line.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @tryfridayai/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js 18+.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Start chatting
|
|
17
|
+
friday chat
|
|
18
|
+
|
|
19
|
+
# Add API keys (stored in system keychain)
|
|
20
|
+
friday chat
|
|
21
|
+
/keys
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
You need at least one API key to get started:
|
|
25
|
+
|
|
26
|
+
| Key | Enables |
|
|
27
|
+
|-----|---------|
|
|
28
|
+
| `ANTHROPIC_API_KEY` | Chat (Claude) |
|
|
29
|
+
| `OPENAI_API_KEY` | Chat, Images, Video, Voice |
|
|
30
|
+
| `GOOGLE_API_KEY` | Chat, Images, Video, Voice |
|
|
31
|
+
| `ELEVENLABS_API_KEY` | Voice |
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
### Chat
|
|
36
|
+
|
|
37
|
+
Conversational AI powered by Claude, GPT, and Gemini. Sessions auto-resume — close and reopen without losing context.
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
> explain this codebase
|
|
41
|
+
> build a landing page for my app
|
|
42
|
+
> find and fix the bug in auth.js
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Image Generation
|
|
46
|
+
|
|
47
|
+
Generate images with DALL-E, GPT Image, and Google Imagen.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
> generate an image of a mountain sunset
|
|
51
|
+
> create a logo for my startup
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Video Generation
|
|
55
|
+
|
|
56
|
+
Create videos with OpenAI Sora and Google Veo.
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
> generate a 10-second video of ocean waves
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Voice
|
|
63
|
+
|
|
64
|
+
Text-to-speech with OpenAI TTS, Google Cloud TTS, and ElevenLabs.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
> read this paragraph aloud
|
|
68
|
+
> generate speech in a warm friendly tone
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
Type these in the chat:
|
|
74
|
+
|
|
75
|
+
| Command | Description |
|
|
76
|
+
|---------|-------------|
|
|
77
|
+
| `/help` | Show all commands |
|
|
78
|
+
| `/keys` | Add or manage API keys |
|
|
79
|
+
| `/model` | Enable/disable models, see pricing |
|
|
80
|
+
| `/plugins` | Install plugins (GitHub, Figma, email, etc.) |
|
|
81
|
+
| `/config` | View and edit settings |
|
|
82
|
+
| `/clear` | Clear chat history |
|
|
83
|
+
| `/quit` | Exit |
|
|
84
|
+
|
|
85
|
+
## Models
|
|
86
|
+
|
|
87
|
+
### Chat Models
|
|
88
|
+
- Claude 4 Sonnet / Opus (Anthropic)
|
|
89
|
+
- GPT-5.2 / GPT-4o (OpenAI)
|
|
90
|
+
- Gemini 3 Pro / Flash (Google)
|
|
91
|
+
|
|
92
|
+
### Image Models
|
|
93
|
+
- GPT Image 1.5 (OpenAI)
|
|
94
|
+
- Imagen 4 Ultra / Standard / Fast (Google)
|
|
95
|
+
|
|
96
|
+
### Video Models
|
|
97
|
+
- Sora 2 / Sora 2 Pro (OpenAI)
|
|
98
|
+
- Veo 3.1 / Veo 3.1 Fast (Google)
|
|
99
|
+
|
|
100
|
+
### Voice Models
|
|
101
|
+
- GPT-4o Mini TTS (OpenAI)
|
|
102
|
+
- Google Cloud TTS (WaveNet, Neural2, Standard)
|
|
103
|
+
- ElevenLabs Eleven v3, Flash v2.5, Turbo v2.5
|
|
104
|
+
|
|
105
|
+
## Plugins
|
|
106
|
+
|
|
107
|
+
Extend Friday with MCP-based plugins:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
/plugins
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Available plugins: GitHub, Figma, Firecrawl, Resend (email), Discord, Reddit, Twitter, Gmail, Google Drive, Supabase, and more.
|
|
114
|
+
|
|
115
|
+
## Architecture
|
|
116
|
+
|
|
117
|
+
The CLI (`@tryfridayai/cli`) is a thin interface over the runtime (`friday-runtime`). The runtime handles agent orchestration, MCP servers, permissions, sessions, and provider management.
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
friday chat
|
|
121
|
+
└── CLI (this package)
|
|
122
|
+
└── friday-runtime
|
|
123
|
+
├── Claude Agent SDK
|
|
124
|
+
├── MCP Servers (filesystem, terminal, media, plugins)
|
|
125
|
+
└── AI Providers (OpenAI, Google, ElevenLabs)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Links
|
|
129
|
+
|
|
130
|
+
- Website: [tryfriday.ai](https://tryfriday.ai)
|
|
131
|
+
- Documentation: [docs.tryfriday.ai](https://docs.tryfriday.ai)
|
|
132
|
+
- GitHub: [github.com/tryfridayai/friday_cli](https://github.com/tryfridayai/friday_cli)
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.2.
|
|
6
|
+
"version": "0.2.4",
|
|
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
|
+
}
|