cliedit 0.2.0 → 0.3.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/README.md CHANGED
@@ -14,6 +14,7 @@ It includes line wrapping, visual navigation, smart auto-indentation, undo/redo,
14
14
  - **Undo/Redo:** `Ctrl+Z` / `Ctrl+Y` for persistent history.
15
15
  - **Text Selection:** `Ctrl+Arrow` keys to select text.
16
16
  - **Clipboard Support:** `Ctrl+C` (Copy), `Ctrl+X` (Cut), `Ctrl+V` (Paste) for system clipboard (macOS, Windows, **and Linux** via `xclip`).
17
+ - **Syntax Highlighting:** Lightweight highlighting for Brackets `()` `[]` `{}` and Strings `""` `''`.
17
18
  - **File I/O:** Loads from and saves to the filesystem.
18
19
  - **Search & Replace:** `Ctrl+W` to find text, `Ctrl+R` to find and replace interactively.
19
20
  - **Go to Line:** `Ctrl+L` to quickly jump to a specific line number.
@@ -23,7 +24,7 @@ It includes line wrapping, visual navigation, smart auto-indentation, undo/redo,
23
24
  - **Crash Recovery:** Automatically saves changes to a hidden swap file (e.g. `.filename.swp`) to prevent data loss.
24
25
 
25
26
  ## Installation
26
- ```bash
27
+ ```shell
27
28
  npm install cliedit
28
29
  ````
29
30
 
@@ -31,7 +32,7 @@ npm install cliedit
31
32
 
32
33
  The package exports an `async` function `openEditor` that returns a `Promise`. The promise resolves when the user quits the editor.
33
34
 
34
- ```javascript
35
+ ```typescript
35
36
  import { openEditor } from 'cliedit';
36
37
  import path from 'path';
37
38
 
@@ -64,7 +65,7 @@ getCommitMessage();
64
65
 
65
66
  `cliedit` supports standard input piping. When used in a pipeline, it reads the input content, then re-opens the TTY to allow interactive editing.
66
67
 
67
- ```bash
68
+ ```shell
68
69
  # Edit a file using cat
69
70
  cat README.md | node my-app.js
70
71
 
@@ -101,7 +102,7 @@ If the process crashes or is terminated abruptly, the next time you open the fil
101
102
 
102
103
  The main editor class. You can import this directly if you need to extend or instantiate the editor with custom logic.
103
104
 
104
- ```javascript
105
+ ```typescript
105
106
  import { CliEditor } from 'cliedit';
106
107
  ```
107
108
 
@@ -109,7 +110,7 @@ import { CliEditor } from 'cliedit';
109
110
 
110
111
  Key types are also exported for convenience:
111
112
 
112
- ```javascript
113
+ ```typescript
113
114
  import type {
114
115
  DocumentState,
115
116
  VisualRow,
@@ -11,6 +11,9 @@ export declare const ANSI: {
11
11
  RESET_COLORS: string;
12
12
  ENTER_ALTERNATE_SCREEN: string;
13
13
  EXIT_ALTERNATE_SCREEN: string;
14
+ YELLOW: string;
15
+ CYAN: string;
16
+ GREEN: string;
14
17
  };
15
18
  /**
16
19
  * Key definitions for special keypresses (using Ctrl+ keys for reliable detection).
package/dist/constants.js CHANGED
@@ -12,6 +12,10 @@ export const ANSI = {
12
12
  RESET_COLORS: '\x1b[0m', // Reset colors
13
13
  ENTER_ALTERNATE_SCREEN: '\x1b[?1049h', // Enter alternate screen
14
14
  EXIT_ALTERNATE_SCREEN: '\x1b[?1049l', // Exit alternate screen
15
+ // Syntax Highlighting Colors
16
+ YELLOW: '\x1b[33m',
17
+ CYAN: '\x1b[36m',
18
+ GREEN: '\x1b[32m',
15
19
  };
16
20
  /**
17
21
  * Key definitions for special keypresses (using Ctrl+ keys for reliable detection).
package/dist/editor.d.ts CHANGED
@@ -10,6 +10,7 @@ import { historyMethods } from './editor.history.js';
10
10
  import { ioMethods } from './editor.io.js';
11
11
  import { TKeyHandlingMethods } from './editor.keys.js';
12
12
  import { TSelectionMethods } from './editor.selection.js';
13
+ import { syntaxMethods } from './editor.syntax.js';
13
14
  type TEditingMethods = typeof editingMethods;
14
15
  type TClipboardMethods = typeof clipboardMethods;
15
16
  type TNavigationMethods = typeof navigationMethods;
@@ -17,7 +18,8 @@ type TRenderingMethods = typeof renderingMethods;
17
18
  type TSearchMethods = typeof searchMethods;
18
19
  type THistoryMethods = typeof historyMethods;
19
20
  type TIOMethods = typeof ioMethods;
20
- export interface CliEditor extends TEditingMethods, TClipboardMethods, TNavigationMethods, TRenderingMethods, TSearchMethods, THistoryMethods, TIOMethods, TKeyHandlingMethods, TSelectionMethods {
21
+ type TSyntaxMethods = typeof syntaxMethods;
22
+ export interface CliEditor extends TEditingMethods, TClipboardMethods, TNavigationMethods, TRenderingMethods, TSearchMethods, THistoryMethods, TIOMethods, TKeyHandlingMethods, TSelectionMethods, TSyntaxMethods {
21
23
  }
22
24
  /**
23
25
  * Main editor class managing application state, TTY interaction, and rendering.
@@ -52,7 +54,12 @@ export declare class CliEditor {
52
54
  y: number;
53
55
  x: number;
54
56
  }[];
57
+ searchResultMap: Map<number, Array<{
58
+ start: number;
59
+ end: number;
60
+ }>>;
55
61
  searchResultIndex: number;
62
+ syntaxCache: Map<number, Map<number, string>>;
56
63
  history: HistoryManager;
57
64
  swapManager: SwapManager;
58
65
  isCleanedUp: boolean;
@@ -33,6 +33,7 @@ function insertContentAtCursor(contentLines) {
33
33
  this.cursorX = lastPasteLine.length;
34
34
  }
35
35
  this.setDirty();
36
+ this.invalidateSyntaxCache();
36
37
  this.recalculateVisualRows();
37
38
  }
38
39
  /**
@@ -42,6 +43,7 @@ function insertCharacter(char) {
42
43
  const line = this.lines[this.cursorY] || '';
43
44
  this.lines[this.cursorY] = line.slice(0, this.cursorX) + char + line.slice(this.cursorX);
44
45
  this.cursorX += char.length;
46
+ this.invalidateSyntaxCache();
45
47
  }
46
48
  /**
47
49
  * Inserts a soft tab (using configured tabSize).
@@ -49,6 +51,7 @@ function insertCharacter(char) {
49
51
  function insertSoftTab() {
50
52
  const spaces = ' '.repeat(this.tabSize || 4);
51
53
  this.insertCharacter(spaces);
54
+ // invalidation handled in insertCharacter
52
55
  }
53
56
  /**
54
57
  * Inserts a new line, splitting the current line at the cursor position.
@@ -66,6 +69,7 @@ function insertNewLine() {
66
69
  this.cursorY++;
67
70
  this.cursorX = indent.length; // Move cursor to end of indent
68
71
  this.setDirty();
72
+ this.invalidateSyntaxCache();
69
73
  }
70
74
  /**
71
75
  * Deletes the character before the cursor, or joins the current line with the previous one.
@@ -90,6 +94,7 @@ function deleteBackward() {
90
94
  this.cursorX = 0;
91
95
  }
92
96
  this.setDirty();
97
+ this.invalidateSyntaxCache();
93
98
  }
94
99
  /**
95
100
  * Deletes the character after the cursor, or joins the current line with the next one.
@@ -110,6 +115,7 @@ function deleteForward() {
110
115
  this.cursorX = 0;
111
116
  }
112
117
  this.setDirty();
118
+ this.invalidateSyntaxCache();
113
119
  }
114
120
  /**
115
121
  * Handles auto-pairing of brackets and quotes.
@@ -38,6 +38,7 @@ function undo() {
38
38
  if (state) {
39
39
  this.loadState(state);
40
40
  this.setDirty();
41
+ this.invalidateSyntaxCache();
41
42
  this.setStatusMessage('Undo successful');
42
43
  }
43
44
  else {
@@ -52,6 +53,7 @@ function redo() {
52
53
  if (state) {
53
54
  this.loadState(state);
54
55
  this.setDirty();
56
+ this.invalidateSyntaxCache();
55
57
  this.setStatusMessage('Redo successful');
56
58
  }
57
59
  else {
package/dist/editor.js CHANGED
@@ -14,6 +14,7 @@ import { historyMethods } from './editor.history.js';
14
14
  import { ioMethods } from './editor.io.js';
15
15
  import { keyHandlingMethods } from './editor.keys.js';
16
16
  import { selectionMethods } from './editor.selection.js';
17
+ import { syntaxMethods } from './editor.syntax.js';
17
18
  const DEFAULT_STATUS = 'HELP: Ctrl+S = Save | Ctrl+Q = Quit | Ctrl+W = Find | Ctrl+R = Replace | Ctrl+L = Go to Line';
18
19
  /**
19
20
  * Main editor class managing application state, TTY interaction, and rendering.
@@ -41,7 +42,10 @@ export class CliEditor {
41
42
  this.replaceQuery = null; // null = Find mode, string = Replace mode
42
43
  this.goToLineQuery = ''; // For Go to Line prompt
43
44
  this.searchResults = [];
45
+ // Map<lineNumber, Array<{ start, end }>> for fast rendering lookup
46
+ this.searchResultMap = new Map();
44
47
  this.searchResultIndex = -1;
48
+ this.syntaxCache = new Map();
45
49
  this.isCleanedUp = false;
46
50
  this.resolvePromise = null;
47
51
  this.rejectPromise = null;
@@ -78,7 +82,8 @@ export class CliEditor {
78
82
  process.stdout.removeAllListeners('resize');
79
83
  // 2. (FIX GHOST TUI) Write exit sequence and use callback to ensure it's written
80
84
  // before Node.js fully releases the TTY.
81
- process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.MOVE_CURSOR_TOP_LEFT + ANSI.SHOW_CURSOR + ANSI.EXIT_ALTERNATE_SCREEN, () => {
85
+ // Disable mouse tracking (1000 and 1006)
86
+ process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.MOVE_CURSOR_TOP_LEFT + ANSI.SHOW_CURSOR + ANSI.EXIT_ALTERNATE_SCREEN + '\x1b[?1000l' + '\x1b[?1006l', () => {
82
87
  // 3. Disable TTY raw mode and pause stdin after screen is cleared
83
88
  if (this.inputStream.setRawMode) {
84
89
  this.inputStream.setRawMode(false);
@@ -109,8 +114,8 @@ export class CliEditor {
109
114
  }
110
115
  this.updateScreenSize();
111
116
  this.recalculateVisualRows();
112
- // Enter alternate screen and hide cursor
113
- process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN);
117
+ // Enter alternate screen and hide cursor + Enable SGR Mouse (1006) and Button Event (1000)
118
+ process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN + '\x1b[?1000h' + '\x1b[?1006h');
114
119
  if (this.inputStream.setRawMode) {
115
120
  this.inputStream.setRawMode(true);
116
121
  }
@@ -142,3 +147,4 @@ Object.assign(CliEditor.prototype, historyMethods);
142
147
  Object.assign(CliEditor.prototype, ioMethods);
143
148
  Object.assign(CliEditor.prototype, keyHandlingMethods);
144
149
  Object.assign(CliEditor.prototype, selectionMethods);
150
+ Object.assign(CliEditor.prototype, syntaxMethods);
@@ -85,6 +85,11 @@ function handleKeypressEvent(ch, key) {
85
85
  keyName = 'ALT_LEFT';
86
86
  else if (key.meta && key.name === 'right')
87
87
  keyName = 'ALT_RIGHT';
88
+ // Handle Mouse Scroll events explicitly
89
+ else if (key.name === 'scrollup')
90
+ keyName = 'SCROLL_UP';
91
+ else if (key.name === 'scrolldown')
92
+ keyName = 'SCROLL_DOWN';
88
93
  else
89
94
  keyName = key.sequence;
90
95
  }
@@ -118,7 +123,55 @@ function handleKeypressEvent(ch, key) {
118
123
  return;
119
124
  }
120
125
  // 5. Xử lý tất cả các phím lệnh/chỉnh sửa khác
121
- edited = this.handleEditKeys(keyName || ch);
126
+ if (keyName === 'SCROLL_UP') {
127
+ const scrollAmount = 3;
128
+ this.rowOffset = Math.max(0, this.rowOffset - scrollAmount);
129
+ // Adjust cursor if it falls out of view (below the viewport)
130
+ // Actually if we scroll UP, the viewport moves UP. The cursor might be BELOW the viewport.
131
+ // Wait, scroll UP means viewing lines ABOVE. Viewport index decreases.
132
+ // Cursor (if previously in view) might now be >= rowOffset + screenRows.
133
+ // We need to ensure cursor is within [rowOffset, rowOffset + screenRows - 1]
134
+ // But verify after setting rowOffset.
135
+ const currentVisualRow = this.findCurrentVisualRowIndex();
136
+ const bottomEdge = this.rowOffset + this.screenRows - 1;
137
+ if (currentVisualRow > bottomEdge) {
138
+ const targetRow = this.visualRows[bottomEdge];
139
+ this.cursorY = targetRow.logicalY;
140
+ this.cursorX = targetRow.logicalXStart;
141
+ }
142
+ else if (currentVisualRow < this.rowOffset) {
143
+ // Should not happen when scrolling up (moving viewport up), unless cursor was already above?
144
+ // If we scroll up, rowOffset decreases. Current row stays same.
145
+ // So current row > new rowOffset.
146
+ // It might be > bottomEdge.
147
+ }
148
+ // However, to be safe against 'scroll' method resetting it:
149
+ // The 'scroll' method checks:
150
+ // if (currentVisualRow < this.rowOffset) -> this.rowOffset = currentVisualRow
151
+ // if (currentVisualRow >= this.rowOffset + this.screenRows) -> this.rowOffset = ...
152
+ // So we MUST move cursor inside the new viewport.
153
+ if (currentVisualRow > bottomEdge) {
154
+ const targetRow = this.visualRows[bottomEdge];
155
+ this.cursorY = targetRow.logicalY;
156
+ this.cursorX = targetRow.logicalXStart;
157
+ }
158
+ }
159
+ else if (keyName === 'SCROLL_DOWN') {
160
+ const scrollAmount = 3;
161
+ const maxOffset = Math.max(0, this.visualRows.length - this.screenRows);
162
+ this.rowOffset = Math.min(maxOffset, this.rowOffset + scrollAmount);
163
+ // Scroll DOWN means viewport index increases.
164
+ // Cursor might be ABOVE the new viewport (currentVisualRow < rowOffset).
165
+ const currentVisualRow = this.findCurrentVisualRowIndex();
166
+ if (currentVisualRow < this.rowOffset) {
167
+ const targetRow = this.visualRows[this.rowOffset];
168
+ this.cursorY = targetRow.logicalY;
169
+ this.cursorX = targetRow.logicalXStart;
170
+ }
171
+ }
172
+ else {
173
+ edited = this.handleEditKeys(keyName || ch);
174
+ }
122
175
  }
123
176
  // 6. Cập nhật Trạng thái và Render
124
177
  if (edited) {
@@ -130,6 +183,7 @@ function handleKeypressEvent(ch, key) {
130
183
  }
131
184
  }
132
185
  function handleAltArrows(keyName) {
186
+ this.clearSearchResults(); // Clear highlights on smart navigation
133
187
  if (keyName === 'ALT_LEFT')
134
188
  this.moveCursorByWord('left');
135
189
  else if (keyName === 'ALT_RIGHT')
@@ -147,6 +201,7 @@ function handleEditKeys(key) {
147
201
  ].includes(key);
148
202
  if (isNavigation) {
149
203
  this.cancelSelection();
204
+ this.clearSearchResults(); // Clear highlights on navigation
150
205
  if (this.isMessageCustom) {
151
206
  this.setStatusMessage(this.DEFAULT_STATUS, 0);
152
207
  }
@@ -189,9 +244,11 @@ function handleEditKeys(key) {
189
244
  return false;
190
245
  // --- Editing ---
191
246
  case KEYS.ENTER:
247
+ this.clearSearchResults();
192
248
  this.insertNewLine();
193
249
  return true;
194
250
  case KEYS.BACKSPACE:
251
+ this.clearSearchResults();
195
252
  // Handle auto-pair deletion
196
253
  const line = this.lines[this.cursorY] || '';
197
254
  const charBefore = line[this.cursorX - 1];
@@ -212,12 +269,14 @@ function handleEditKeys(key) {
212
269
  }
213
270
  return true;
214
271
  case KEYS.DELETE:
272
+ this.clearSearchResults();
215
273
  if (this.selectionAnchor)
216
274
  this.deleteSelectedText();
217
275
  else
218
276
  this.deleteForward();
219
277
  return true;
220
278
  case KEYS.TAB:
279
+ this.clearSearchResults();
221
280
  this.insertSoftTab();
222
281
  return true;
223
282
  // --- Search & History ---
@@ -273,6 +332,7 @@ function handleEditKeys(key) {
273
332
  // Xử lý Ký tự in được
274
333
  default:
275
334
  if (key.length === 1 && key >= ' ' && key <= '~') {
335
+ this.clearSearchResults();
276
336
  this.handleCharacterKey(key);
277
337
  return true;
278
338
  }
@@ -41,6 +41,11 @@ function render() {
41
41
  const displayX = cursorVisualX + this.gutterWidth;
42
42
  const displayY = this.screenStartRow + (currentVisualRowIndex - this.rowOffset);
43
43
  const selectionRange = this.getNormalizedSelection();
44
+ // Scrollbar calculations
45
+ const totalLines = this.visualRows.length;
46
+ const showScrollbar = totalLines > this.screenRows;
47
+ const thumbHeight = showScrollbar ? Math.max(1, Math.floor((this.screenRows / totalLines) * this.screenRows)) : 0;
48
+ const thumbStart = showScrollbar ? Math.floor((this.rowOffset / totalLines) * this.screenRows) : 0;
44
49
  // Draw visual rows
45
50
  for (let y = 0; y < this.screenRows; y++) {
46
51
  const visualRowIndex = y + this.rowOffset;
@@ -58,6 +63,9 @@ function render() {
58
63
  : ' '.padStart(this.gutterWidth - 2, ' ') + ' | ';
59
64
  buffer += lineNumber;
60
65
  let lineContent = row.content;
66
+ // Retrieve syntax color map for the full logical line
67
+ // We pass the full line content because the scanner needs context
68
+ const syntaxColorMap = this.getLineSyntaxColor(row.logicalY, this.lines[row.logicalY]);
61
69
  // 2. Draw Content (Character by Character for selection/cursor)
62
70
  for (let i = 0; i < lineContent.length; i++) {
63
71
  const char = lineContent[i];
@@ -66,10 +74,24 @@ function render() {
66
74
  const isCursorPosition = (visualRowIndex === currentVisualRowIndex && i === cursorVisualX);
67
75
  const isSelected = selectionRange && this.isPositionInSelection(logicalY, logicalX, selectionRange);
68
76
  // Highlight search result under cursor
69
- const isSearchResult = (this.searchResultIndex !== -1 &&
77
+ // Check if this character is part of ANY search result
78
+ let isGlobalSearchResult = false;
79
+ if (this.searchResultMap.has(logicalY)) {
80
+ const matches = this.searchResultMap.get(logicalY);
81
+ for (const match of matches) {
82
+ if (logicalX >= match.start && logicalX < match.end) {
83
+ isGlobalSearchResult = true;
84
+ break;
85
+ }
86
+ }
87
+ }
88
+ // Check if this character is part of the CURRENTLY SELECTED search result
89
+ const isCurrentSearchResult = (this.searchResultIndex !== -1 &&
70
90
  this.searchResults[this.searchResultIndex]?.y === logicalY &&
71
91
  logicalX >= this.searchResults[this.searchResultIndex]?.x &&
72
92
  logicalX < (this.searchResults[this.searchResultIndex]?.x + this.searchQuery.length));
93
+ // Syntax highlight color
94
+ const syntaxColor = syntaxColorMap.get(logicalX);
73
95
  if (isSelected) {
74
96
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
75
97
  }
@@ -77,10 +99,18 @@ function render() {
77
99
  // Cursor is a single inverted character if not already covered by selection
78
100
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
79
101
  }
80
- else if (isSearchResult) {
81
- // Highlight search result
102
+ else if (isCurrentSearchResult) {
103
+ // Selected Match: Invert + Underline (if supported) or just Invert
104
+ buffer += ANSI.INVERT_COLORS + '\x1b[4m' + char + ANSI.RESET_COLORS;
105
+ }
106
+ else if (isGlobalSearchResult) {
107
+ // Global Match: Invert only
82
108
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
83
109
  }
110
+ else if (syntaxColor) {
111
+ // Apply syntax color
112
+ buffer += syntaxColor + char + ANSI.RESET_COLORS;
113
+ }
84
114
  else {
85
115
  buffer += char;
86
116
  }
@@ -93,6 +123,13 @@ function render() {
93
123
  }
94
124
  buffer += `${ANSI.CLEAR_LINE}`;
95
125
  }
126
+ // Draw Scrollbar (Phase 2)
127
+ if (showScrollbar) {
128
+ const isThumb = y >= thumbStart && y < thumbStart + thumbHeight;
129
+ const scrollChar = isThumb ? '┃' : '│';
130
+ // Move to last column and draw
131
+ buffer += `\x1b[${this.screenStartRow + y};${this.screenCols}H${ANSI.RESET_COLORS}${scrollChar}`;
132
+ }
96
133
  }
97
134
  // Draw status bar
98
135
  buffer += `\x1b[${this.screenRows + this.screenStartRow};1H`;
@@ -33,6 +33,10 @@ declare function jumpToResult(this: CliEditor, result: {
33
33
  y: number;
34
34
  x: number;
35
35
  }): void;
36
+ /**
37
+ * Clears the current search results and highlights.
38
+ */
39
+ declare function clearSearchResults(this: CliEditor): void;
36
40
  export declare const searchMethods: {
37
41
  enterFindMode: typeof enterFindMode;
38
42
  enterReplaceMode: typeof enterReplaceMode;
@@ -41,5 +45,6 @@ export declare const searchMethods: {
41
45
  replaceCurrentAndFindNext: typeof replaceCurrentAndFindNext;
42
46
  replaceAll: typeof replaceAll;
43
47
  jumpToResult: typeof jumpToResult;
48
+ clearSearchResults: typeof clearSearchResults;
44
49
  };
45
50
  export {};
@@ -29,13 +29,20 @@ function enterReplaceMode() {
29
29
  */
30
30
  function executeSearch() {
31
31
  this.searchResults = [];
32
+ this.searchResultMap.clear();
32
33
  if (this.searchQuery === '')
33
34
  return;
35
+ const queryLen = this.searchQuery.length;
34
36
  for (let y = 0; y < this.lines.length; y++) {
35
37
  const line = this.lines[y];
36
38
  let index = -1;
39
+ const lineMatches = [];
37
40
  while ((index = line.indexOf(this.searchQuery, index + 1)) !== -1) {
38
41
  this.searchResults.push({ y, x: index });
42
+ lineMatches.push({ start: index, end: index + queryLen });
43
+ }
44
+ if (lineMatches.length > 0) {
45
+ this.searchResultMap.set(y, lineMatches);
39
46
  }
40
47
  }
41
48
  this.searchResultIndex = -1;
@@ -158,6 +165,14 @@ function jumpToResult(result) {
158
165
  // Calculate new scroll offset to center the result visually
159
166
  this.rowOffset = Math.max(0, visualRowIndex - Math.floor(this.screenRows / 2));
160
167
  }
168
+ /**
169
+ * Clears the current search results and highlights.
170
+ */
171
+ function clearSearchResults() {
172
+ this.searchResults = [];
173
+ this.searchResultMap.clear();
174
+ this.searchResultIndex = -1;
175
+ }
161
176
  export const searchMethods = {
162
177
  enterFindMode,
163
178
  enterReplaceMode,
@@ -166,4 +181,5 @@ export const searchMethods = {
166
181
  replaceCurrentAndFindNext,
167
182
  replaceAll,
168
183
  jumpToResult,
184
+ clearSearchResults,
169
185
  };
@@ -0,0 +1,16 @@
1
+ import { CliEditor } from './editor.js';
2
+ /**
3
+ * Single-pass character scanner to generate a color map for a line.
4
+ * Implements "Poor Man's Syntax Highlighting" focusing on Brackets and Quotes.
5
+ */
6
+ declare function getLineSyntaxColor(this: CliEditor, lineIndex: number, lineContent: string): Map<number, string>;
7
+ /**
8
+ * Invalidates the syntax highlighting cache.
9
+ * Clears the entire cache to be safe and simple ("Poor Man's" approach).
10
+ */
11
+ declare function invalidateSyntaxCache(this: CliEditor): void;
12
+ export declare const syntaxMethods: {
13
+ getLineSyntaxColor: typeof getLineSyntaxColor;
14
+ invalidateSyntaxCache: typeof invalidateSyntaxCache;
15
+ };
16
+ export {};
@@ -0,0 +1,84 @@
1
+ import { ANSI } from './constants.js';
2
+ // Syntax colors
3
+ const COLOR_BRACKET_1 = ANSI.YELLOW;
4
+ const COLOR_STRING = ANSI.GREEN;
5
+ // State constants
6
+ const STATE_NORMAL = 0;
7
+ const STATE_IN_STRING_SINGLE = 1; // '
8
+ const STATE_IN_STRING_DOUBLE = 2; // "
9
+ /**
10
+ * Single-pass character scanner to generate a color map for a line.
11
+ * Implements "Poor Man's Syntax Highlighting" focusing on Brackets and Quotes.
12
+ */
13
+ function getLineSyntaxColor(lineIndex, lineContent) {
14
+ // Check cache first
15
+ if (this.syntaxCache.has(lineIndex)) {
16
+ return this.syntaxCache.get(lineIndex);
17
+ }
18
+ const colorMap = new Map();
19
+ let state = STATE_NORMAL;
20
+ for (let i = 0; i < lineContent.length; i++) {
21
+ const char = lineContent[i];
22
+ if (state === STATE_NORMAL) {
23
+ if (char === '"') {
24
+ state = STATE_IN_STRING_DOUBLE;
25
+ colorMap.set(i, COLOR_STRING);
26
+ }
27
+ else if (char === "'") {
28
+ state = STATE_IN_STRING_SINGLE;
29
+ colorMap.set(i, COLOR_STRING);
30
+ }
31
+ else if ('()[]{}'.includes(char)) {
32
+ // Alternate bracket colors for fun, or just use one
33
+ colorMap.set(i, COLOR_BRACKET_1);
34
+ }
35
+ }
36
+ else if (state === STATE_IN_STRING_DOUBLE) {
37
+ colorMap.set(i, COLOR_STRING);
38
+ if (char === '"') {
39
+ // Check if escaped
40
+ let backslashCount = 0;
41
+ for (let j = i - 1; j >= 0; j--) {
42
+ if (lineContent[j] === '\\')
43
+ backslashCount++;
44
+ else
45
+ break;
46
+ }
47
+ // Even backslashes => not escaped (e.g., \\" is literal backslash then quote)
48
+ // Odd backslashes => escaped (e.g., \" is literal quote)
49
+ if (backslashCount % 2 === 0) {
50
+ state = STATE_NORMAL;
51
+ }
52
+ }
53
+ }
54
+ else if (state === STATE_IN_STRING_SINGLE) {
55
+ colorMap.set(i, COLOR_STRING);
56
+ if (char === "'") {
57
+ // Check if escaped
58
+ let backslashCount = 0;
59
+ for (let j = i - 1; j >= 0; j--) {
60
+ if (lineContent[j] === '\\')
61
+ backslashCount++;
62
+ else
63
+ break;
64
+ }
65
+ if (backslashCount % 2 === 0) {
66
+ state = STATE_NORMAL;
67
+ }
68
+ }
69
+ }
70
+ }
71
+ this.syntaxCache.set(lineIndex, colorMap);
72
+ return colorMap;
73
+ }
74
+ /**
75
+ * Invalidates the syntax highlighting cache.
76
+ * Clears the entire cache to be safe and simple ("Poor Man's" approach).
77
+ */
78
+ function invalidateSyntaxCache() {
79
+ this.syntaxCache.clear();
80
+ }
81
+ export const syntaxMethods = {
82
+ getLineSyntaxColor,
83
+ invalidateSyntaxCache
84
+ };
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Định nghĩa giao diện cho một sự kiện keypress.
3
- * Được điều chỉnh từ file editor.ts.
2
+ * Defines the interface for a keypress event.
3
+ * Adapted from the editor.ts file.
4
4
  */
5
5
  export interface KeypressEvent {
6
6
  name?: string;
@@ -11,7 +11,7 @@ export interface KeypressEvent {
11
11
  code?: string;
12
12
  }
13
13
  /**
14
- * Hàm chính, chấp nhận một Readable Stream làm cho nó
15
- * phát ra sự kiện "keypress".
14
+ * Main function, accepts a Readable Stream and makes it
15
+ * emit "keypress" events.
16
16
  */
17
17
  export default function keypress(stream: NodeJS.ReadStream): void;
@@ -1,11 +1,11 @@
1
1
  // src/vendor/keypress.ts
2
- // Đây phiên bản "vendored" của thư viện 'keypress' (0.2.1)
3
- // được chuyển đổi sang TypeScript loại bỏ hỗ trợ chuột
4
- // để tích hợp trực tiếp vào cliedit.
2
+ // This is a "vendored" version of the 'keypress' library (0.2.1)
3
+ // converted to TypeScript and stripped of mouse support
4
+ // to be integrated directly into cliedit.
5
5
  import { EventEmitter } from 'events';
6
6
  import { StringDecoder } from 'string_decoder';
7
7
  /**
8
- * Hàm polyfill cho `EventEmitter.listenerCount()`, để tương thích ngược.
8
+ * Polyfill for `EventEmitter.listenerCount()`, for backward compatibility.
9
9
  */
10
10
  let listenerCount = EventEmitter.listenerCount;
11
11
  if (!listenerCount) {
@@ -14,18 +14,19 @@ if (!listenerCount) {
14
14
  };
15
15
  }
16
16
  /**
17
- * Regexes dùng để phân tích escape code của ansi
17
+ * Regexes used to parse ansi escape codes.
18
18
  */
19
19
  const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/;
20
20
  const functionKeyCodeRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/;
21
+ const mouseSgrRe = /^\x1b\[<(\d+);(\d+);(\d+)([mM])/;
21
22
  /**
22
- * Hàm chính, chấp nhận một Readable Stream làm cho nó
23
- * phát ra sự kiện "keypress".
23
+ * Main function, accepts a Readable Stream and makes it
24
+ * emit "keypress" events.
24
25
  */
25
26
  export default function keypress(stream) {
26
27
  if (isEmittingKeypress(stream))
27
28
  return;
28
- // Gắn decoder vào stream để theo dõi
29
+ // Attach decoder to the stream to monitor data
29
30
  stream._keypressDecoder = new StringDecoder('utf8');
30
31
  function onData(b) {
31
32
  if (listenerCount(stream, 'keypress') > 0) {
@@ -34,7 +35,7 @@ export default function keypress(stream) {
34
35
  emitKey(stream, r);
35
36
  }
36
37
  else {
37
- // Không ai đang nghe, gỡ bỏ listener
38
+ // No one is listening, remove listener
38
39
  stream.removeListener('data', onData);
39
40
  stream.on('newListener', onNewListener);
40
41
  }
@@ -53,23 +54,23 @@ export default function keypress(stream) {
53
54
  }
54
55
  }
55
56
  /**
56
- * Kiểm tra xem stream đã phát ra sự kiện "keypress" hay chưa.
57
+ * Checks if the stream has already emitted the "keypress" event.
57
58
  */
58
59
  function isEmittingKeypress(stream) {
59
60
  let rtn = !!stream._keypressDecoder;
60
61
  if (!rtn) {
61
- // XXX: Đối với các phiên bản node cũ, chúng ta muốn xóa các
62
- // listener "data" "newListener" hiện chúng sẽ không
63
- // bao gồm các phần mở rộng của module này (như "mousepress" đã bị loại bỏ).
62
+ // XXX: For older node versions, we want to remove existing
63
+ // "data" and "newListener" listeners because they won't
64
+ // include extensions from this module (like "mousepress" which was removed).
64
65
  stream.listeners('data').slice(0).forEach(function (l) {
65
66
  if (l.name === 'onData' && /emitKey/.test(l.toString())) {
66
- // FIX TS2769: Ép kiểu 'l' thành kiểu listener hợp lệ
67
+ // FIX TS2769: Cast 'l' to a valid listener type
67
68
  stream.removeListener('data', l);
68
69
  }
69
70
  });
70
71
  stream.listeners('newListener').slice(0).forEach(function (l) {
71
72
  if (l.name === 'onNewListener' && /keypress/.test(l.toString())) {
72
- // FIX TS2769: Ép kiểu 'l' thành kiểu listener hợp lệ
73
+ // FIX TS2769: Cast 'l' to a valid listener type
73
74
  stream.removeListener('newListener', l);
74
75
  }
75
76
  });
@@ -77,8 +78,8 @@ function isEmittingKeypress(stream) {
77
78
  return rtn;
78
79
  }
79
80
  /**
80
- * Phần code bên dưới được lấy từ module `readline.js` của node-core
81
- * đã được chuyển đổi sang TypeScript.
81
+ * The code below is taken from node-core's `readline.js` module
82
+ * and has been converted to TypeScript.
82
83
  */
83
84
  function emitKey(stream, s) {
84
85
  let ch;
@@ -90,16 +91,16 @@ function emitKey(stream, s) {
90
91
  sequence: s,
91
92
  };
92
93
  let parts;
93
- // Cảnh báo: Block `Buffer.isBuffer(s)` đã bị loại bỏ.
94
- // Lý do: `onData` luôn gọi `emitKey` với một string (kết quả từ StringDecoder).
95
- // Block đệ quy (paste) cũng gọi với string.
96
- // Vì vậy, `s` luôn string.
94
+ // Warning: The `Buffer.isBuffer(s)` block has been removed.
95
+ // Reason: `onData` always calls `emitKey` with a string (result from StringDecoder).
96
+ // The recursive block (paste) also calls with a string.
97
+ // Therefore, `s` is always a string.
97
98
  if (s === '\r') {
98
99
  // carriage return
99
100
  key.name = 'return';
100
101
  }
101
102
  else if (s === '\n') {
102
- // enter, đáng lẽ phải linefeed
103
+ // enter, should have been linefeed
103
104
  key.name = 'enter';
104
105
  }
105
106
  else if (s === '\t') {
@@ -110,7 +111,7 @@ function emitKey(stream, s) {
110
111
  s === '\x7f' ||
111
112
  s === '\x1b\x7f' ||
112
113
  s === '\x1b\b') {
113
- // backspace hoặc ctrl+h
114
+ // backspace or ctrl+h
114
115
  key.name = 'backspace';
115
116
  key.meta = s.charAt(0) === '\x1b';
116
117
  }
@@ -155,20 +156,20 @@ function emitKey(stream, s) {
155
156
  }
156
157
  else if ((parts = functionKeyCodeRe.exec(s))) {
157
158
  // ansi escape sequence
158
- // Lắp ráp lại key code, bỏ qua \x1b đứng đầu,
159
- // bitflag của phím bổ trợ và bất kỳ chuỗi "1;" vô nghĩa nào
159
+ // Reassemble key code, ignoring leading \x1b,
160
+ // modifier bitflag, and any meaningless "1;" strings
160
161
  const code = (parts[1] || '') +
161
162
  (parts[2] || '') +
162
163
  (parts[4] || '') +
163
164
  (parts[6] || '');
164
- // FIX TS2362: Chuyển đổi (parts[...]) sang number bằng parseInt
165
+ // FIX TS2362: Convert (parts[...]) to number using parseInt
165
166
  const modifier = parseInt(parts[3] || parts[5] || '1', 10) - 1;
166
- // Phân tích phím bổ trợ
167
+ // Parse modifier keys
167
168
  key.ctrl = !!(modifier & 4);
168
169
  key.meta = !!(modifier & 10);
169
170
  key.shift = !!(modifier & 1);
170
171
  key.code = code;
171
- // Phân tích chính phím đó
172
+ // Parse the key itself
172
173
  switch (code) {
173
174
  /* xterm/gnome ESC O letter */
174
175
  case 'OP':
@@ -414,15 +415,40 @@ function emitKey(stream, s) {
414
415
  }
415
416
  }
416
417
  else if (s.length > 1 && s[0] !== '\x1b') {
417
- // Nhận được một chuỗi tự dài hơn một.
418
- // thể paste, không phải control sequence.
418
+ // Received a string longer than one character.
419
+ // Could be a paste, since it's not a control sequence.
419
420
  for (const c of s) {
420
421
  emitKey(stream, c);
421
422
  }
422
423
  return;
423
424
  }
424
- // XXX: code phân tích "mouse" đã bị XÓA theo yêu cầu.
425
- // Không phát ra key nếu không tìm thấy tên
425
+ // Mouse handling (SGR 1006)
426
+ if ((parts = mouseSgrRe.exec(s))) {
427
+ // SGR Mode: \x1b[< b; x; y M/m
428
+ // b: button code
429
+ // x, y: coordinates (1-based)
430
+ // M/m: Press/Release
431
+ const b = parseInt(parts[1], 10);
432
+ const x = parseInt(parts[2], 10);
433
+ const y = parseInt(parts[3], 10);
434
+ const type = parts[4]; // M=press, m=release
435
+ key.name = 'mouse';
436
+ key.ctrl = false;
437
+ key.meta = false;
438
+ key.shift = false;
439
+ // Check for Scroll (Button 64 = Up, 65 = Down)
440
+ if (b === 64) {
441
+ key.name = 'scrollup';
442
+ key.code = 'scrollup';
443
+ }
444
+ else if (b === 65) {
445
+ key.name = 'scrolldown';
446
+ key.code = 'scrolldown';
447
+ }
448
+ // We can handle click here if needed (b=0 left, b=1 middle, b=2 right)
449
+ // but for now only scroll is requested.
450
+ }
451
+ // Don't emit key if name is not found
426
452
  if (key.name === undefined) {
427
453
  return; // key = undefined;
428
454
  }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "cliedit",
3
- "version": "0.2.0",
3
+ "version": "0.3.5",
4
4
  "description": "A lightweight, raw-mode terminal editor utility for Node.js CLI applications, with line wrapping and undo/redo support.",
5
- "repository": "https://github.com/CodeTease/cliedit",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/CodeTease/cliedit.git"
8
+ },
6
9
  "type": "module",
7
10
  "main": "dist/index.js",
8
11
  "types": "dist/index.d.ts",
@@ -21,7 +24,7 @@
21
24
  "scripts": {
22
25
  "build": "tsc",
23
26
  "prepublishOnly": "npm run build",
24
- "demo": "npm run build && cross-env NODE_ENV=development node dist/demo.js",
27
+ "demo": "npm run build && cross-env NODE_ENV=development tsx src/demo.ts",
25
28
  "test": "echo \"Error: no test specified\" && exit 1"
26
29
  },
27
30
  "keywords": [
@@ -35,10 +38,9 @@
35
38
  "author": "CodeTease",
36
39
  "license": "MIT",
37
40
  "devDependencies": {
38
- "@types/node": "^20.12.12",
39
- "cross-env": "^7.0.3",
40
- "typescript": "^5.4.5"
41
- },
42
- "dependencies": {
41
+ "@types/node": "^22",
42
+ "cross-env": "^7",
43
+ "tsx": "^4",
44
+ "typescript": "^5"
43
45
  }
44
- }
46
+ }