cliedit 0.1.1 → 0.1.3

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,21 @@
1
+ # Acknowledgements & Copyright (ACKNOWLEDGEMENTS)
2
+
3
+ `cliedit` is an open-source project that utilizes components (or modified/converted versions thereof) from other open-source projects. We express our deep gratitude to the communities and authors who provided the foundation for these components.
4
+
5
+ All components listed below are included as vendored code (integrated directly into the project source code) and have been adapted to fit **cliedit**'s TypeScript architecture and remove unnecessary features (e.g., mouse support).
6
+
7
+ ## Vendored Components List
8
+
9
+ **Original Project Name: `keypress`**
10
+
11
+ **Description & Origin:** Provides raw key event parsing logic for the TTY environment. The source code was converted from the original JavaScript version and optimized for Node.js.
12
+
13
+ **Repository:** https://github.com/TooTallNate/keypress
14
+
15
+ **License:** MIT
16
+
17
+ **Copyright Notes:**
18
+
19
+ - `keypress` was originally authored by **Nathan Rajlich** (tootallnate.net).
20
+ - Copyright (c) 2012 Nathan Rajlich.
21
+ - The module is based on the keypress logic found within the Node.js Core `readline` module.
package/README.md CHANGED
@@ -4,7 +4,7 @@ A lightweight, zero-dependency, raw-mode terminal editor component for Node.js.
4
4
 
5
5
  `cliedit` is designed to be imported into your own CLI application to provide a full-featured, TTY-based text editing experience. It's perfect for applications that need to ask the user for multi-line input, edit configuration files, or write commit messages.
6
6
 
7
- It includes line wrapping, visual navigation, undo/redo, text selection, and clipboard support.
7
+ It includes line wrapping, visual navigation, smart auto-indentation, undo/redo, text selection, Find/Replace, and cross-platform clipboard support.
8
8
 
9
9
  ## Features
10
10
 
@@ -13,18 +13,21 @@ It includes line wrapping, visual navigation, undo/redo, text selection, and cli
13
13
  - **Visual Navigation:** `Up`/`Down` arrows move by visual rows, not logical lines.
14
14
  - **Undo/Redo:** `Ctrl+Z` / `Ctrl+Y` for persistent history.
15
15
  - **Text Selection:** `Ctrl+Arrow` keys to select text.
16
- - **Clipboard Support:** `Ctrl+C` (Copy), `Ctrl+X` (Cut), `Ctrl+V` (Paste) for system clipboard (macOS/Windows).
16
+ - **Clipboard Support:** `Ctrl+C` (Copy), `Ctrl+X` (Cut), `Ctrl+V` (Paste) for system clipboard (macOS, Windows, **and Linux** via `xclip`).
17
17
  - **File I/O:** Loads from and saves to the filesystem.
18
- - **Search:** `Ctrl+W` to find text.
18
+ - **Search & Replace:** `Ctrl+W` to find text, `Ctrl+R` to find and replace interactively.
19
+ - **Go to Line:** `Ctrl+L` to quickly jump to a specific line number.
20
+ - **Smart Auto-Indentation:** Automatically preserves indentation level when pressing Enter.
19
21
 
20
22
  ## Installation
21
23
  ```bash
22
24
  npm install cliedit
23
- ```
25
+ ````
24
26
 
25
27
  ## Usage
26
28
 
27
29
  The package exports an `async` function `openEditor` that returns a `Promise`. The promise resolves when the user quits the editor.
30
+
28
31
  ```javascript
29
32
  import { openEditor } from 'cliedit';
30
33
  import path from 'path';
@@ -60,13 +63,15 @@ getCommitMessage();
60
63
  `openEditor(filepath: string)`
61
64
 
62
65
  Opens the editor for the specified file. If the file doesn't exist, it will be created upon saving.
63
- - **Returns:** `Promise<{ saved: boolean; content: string }>`
64
- * `saved`: `true` if the user saved (Ctrl+S), `false` otherwise (Ctrl+Q).
65
- * `content`: The final content of the file as a string.
66
+
67
+ - **Returns:** `Promise<{ saved: boolean; content: string }>`
68
+ * `saved`: `true` if the user saved (Ctrl+S), `false` otherwise (Ctrl+Q).
69
+ * `content`: The final content of the file as a string.
66
70
 
67
71
  `CliEditor`
68
72
 
69
73
  The main editor class. You can import this directly if you need to extend or instantiate the editor with custom logic.
74
+
70
75
  ```javascript
71
76
  import { CliEditor } from 'cliedit';
72
77
  ```
@@ -74,6 +79,7 @@ import { CliEditor } from 'cliedit';
74
79
  ### Types
75
80
 
76
81
  Key types are also exported for convenience:
82
+
77
83
  ```javascript
78
84
  import type {
79
85
  DocumentState,
@@ -20,7 +20,9 @@ export declare const KEYS: {
20
20
  CTRL_Q: string;
21
21
  CTRL_S: string;
22
22
  CTRL_W: string;
23
+ CTRL_R: string;
23
24
  CTRL_G: string;
25
+ CTRL_L: string;
24
26
  CTRL_Z: string;
25
27
  CTRL_Y: string;
26
28
  CTRL_K: string;
package/dist/constants.js CHANGED
@@ -22,7 +22,9 @@ export const KEYS = {
22
22
  CTRL_Q: '\x11', // Quit
23
23
  CTRL_S: '\x13', // Save
24
24
  CTRL_W: '\x17', // Find (Where is)
25
+ CTRL_R: '\x12', // Replace
25
26
  CTRL_G: '\x07', // Go to next
27
+ CTRL_L: '\x0c', // Go to Line (L)
26
28
  CTRL_Z: '\x1a', // Undo
27
29
  CTRL_Y: '\x19', // Redo
28
30
  CTRL_K: '\x0b', // Cut/Kill line
@@ -15,8 +15,11 @@ function setClipboard(text) {
15
15
  case 'win32':
16
16
  command = 'clip';
17
17
  break;
18
+ case 'linux': // <--- THÊM HỖ TRỢ LINUX
19
+ command = 'xclip -selection clipboard';
20
+ break;
18
21
  default:
19
- this.setStatusMessage('Clipboard only supported on macOS/Windows for now');
22
+ this.setStatusMessage('Clipboard not supported on this platform');
20
23
  return resolve();
21
24
  }
22
25
  const process = exec(command, (error) => {
@@ -43,8 +46,11 @@ function getClipboard() {
43
46
  case 'win32':
44
47
  command = 'powershell -command "Get-Clipboard"';
45
48
  break;
49
+ case 'linux': // <--- THÊM HỖ TRỢ LINUX
50
+ command = 'xclip -selection clipboard -o'; // -o (hoặc -out) để đọc
51
+ break;
46
52
  default:
47
- this.setStatusMessage('Clipboard only supported on macOS/Windows for now');
53
+ this.setStatusMessage('Clipboard not supported on this platform');
48
54
  return resolve('');
49
55
  }
50
56
  exec(command, (error, stdout) => {
package/dist/editor.d.ts CHANGED
@@ -42,8 +42,10 @@ export declare class CliEditor {
42
42
  statusTimeout: NodeJS.Timeout | null;
43
43
  isMessageCustom: boolean;
44
44
  quitConfirm: boolean;
45
- readonly DEFAULT_STATUS = "HELP: Ctrl+S = Save & Quit | Ctrl+Q = Quit | Ctrl+C = Copy All | Ctrl+Arrow = Select";
45
+ readonly DEFAULT_STATUS = "HELP: Ctrl+S = Save | Ctrl+Q = Quit | Ctrl+W = Find | Ctrl+R = Replace | Ctrl+L = Go to Line";
46
46
  searchQuery: string;
47
+ replaceQuery: string | null;
48
+ goToLineQuery: string;
47
49
  searchResults: {
48
50
  y: number;
49
51
  x: number;
@@ -18,6 +18,7 @@ declare function insertCharacter(this: CliEditor, char: string): void;
18
18
  declare function insertSoftTab(this: CliEditor): void;
19
19
  /**
20
20
  * Inserts a new line, splitting the current line at the cursor position.
21
+ * Implements auto-indent.
21
22
  */
22
23
  declare function insertNewLine(this: CliEditor): void;
23
24
  /**
@@ -28,6 +29,14 @@ declare function deleteBackward(this: CliEditor): void;
28
29
  * Deletes the character after the cursor, or joins the current line with the next one.
29
30
  */
30
31
  declare function deleteForward(this: CliEditor): void;
32
+ /**
33
+ * Handles auto-pairing of brackets and quotes.
34
+ * If text is selected, it wraps the selection.
35
+ * Otherwise, it inserts the pair and places the cursor in the middle.
36
+ * @param openChar The opening character that was typed (e.g., '(', '[', '{').
37
+ * @param closeChar The corresponding closing character (e.g., ')', ']', '}').
38
+ */
39
+ declare function handleAutoPair(this: CliEditor, openChar: string, closeChar: string): void;
31
40
  export declare const editingMethods: {
32
41
  insertContentAtCursor: typeof insertContentAtCursor;
33
42
  insertCharacter: typeof insertCharacter;
@@ -35,5 +44,6 @@ export declare const editingMethods: {
35
44
  insertNewLine: typeof insertNewLine;
36
45
  deleteBackward: typeof deleteBackward;
37
46
  deleteForward: typeof deleteForward;
47
+ handleAutoPair: typeof handleAutoPair;
38
48
  };
39
49
  export {};
@@ -51,14 +51,19 @@ function insertSoftTab() {
51
51
  }
52
52
  /**
53
53
  * Inserts a new line, splitting the current line at the cursor position.
54
+ * Implements auto-indent.
54
55
  */
55
56
  function insertNewLine() {
56
57
  const line = this.lines[this.cursorY] || '';
58
+ // Find indentation of the current line
59
+ const match = line.match(/^(\s*)/);
60
+ const indent = match ? match[1] : '';
57
61
  const remainder = line.slice(this.cursorX);
58
62
  this.lines[this.cursorY] = line.slice(0, this.cursorX);
59
- this.lines.splice(this.cursorY + 1, 0, remainder);
63
+ // Add new line with the same indentation + remainder
64
+ this.lines.splice(this.cursorY + 1, 0, indent + remainder);
60
65
  this.cursorY++;
61
- this.cursorX = 0;
66
+ this.cursorX = indent.length; // Move cursor to end of indent
62
67
  this.setDirty();
63
68
  }
64
69
  /**
@@ -105,6 +110,36 @@ function deleteForward() {
105
110
  }
106
111
  this.setDirty();
107
112
  }
113
+ /**
114
+ * Handles auto-pairing of brackets and quotes.
115
+ * If text is selected, it wraps the selection.
116
+ * Otherwise, it inserts the pair and places the cursor in the middle.
117
+ * @param openChar The opening character that was typed (e.g., '(', '[', '{').
118
+ * @param closeChar The corresponding closing character (e.g., ')', ']', '}').
119
+ */
120
+ function handleAutoPair(openChar, closeChar) {
121
+ if (this.selectionAnchor) {
122
+ // There is a selection, so we need to wrap it.
123
+ const selection = this.getNormalizedSelection();
124
+ if (!selection)
125
+ return; // Should not happen if anchor exists, but good practice
126
+ const selectedText = this.getSelectedText();
127
+ // The deleteSelectedText() function automatically moves the cursor to the start
128
+ // of the selection, so we don't need to set it manually.
129
+ this.deleteSelectedText();
130
+ // Wrap the original selected text
131
+ const wrappedText = openChar + selectedText + closeChar;
132
+ this.insertContentAtCursor(wrappedText.split('\n'));
133
+ // The selection is already cancelled by deleteSelectedText().
134
+ }
135
+ else {
136
+ // No selection, just insert the opening and closing characters
137
+ this.insertCharacter(openChar + closeChar);
138
+ // Move cursor back one position to be in between the pair
139
+ this.cursorX--;
140
+ }
141
+ this.setDirty();
142
+ }
108
143
  export const editingMethods = {
109
144
  insertContentAtCursor,
110
145
  insertCharacter,
@@ -112,4 +147,5 @@ export const editingMethods = {
112
147
  insertNewLine,
113
148
  deleteBackward,
114
149
  deleteForward,
150
+ handleAutoPair,
115
151
  };
package/dist/editor.js CHANGED
@@ -13,7 +13,7 @@ import { historyMethods } from './editor.history.js';
13
13
  import { ioMethods } from './editor.io.js';
14
14
  import { keyHandlingMethods } from './editor.keys.js';
15
15
  import { selectionMethods } from './editor.selection.js';
16
- const DEFAULT_STATUS = 'HELP: Ctrl+S = Save & Quit | Ctrl+Q = Quit | Ctrl+C = Copy All | Ctrl+Arrow = Select';
16
+ const DEFAULT_STATUS = 'HELP: Ctrl+S = Save | Ctrl+Q = Quit | Ctrl+W = Find | Ctrl+R = Replace | Ctrl+L = Go to Line';
17
17
  /**
18
18
  * Main editor class managing application state, TTY interaction, and rendering.
19
19
  */
@@ -36,6 +36,8 @@ export class CliEditor {
36
36
  this.quitConfirm = false;
37
37
  this.DEFAULT_STATUS = DEFAULT_STATUS;
38
38
  this.searchQuery = '';
39
+ this.replaceQuery = null; // null = Find mode, string = Replace mode
40
+ this.goToLineQuery = ''; // For Go to Line prompt
39
41
  this.searchResults = [];
40
42
  this.searchResultIndex = -1;
41
43
  this.isCleanedUp = false;
@@ -3,6 +3,8 @@ export type TKeyHandlingMethods = {
3
3
  handleKeypressEvent: (ch: string, key: KeypressEvent) => void;
4
4
  handleEditKeys: (key: string) => boolean;
5
5
  handleSearchKeys: (key: string) => void;
6
+ handleSearchConfirmKeys: (key: string) => void;
7
+ handleGoToLineKeys: (key: string) => void;
6
8
  handleCtrlQ: () => void;
7
9
  handleCopy: () => Promise<void>;
8
10
  handleCharacterKey: (ch: string) => void;
@@ -1,5 +1,12 @@
1
1
  // src/editor.keys.ts
2
2
  import { KEYS } from './constants.js';
3
+ const PAIR_MAP = {
4
+ '(': ')',
5
+ '[': ']',
6
+ '{': '}',
7
+ "'": "'",
8
+ '"': '"',
9
+ };
3
10
  /**
4
11
  * Main router for standardized keypress events from the 'keypress' library.
5
12
  */
@@ -13,10 +20,21 @@ function handleKeypressEvent(ch, key) {
13
20
  // --- 1. Xử lý trường hợp key là null/undefined (Ký tự in được) ---
14
21
  if (!key) {
15
22
  if (ch && ch.length === 1 && ch >= ' ' && ch <= '~') {
16
- edited = this.handleEditKeys(ch);
17
- if (edited) {
18
- this.saveState();
19
- this.recalculateVisualRows(); // Phải tính toán lại sau khi gõ
23
+ if (this.mode === 'search_find' || this.mode === 'search_replace') {
24
+ this.handleSearchKeys(ch);
25
+ }
26
+ else if (this.mode === 'goto_line') {
27
+ this.handleGoToLineKeys(ch);
28
+ }
29
+ else if (this.mode === 'edit') {
30
+ edited = this.handleEditKeys(ch);
31
+ if (edited) {
32
+ this.saveState();
33
+ this.recalculateVisualRows(); // Phải tính toán lại sau khi gõ
34
+ }
35
+ }
36
+ else if (this.mode === 'search_confirm') {
37
+ this.handleSearchConfirmKeys(ch);
20
38
  }
21
39
  this.render();
22
40
  return;
@@ -67,9 +85,15 @@ function handleKeypressEvent(ch, key) {
67
85
  keyName = key.sequence;
68
86
  }
69
87
  // --- 3. Định tuyến theo Mode ---
70
- if (this.mode === 'search') {
88
+ if (this.mode === 'search_find' || this.mode === 'search_replace') {
71
89
  this.handleSearchKeys(keyName || ch);
72
90
  }
91
+ else if (this.mode === 'search_confirm') {
92
+ this.handleSearchConfirmKeys(keyName || ch);
93
+ }
94
+ else if (this.mode === 'goto_line') {
95
+ this.handleGoToLineKeys(keyName || ch);
96
+ }
73
97
  else {
74
98
  // 4. Xử lý phím lựa chọn (Ctrl+Arrow) - Navigation
75
99
  switch (keyName) {
@@ -158,10 +182,24 @@ function handleEditKeys(key) {
158
182
  this.insertNewLine();
159
183
  return true;
160
184
  case KEYS.BACKSPACE:
161
- if (this.selectionAnchor)
162
- this.deleteSelectedText();
163
- else
164
- this.deleteBackward();
185
+ // Handle auto-pair deletion
186
+ const line = this.lines[this.cursorY] || '';
187
+ const charBefore = line[this.cursorX - 1];
188
+ const charAfter = line[this.cursorX];
189
+ if (!this.selectionAnchor &&
190
+ charBefore && charAfter &&
191
+ PAIR_MAP[charBefore] === charAfter) {
192
+ // Delete both characters of the pair
193
+ this.lines[this.cursorY] = line.slice(0, this.cursorX - 1) + line.slice(this.cursorX + 1);
194
+ this.cursorX--; // Move cursor back
195
+ this.setDirty();
196
+ }
197
+ else {
198
+ if (this.selectionAnchor)
199
+ this.deleteSelectedText();
200
+ else
201
+ this.deleteBackward();
202
+ }
165
203
  return true;
166
204
  case KEYS.DELETE:
167
205
  if (this.selectionAnchor)
@@ -174,7 +212,13 @@ function handleEditKeys(key) {
174
212
  return true;
175
213
  // --- Search & History ---
176
214
  case KEYS.CTRL_W:
177
- this.enterSearchMode();
215
+ this.enterFindMode();
216
+ return false;
217
+ case KEYS.CTRL_R:
218
+ this.enterReplaceMode();
219
+ return false;
220
+ case KEYS.CTRL_L:
221
+ this.enterGoToLineMode();
178
222
  return false;
179
223
  case KEYS.CTRL_G:
180
224
  this.findNext();
@@ -215,11 +259,26 @@ function handleEditKeys(key) {
215
259
  * Handles insertion of a character, deleting selection first if it exists.
216
260
  */
217
261
  function handleCharacterKey(ch) {
218
- if (this.selectionAnchor) {
219
- this.deleteSelectedText();
262
+ const line = this.lines[this.cursorY] || '';
263
+ const charAfter = line[this.cursorX];
264
+ // If user types a closing character and it's what we expect, just move the cursor.
265
+ if (!this.selectionAnchor &&
266
+ (ch === ')' || ch === ']' || ch === '}' || ch === "'" || ch === '"') &&
267
+ charAfter === ch) {
268
+ this.cursorX++;
269
+ return;
270
+ }
271
+ const closeChar = PAIR_MAP[ch];
272
+ if (closeChar) {
273
+ this.handleAutoPair(ch, closeChar);
274
+ }
275
+ else {
276
+ if (this.selectionAnchor) {
277
+ this.deleteSelectedText();
278
+ }
279
+ this.insertCharacter(ch);
280
+ this.setDirty();
220
281
  }
221
- this.insertCharacter(ch);
222
- this.setDirty();
223
282
  }
224
283
  /**
225
284
  * Handles Ctrl+Q (Quit) sequence.
@@ -268,28 +327,130 @@ async function handleSave() {
268
327
  }
269
328
  }
270
329
  /**
271
- * Handles Search Mode input keys.
330
+ * Handles Search Mode input keys (for 'search_find' and 'search_replace').
272
331
  */
273
332
  function handleSearchKeys(key) {
333
+ const cancelSearch = () => {
334
+ this.mode = 'edit';
335
+ this.searchQuery = '';
336
+ this.replaceQuery = null;
337
+ this.searchResults = [];
338
+ this.searchResultIndex = -1;
339
+ this.setStatusMessage('Cancelled');
340
+ };
274
341
  switch (key) {
275
342
  case KEYS.ENTER:
276
- this.executeSearch();
277
- this.mode = 'edit';
343
+ if (this.mode === 'search_find') {
344
+ if (this.replaceQuery === null) {
345
+ // Find-Only Flow: Execute search and find first
346
+ this.executeSearch();
347
+ this.mode = 'edit';
348
+ this.findNext();
349
+ }
350
+ else {
351
+ // Replace Flow: Transition to get replace string
352
+ this.mode = 'search_replace';
353
+ this.setStatusMessage('Replace with: ');
354
+ }
355
+ }
356
+ else if (this.mode === 'search_replace') {
357
+ // Replace Flow: We have both strings, execute and find first
358
+ this.executeSearch();
359
+ this.mode = 'edit';
360
+ this.findNext();
361
+ }
362
+ break;
363
+ case KEYS.ESCAPE:
364
+ case KEYS.CTRL_C:
365
+ case KEYS.CTRL_Q:
366
+ cancelSearch();
367
+ break;
368
+ case KEYS.BACKSPACE:
369
+ if (this.mode === 'search_find') {
370
+ this.searchQuery = this.searchQuery.slice(0, -1);
371
+ }
372
+ else {
373
+ this.replaceQuery = this.replaceQuery.slice(0, -1);
374
+ }
375
+ break;
376
+ default:
377
+ if (key.length === 1 && key >= ' ' && key <= '~') {
378
+ if (this.mode === 'search_find') {
379
+ this.searchQuery += key;
380
+ }
381
+ else {
382
+ this.replaceQuery += key;
383
+ }
384
+ }
385
+ }
386
+ // Update status bar message live (if not cancelling)
387
+ if (this.mode === 'search_find') {
388
+ this.setStatusMessage((this.replaceQuery === null ? 'Find: ' : 'Find: ') + this.searchQuery);
389
+ }
390
+ else if (this.mode === 'search_replace') {
391
+ this.setStatusMessage('Replace with: ' + this.replaceQuery);
392
+ }
393
+ }
394
+ /**
395
+ * Handles keypresses during the (y/n/a/q) confirmation step.
396
+ */
397
+ function handleSearchConfirmKeys(key) {
398
+ switch (key.toLowerCase()) {
399
+ case 'y': // Yes
400
+ this.replaceCurrentAndFindNext();
401
+ break;
402
+ case 'n': // No
278
403
  this.findNext();
279
404
  break;
405
+ case 'a': // All
406
+ this.replaceAll();
407
+ break;
408
+ case 'q': // Quit
280
409
  case KEYS.ESCAPE:
281
410
  case KEYS.CTRL_C:
282
411
  case KEYS.CTRL_Q:
283
412
  this.mode = 'edit';
284
- this.searchQuery = '';
285
- this.setStatusMessage('Search cancelled');
413
+ this.searchResults = [];
414
+ this.searchResultIndex = -1;
415
+ this.setStatusMessage('Replace cancelled');
416
+ break;
417
+ }
418
+ }
419
+ /**
420
+ * Handles keypresses during the 'Go to Line' prompt.
421
+ */
422
+ function handleGoToLineKeys(key) {
423
+ const cancel = () => {
424
+ this.mode = 'edit';
425
+ this.goToLineQuery = '';
426
+ this.setStatusMessage('Cancelled');
427
+ };
428
+ switch (key) {
429
+ case KEYS.ENTER:
430
+ const lineNumber = parseInt(this.goToLineQuery, 10);
431
+ if (!isNaN(lineNumber) && lineNumber > 0) {
432
+ this.jumpToLine(lineNumber);
433
+ }
434
+ else {
435
+ this.mode = 'edit';
436
+ this.setStatusMessage('Invalid line number');
437
+ }
438
+ this.goToLineQuery = '';
439
+ break;
440
+ case KEYS.ESCAPE:
441
+ case KEYS.CTRL_C:
442
+ case KEYS.CTRL_Q:
443
+ cancel();
286
444
  break;
287
445
  case KEYS.BACKSPACE:
288
- this.searchQuery = this.searchQuery.slice(0, -1);
446
+ this.goToLineQuery = this.goToLineQuery.slice(0, -1);
447
+ this.setStatusMessage('Go to Line: ' + this.goToLineQuery);
289
448
  break;
290
449
  default:
291
- if (key.length === 1 && key >= ' ' && key <= '~') {
292
- this.searchQuery += key;
450
+ // Only accept digits
451
+ if (key.length === 1 && key >= '0' && key <= '9') {
452
+ this.goToLineQuery += key;
453
+ this.setStatusMessage('Go to Line: ' + this.goToLineQuery);
293
454
  }
294
455
  }
295
456
  }
@@ -297,6 +458,8 @@ export const keyHandlingMethods = {
297
458
  handleKeypressEvent,
298
459
  handleEditKeys,
299
460
  handleSearchKeys,
461
+ handleSearchConfirmKeys,
462
+ handleGoToLineKeys,
300
463
  handleCtrlQ,
301
464
  handleCopy,
302
465
  handleCharacterKey,
@@ -30,6 +30,14 @@ declare function adjustCursorPosition(this: CliEditor): void;
30
30
  * Scrolls the viewport to keep the cursor visible.
31
31
  */
32
32
  declare function scroll(this: CliEditor): void;
33
+ /**
34
+ * Jumps the cursor to a specific line number (1-based).
35
+ */
36
+ declare function jumpToLine(this: CliEditor, lineNumber: number): void;
37
+ /**
38
+ * Enters Go To Line mode.
39
+ */
40
+ declare function enterGoToLineMode(this: CliEditor): void;
33
41
  export declare const navigationMethods: {
34
42
  findCurrentVisualRowIndex: typeof findCurrentVisualRowIndex;
35
43
  moveCursorLogically: typeof moveCursorLogically;
@@ -38,5 +46,7 @@ export declare const navigationMethods: {
38
46
  findVisualRowEnd: typeof findVisualRowEnd;
39
47
  adjustCursorPosition: typeof adjustCursorPosition;
40
48
  scroll: typeof scroll;
49
+ jumpToLine: typeof jumpToLine;
50
+ enterGoToLineMode: typeof enterGoToLineMode;
41
51
  };
42
52
  export {};
@@ -121,6 +121,28 @@ function scroll() {
121
121
  this.rowOffset = currentVisualRow - this.screenRows + 1;
122
122
  }
123
123
  }
124
+ /**
125
+ * Jumps the cursor to a specific line number (1-based).
126
+ */
127
+ function jumpToLine(lineNumber) {
128
+ const targetY = lineNumber - 1; // Convert 1-based to 0-based index
129
+ // Clamp targetY to valid range
130
+ this.cursorY = Math.max(0, Math.min(targetY, this.lines.length - 1));
131
+ this.cursorX = 0; // Move to start of line
132
+ // Adjust scroll
133
+ const visualRowIndex = this.findCurrentVisualRowIndex();
134
+ this.rowOffset = Math.max(0, visualRowIndex - Math.floor(this.screenRows / 2));
135
+ this.mode = 'edit';
136
+ this.setStatusMessage(`Jumped to line ${lineNumber}`, 1000);
137
+ }
138
+ /**
139
+ * Enters Go To Line mode.
140
+ */
141
+ function enterGoToLineMode() {
142
+ this.mode = 'goto_line';
143
+ this.goToLineQuery = '';
144
+ this.setStatusMessage('Go to Line (ESC to cancel): ');
145
+ }
124
146
  export const navigationMethods = {
125
147
  findCurrentVisualRowIndex,
126
148
  moveCursorLogically,
@@ -129,4 +151,6 @@ export const navigationMethods = {
129
151
  findVisualRowEnd,
130
152
  adjustCursorPosition,
131
153
  scroll,
154
+ jumpToLine,
155
+ enterGoToLineMode,
132
156
  };
@@ -65,6 +65,11 @@ function render() {
65
65
  const logicalY = row.logicalY;
66
66
  const isCursorPosition = (visualRowIndex === currentVisualRowIndex && i === cursorVisualX);
67
67
  const isSelected = selectionRange && this.isPositionInSelection(logicalY, logicalX, selectionRange);
68
+ // Highlight search result under cursor
69
+ const isSearchResult = (this.searchResultIndex !== -1 &&
70
+ this.searchResults[this.searchResultIndex]?.y === logicalY &&
71
+ logicalX >= this.searchResults[this.searchResultIndex]?.x &&
72
+ logicalX < (this.searchResults[this.searchResultIndex]?.x + this.searchQuery.length));
68
73
  if (isSelected) {
69
74
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
70
75
  }
@@ -72,6 +77,10 @@ function render() {
72
77
  // Cursor is a single inverted character if not already covered by selection
73
78
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
74
79
  }
80
+ else if (isSearchResult) {
81
+ // Highlight search result
82
+ buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
83
+ }
75
84
  else {
76
85
  buffer += char;
77
86
  }
@@ -123,24 +132,38 @@ function renderStatusBar() {
123
132
  let status = '';
124
133
  const contentWidth = this.screenCols;
125
134
  // --- Line 1: Mode, File Status, Position ---
126
- if (this.mode === 'search') {
127
- status = `SEARCH: ${this.searchQuery}`;
128
- }
129
- else {
130
- const visualRowIndex = this.findCurrentVisualRowIndex();
131
- const visualRow = this.visualRows[visualRowIndex];
132
- const visualX = visualRow ? (this.cursorX - visualRow.logicalXStart) : 0;
133
- const fileStatus = this.isDirty ? `* ${this.filepath}` : this.filepath;
134
- const pos = `Ln ${this.cursorY + 1}, Col ${this.cursorX + 1} (View: ${visualRowIndex + 1},${visualX + 1})`;
135
- const statusLeft = `[${fileStatus}]`.padEnd(Math.floor(contentWidth * 0.5));
136
- const statusRight = pos.padStart(Math.floor(contentWidth * 0.5));
137
- status = statusLeft + statusRight;
135
+ switch (this.mode) {
136
+ case 'search_find':
137
+ status = (this.replaceQuery === null ? 'Find: ' : 'Find: ') + this.searchQuery;
138
+ break;
139
+ case 'search_replace':
140
+ status = 'Replace with: ' + this.replaceQuery;
141
+ break;
142
+ case 'goto_line':
143
+ status = 'Go to Line: ' + this.goToLineQuery;
144
+ break;
145
+ case 'search_confirm':
146
+ // The (y/n/a/q) prompt is set via setStatusMessage
147
+ status = this.statusMessage;
148
+ break;
149
+ case 'edit':
150
+ default:
151
+ const visualRowIndex = this.findCurrentVisualRowIndex();
152
+ const visualRow = this.visualRows[visualRowIndex];
153
+ const visualX = visualRow ? (this.cursorX - visualRow.logicalXStart) : 0;
154
+ const fileStatus = this.isDirty ? `* ${this.filepath}` : this.filepath;
155
+ const pos = `Ln ${this.cursorY + 1}, Col ${this.cursorX + 1} (View: ${visualRowIndex + 1},${visualX + 1})`;
156
+ const statusLeft = `[${fileStatus}]`.padEnd(Math.floor(contentWidth * 0.5));
157
+ const statusRight = pos.padStart(Math.floor(contentWidth * 0.5));
158
+ status = statusLeft + statusRight;
159
+ break;
138
160
  }
139
161
  status = status.padEnd(contentWidth);
140
162
  let buffer = `${ANSI.INVERT_COLORS}${status}${ANSI.RESET_COLORS}`;
141
163
  // --- Line 2: Message/Help line ---
142
164
  buffer += `\x1b[${this.screenRows + this.screenStartRow + 1};1H`;
143
- const message = this.statusMessage.padEnd(contentWidth);
165
+ // Show prompt message if in search mode, otherwise show default help
166
+ const message = (this.mode === 'edit' ? this.DEFAULT_STATUS : this.statusMessage).padEnd(contentWidth);
144
167
  buffer += `${message}${ANSI.CLEAR_LINE}`;
145
168
  return buffer;
146
169
  }
@@ -1,11 +1,15 @@
1
1
  import { CliEditor } from './editor.js';
2
2
  /**
3
- * Methods related to Find/Search functionality.
3
+ * Methods related to Find/Search/Replace functionality.
4
4
  */
5
5
  /**
6
- * Enters search mode.
6
+ * Enters Find mode.
7
7
  */
8
- declare function enterSearchMode(this: CliEditor): void;
8
+ declare function enterFindMode(this: CliEditor): void;
9
+ /**
10
+ * Enters Replace mode (starting with the "Find" prompt).
11
+ */
12
+ declare function enterReplaceMode(this: CliEditor): void;
9
13
  /**
10
14
  * Executes the search and populates results.
11
15
  */
@@ -14,6 +18,14 @@ declare function executeSearch(this: CliEditor): void;
14
18
  * Jumps to the next search result.
15
19
  */
16
20
  declare function findNext(this: CliEditor): void;
21
+ /**
22
+ * Replaces the current highlighted search result and finds the next one.
23
+ */
24
+ declare function replaceCurrentAndFindNext(this: CliEditor): void;
25
+ /**
26
+ * Replaces all occurrences of the search query.
27
+ */
28
+ declare function replaceAll(this: CliEditor): void;
17
29
  /**
18
30
  * Moves cursor and adjusts scroll offset to make the result visible.
19
31
  */
@@ -22,9 +34,12 @@ declare function jumpToResult(this: CliEditor, result: {
22
34
  x: number;
23
35
  }): void;
24
36
  export declare const searchMethods: {
25
- enterSearchMode: typeof enterSearchMode;
37
+ enterFindMode: typeof enterFindMode;
38
+ enterReplaceMode: typeof enterReplaceMode;
26
39
  executeSearch: typeof executeSearch;
27
40
  findNext: typeof findNext;
41
+ replaceCurrentAndFindNext: typeof replaceCurrentAndFindNext;
42
+ replaceAll: typeof replaceAll;
28
43
  jumpToResult: typeof jumpToResult;
29
44
  };
30
45
  export {};
@@ -1,16 +1,28 @@
1
1
  // src/editor.search.ts
2
2
  /**
3
- * Methods related to Find/Search functionality.
3
+ * Methods related to Find/Search/Replace functionality.
4
4
  */
5
5
  /**
6
- * Enters search mode.
6
+ * Enters Find mode.
7
7
  */
8
- function enterSearchMode() {
9
- this.mode = 'search';
8
+ function enterFindMode() {
9
+ this.mode = 'search_find';
10
10
  this.searchQuery = '';
11
+ this.replaceQuery = null; // Mark as Find-Only
11
12
  this.searchResults = [];
12
13
  this.searchResultIndex = -1;
13
- this.setStatusMessage('Search (ESC/Ctrl+Q/C to cancel, ENTER to find): ');
14
+ this.setStatusMessage('Find (ESC to cancel): ');
15
+ }
16
+ /**
17
+ * Enters Replace mode (starting with the "Find" prompt).
18
+ */
19
+ function enterReplaceMode() {
20
+ this.mode = 'search_find';
21
+ this.searchQuery = '';
22
+ this.replaceQuery = ''; // Mark as Replace flow
23
+ this.searchResults = [];
24
+ this.searchResultIndex = -1;
25
+ this.setStatusMessage('Find (for Replace): ');
14
26
  }
15
27
  /**
16
28
  * Executes the search and populates results.
@@ -27,18 +39,114 @@ function executeSearch() {
27
39
  }
28
40
  }
29
41
  this.searchResultIndex = -1;
30
- this.setStatusMessage(`Found ${this.searchResults.length} results for "${this.searchQuery}"`);
42
+ if (this.replaceQuery === null) { // Find-only flow
43
+ this.setStatusMessage(`Found ${this.searchResults.length} results for "${this.searchQuery}"`);
44
+ }
31
45
  }
32
46
  /**
33
47
  * Jumps to the next search result.
34
48
  */
35
49
  function findNext() {
50
+ if (this.searchQuery === '') {
51
+ this.enterFindMode();
52
+ return;
53
+ }
54
+ // Execute search if results are not yet populated
55
+ if (this.searchResults.length === 0 && this.searchResultIndex === -1) {
56
+ this.executeSearch();
57
+ }
36
58
  if (this.searchResults.length === 0) {
37
- this.setStatusMessage('No search results');
59
+ this.setStatusMessage('No results found');
60
+ this.mode = 'edit';
38
61
  return;
39
62
  }
40
- this.searchResultIndex = (this.searchResultIndex + 1) % this.searchResults.length;
63
+ this.searchResultIndex++;
64
+ if (this.searchResultIndex >= this.searchResults.length) {
65
+ this.setStatusMessage('End of file reached. Starting from top.');
66
+ this.searchResultIndex = 0;
67
+ }
68
+ const result = this.searchResults[this.searchResultIndex];
69
+ this.jumpToResult(result);
70
+ if (this.replaceQuery !== null) {
71
+ // Replace flow: Enter confirmation step
72
+ this.mode = 'search_confirm';
73
+ this.setStatusMessage(`Replace "${this.searchQuery}"? (y/n/a/q)`);
74
+ }
75
+ else {
76
+ // Find-only flow: Go back to edit
77
+ this.mode = 'edit';
78
+ }
79
+ }
80
+ /**
81
+ * Replaces the current highlighted search result and finds the next one.
82
+ */
83
+ function replaceCurrentAndFindNext() {
84
+ if (this.searchResultIndex === -1 || !this.searchResults[this.searchResultIndex]) {
85
+ this.findNext();
86
+ return;
87
+ }
88
+ const result = this.searchResults[this.searchResultIndex];
89
+ const line = this.lines[result.y];
90
+ const before = line.substring(0, result.x);
91
+ const after = line.substring(result.x + this.searchQuery.length);
92
+ // Use replaceQuery (it's guaranteed to be a string here, not null)
93
+ this.lines[result.y] = before + this.replaceQuery + after;
94
+ this.setDirty();
95
+ // Store current position to find the *next* match after this one
96
+ const replacedResultY = result.y;
97
+ const replacedResultX = result.x;
98
+ // We MUST re-execute search as all indices may have changed
99
+ this.executeSearch();
100
+ this.recalculateVisualRows();
101
+ // Find the next result *after* the one we just replaced
102
+ let nextIndex = -1;
103
+ for (let i = 0; i < this.searchResults.length; i++) {
104
+ const res = this.searchResults[i];
105
+ if (res.y > replacedResultY || (res.y === replacedResultY && res.x > replacedResultX)) {
106
+ nextIndex = i;
107
+ break;
108
+ }
109
+ }
110
+ if (nextIndex === -1) {
111
+ this.setStatusMessage('No more results');
112
+ this.mode = 'edit';
113
+ this.searchResultIndex = -1; // Reset search
114
+ return;
115
+ }
116
+ // Found the next one
117
+ this.searchResultIndex = nextIndex;
41
118
  this.jumpToResult(this.searchResults[this.searchResultIndex]);
119
+ this.mode = 'search_confirm'; // Stay in confirm mode
120
+ this.setStatusMessage(`Replace "${this.searchQuery}"? (y/n/a/q)`);
121
+ }
122
+ /**
123
+ * Replaces all occurrences of the search query.
124
+ */
125
+ function replaceAll() {
126
+ if (this.searchResults.length === 0) {
127
+ this.executeSearch();
128
+ }
129
+ if (this.searchResults.length === 0) {
130
+ this.setStatusMessage('No results found');
131
+ this.mode = 'edit';
132
+ return;
133
+ }
134
+ let count = 0;
135
+ // Iterate backwards to ensure indices remain valid during replacement
136
+ for (let i = this.searchResults.length - 1; i >= 0; i--) {
137
+ const result = this.searchResults[i];
138
+ const line = this.lines[result.y];
139
+ const before = line.substring(0, result.x);
140
+ const after = line.substring(result.x + this.searchQuery.length);
141
+ this.lines[result.y] = before + this.replaceQuery + after;
142
+ count++;
143
+ }
144
+ this.setDirty();
145
+ this.recalculateVisualRows();
146
+ this.mode = 'edit';
147
+ this.searchResults = [];
148
+ this.searchResultIndex = -1;
149
+ this.setStatusMessage(`Replaced ${count} occurrences.`);
42
150
  }
43
151
  /**
44
152
  * Moves cursor and adjusts scroll offset to make the result visible.
@@ -51,8 +159,11 @@ function jumpToResult(result) {
51
159
  this.rowOffset = Math.max(0, visualRowIndex - Math.floor(this.screenRows / 2));
52
160
  }
53
161
  export const searchMethods = {
54
- enterSearchMode,
162
+ enterFindMode,
163
+ enterReplaceMode,
55
164
  executeSearch,
56
165
  findNext,
166
+ replaceCurrentAndFindNext,
167
+ replaceAll,
57
168
  jumpToResult,
58
169
  };
package/dist/types.d.ts CHANGED
@@ -15,4 +15,4 @@ export interface VisualRow {
15
15
  logicalXStart: number;
16
16
  content: string;
17
17
  }
18
- export type EditorMode = 'edit' | 'search';
18
+ export type EditorMode = 'edit' | 'search_find' | 'search_replace' | 'search_confirm' | 'goto_line';
@@ -142,6 +142,16 @@ function emitKey(stream, s) {
142
142
  key.name = parts[1].toLowerCase();
143
143
  key.meta = true;
144
144
  key.shift = /^[A-Z]$/.test(parts[1]);
145
+ // ***** START BUG FIX *****
146
+ // The original library failed to handle any standard printable
147
+ // characters (numbers, symbols) that weren't a-z or A-Z.
148
+ }
149
+ else if (s.length === 1 && s >= ' ' && s <= '~') {
150
+ // Standard printable character (digits, symbols, etc.)
151
+ key.name = s;
152
+ // We can infer shift status for common symbols
153
+ key.shift = '!@#$%^&*()_+{}|:"<>?~'.includes(s);
154
+ // ***** END BUG FIX *****
145
155
  }
146
156
  else if ((parts = functionKeyCodeRe.exec(s))) {
147
157
  // ansi escape sequence
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliedit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A lightweight, raw-mode terminal editor utility for Node.js CLI applications, with line wrapping and undo/redo support.",
5
5
  "repository": "https://github.com/CodeTease/cliedit",
6
6
  "type": "module",
@@ -15,7 +15,8 @@
15
15
  "files": [
16
16
  "dist",
17
17
  "LICENSE",
18
- "README.md"
18
+ "README.md",
19
+ "ACKNOWLEDGEMENTS.md"
19
20
  ],
20
21
  "scripts": {
21
22
  "build": "tsc",