caroushell 0.1.20 → 0.1.23

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
@@ -3,8 +3,8 @@
3
3
  [![npm version](https://img.shields.io/npm/v/caroushell.svg)](https://www.npmjs.com/package/caroushell)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/caroushell.svg)](https://www.npmjs.com/package/caroushell)
5
5
 
6
- Caroushell is an interactive terminal carousel that suggests commands from your
7
- history, and AI suggestions as you type.
6
+ Caroushell is kind of like `bash` but you see history and AI suggestions as you
7
+ type.
8
8
 
9
9
  ## Features
10
10
 
@@ -12,9 +12,6 @@ history, and AI suggestions as you type.
12
12
  - The bottom panel of the carousel shows AI-generated command suggestions.
13
13
  - Go up and down the carousel with arrow keys.
14
14
  - Press `Enter` to run the highlighted command.
15
- - Logs activity under `~/.caroushell/logs` for easy troubleshooting.
16
- - Extensible config file (`~/.caroushell/config.toml`) so you can point the CLI
17
- at different AI providers.
18
15
 
19
16
  ## UI
20
17
 
@@ -42,12 +39,13 @@ It would look like this:
42
39
 
43
40
  ![Caroushell ai suggestion for ffmpeg slowmo](docs/assets/demo.gif)
44
41
 
45
- ## Requirements
42
+ ## Setup
46
43
 
47
44
  - Node.js 18 or newer.
48
45
  - On first launch Caroushell will prompt you for an OpenAI-compatible endpoint
49
46
  URL, API key, and model name, then store them in `~/.caroushell/config.toml`.
50
47
  - You can also create the file manually:
48
+ - Logs are at `~/.caroushell/logs` for easy troubleshooting.
51
49
 
52
50
  ```toml
53
51
  apiUrl = "https://openrouter.ai/api/v1"
@@ -80,7 +80,7 @@ async function generateContent(prompt, options) {
80
80
  async function listModels(apiUrl, apiKey) {
81
81
  const url = apiUrl.replace("/chat/completions", "") + "/models";
82
82
  const res = await fetch(url, { headers: headers(apiKey) });
83
- const models = await res.json();
83
+ const models = (await res.json());
84
84
  return models.data.map((m) => m.id);
85
85
  }
86
86
  function headers(apiKey) {
package/dist/app.js CHANGED
@@ -9,6 +9,9 @@ const ai_suggester_1 = require("./ai-suggester");
9
9
  const file_suggester_1 = require("./file-suggester");
10
10
  const spawner_1 = require("./spawner");
11
11
  const logs_1 = require("./logs");
12
+ function collapseLineContinuations(input) {
13
+ return input.replace(/\\\r?\n/g, "");
14
+ }
12
15
  class App {
13
16
  constructor(deps = {}) {
14
17
  this.usingFileSuggestions = false;
@@ -63,17 +66,12 @@ class App {
63
66
  if (this.tryAcceptHighlightedFileSuggestion()) {
64
67
  return;
65
68
  }
66
- const cmd = this.carousel.getCurrentRow().trim();
67
- this.carousel.setInputBuffer("", 0);
68
- await this.runCommand(cmd);
69
- // Carousel should point to the prompt
70
- this.carousel.resetIndex();
71
- // After arbitrary output, reset render block tracking
72
- this.terminal.resetBlockTracking();
73
- // Render the prompt, without this we'd wait for the suggestions to call render
74
- // and it would appear slow
75
- this.render();
76
- this.queueUpdateSuggestions();
69
+ if (this.carousel.isPromptRowSelected()) {
70
+ await this.enterOnPrompt();
71
+ }
72
+ else {
73
+ await this.enterOnSuggestion();
74
+ }
77
75
  },
78
76
  char: (evt) => {
79
77
  this.carousel.insertAtCursor(evt.sequence);
@@ -83,10 +81,20 @@ class App {
83
81
  this.queueUpdateSuggestions();
84
82
  },
85
83
  up: () => {
84
+ if (this.carousel.shouldUpMoveMultilineCursor()) {
85
+ this.carousel.moveMultilineCursorUp();
86
+ this.render();
87
+ return;
88
+ }
86
89
  this.carousel.up();
87
90
  this.render();
88
91
  },
89
92
  down: () => {
93
+ if (this.carousel.shouldDownMoveMultilineCursor()) {
94
+ this.carousel.moveMultilineCursorDown();
95
+ this.render();
96
+ return;
97
+ }
90
98
  this.carousel.down();
91
99
  this.render();
92
100
  },
@@ -136,13 +144,21 @@ class App {
136
144
  async run() {
137
145
  await this.init();
138
146
  this.keyboard.enableCapture();
139
- this.keyboard.on("key", (evt) => {
147
+ this.onKeyHandler = (evt) => {
140
148
  void this.handleKey(evt);
141
- });
149
+ };
150
+ this.keyboard.on("key", this.onKeyHandler);
142
151
  // Initial draw
143
152
  this.render();
144
153
  await this.carousel.updateSuggestions();
145
154
  }
155
+ end() {
156
+ if (this.onKeyHandler) {
157
+ this.keyboard.off("key", this.onKeyHandler);
158
+ this.onKeyHandler = undefined;
159
+ }
160
+ this.keyboard.disableCapture();
161
+ }
146
162
  async handleKey(evt) {
147
163
  const fn = this.handlers[evt.name];
148
164
  if (fn) {
@@ -177,13 +193,44 @@ class App {
177
193
  }
178
194
  finally {
179
195
  this.terminal.enableWrites();
196
+ this.terminal.reset();
180
197
  this.keyboard.enableCapture();
181
198
  }
182
199
  }
200
+ async enterOnPrompt() {
201
+ // Check for '\' line continuation
202
+ const lineInfo = this.carousel.getInputLineInfoAtCursor();
203
+ if (lineInfo.lineText.endsWith("\\") &&
204
+ lineInfo.column === lineInfo.lineText.length) {
205
+ this.carousel.insertAtCursor("\n");
206
+ this.render();
207
+ this.queueUpdateSuggestions();
208
+ return;
209
+ }
210
+ const rawInput = this.carousel.getInputBuffer();
211
+ const cmd = collapseLineContinuations(rawInput).trim();
212
+ await this.confirmCommandRun(cmd);
213
+ }
214
+ async enterOnSuggestion() {
215
+ const cmd = this.carousel.getCurrentRow().trim();
216
+ await this.confirmCommandRun(cmd);
217
+ }
218
+ async confirmCommandRun(cmd) {
219
+ this.carousel.setInputBuffer("", 0);
220
+ await this.runCommand(cmd);
221
+ // Carousel should point to the prompt
222
+ this.carousel.resetIndex();
223
+ // After arbitrary output, reset render block tracking
224
+ this.terminal.resetBlockTracking();
225
+ // Render the prompt, without this we'd wait for the suggestions to call render
226
+ // and it would appear slow
227
+ this.render();
228
+ this.queueUpdateSuggestions();
229
+ }
183
230
  exit() {
184
231
  // Clear terminal contents before shutting down to leave a clean screen.
185
232
  this.terminal.renderBlock([]);
186
- this.keyboard.disableCapture();
233
+ this.end();
187
234
  process.exit(0);
188
235
  }
189
236
  async tryAutocompleteFile() {
package/dist/carousel.js CHANGED
@@ -1,13 +1,67 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Carousel = void 0;
4
+ exports.getDisplayWidth = getDisplayWidth;
4
5
  const logs_1 = require("./logs");
5
6
  const terminal_1 = require("./terminal");
7
+ const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
8
+ const COMBINING_MARK_REGEX = /^\p{Mark}+$/u;
9
+ const EMOJI_REGEX = /\p{Extended_Pictographic}/u;
10
+ const GRAPHEME_SEGMENTER = typeof Intl !== "undefined" && "Segmenter" in Intl
11
+ ? new Intl.Segmenter("en", { granularity: "grapheme" })
12
+ : null;
13
+ function isFullWidthCodePoint(codePoint) {
14
+ return (codePoint >= 0x1100 &&
15
+ (codePoint <= 0x115f ||
16
+ codePoint === 0x2329 ||
17
+ codePoint === 0x232a ||
18
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
19
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
20
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
21
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
22
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
23
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
24
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
25
+ (codePoint >= 0x1f300 && codePoint <= 0x1f6ff) ||
26
+ (codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
27
+ (codePoint >= 0x1fa70 && codePoint <= 0x1faff) ||
28
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd)));
29
+ }
30
+ function getDisplayWidth(text) {
31
+ const stripped = text.replace(ANSI_ESCAPE_REGEX, "");
32
+ let width = 0;
33
+ if (GRAPHEME_SEGMENTER) {
34
+ for (const { segment } of GRAPHEME_SEGMENTER.segment(stripped)) {
35
+ if (!segment)
36
+ continue;
37
+ if (COMBINING_MARK_REGEX.test(segment))
38
+ continue;
39
+ if (EMOJI_REGEX.test(segment)) {
40
+ width += 2;
41
+ continue;
42
+ }
43
+ const codePoint = segment.codePointAt(0) ?? 0;
44
+ width += isFullWidthCodePoint(codePoint) ? 2 : 1;
45
+ }
46
+ return width;
47
+ }
48
+ for (const char of stripped) {
49
+ if (COMBINING_MARK_REGEX.test(char))
50
+ continue;
51
+ if (EMOJI_REGEX.test(char)) {
52
+ width += 2;
53
+ continue;
54
+ }
55
+ const codePoint = char.codePointAt(0) ?? 0;
56
+ width += isFullWidthCodePoint(codePoint) ? 2 : 1;
57
+ }
58
+ return width;
59
+ }
6
60
  class Carousel {
7
61
  constructor(opts) {
8
62
  this.index = 0;
9
63
  this.inputBuffer = "";
10
- this.inputCursor = 0;
64
+ this.cursorIndex = 0;
11
65
  this.terminal = opts.terminal;
12
66
  this.top = opts.top;
13
67
  this.bottom = opts.bottom;
@@ -27,6 +81,7 @@ class Carousel {
27
81
  if (this.index >= topLength) {
28
82
  this.index = topLength;
29
83
  }
84
+ this.clampCursorToActiveRow();
30
85
  }
31
86
  down() {
32
87
  this.index -= 1;
@@ -34,6 +89,7 @@ class Carousel {
34
89
  if (-this.index >= bottomLength) {
35
90
  this.index = -bottomLength;
36
91
  }
92
+ this.clampCursorToActiveRow();
37
93
  }
38
94
  getRow(rowIndex) {
39
95
  const latestTop = this.top.latest();
@@ -60,22 +116,32 @@ class Carousel {
60
116
  }
61
117
  return "$> ";
62
118
  }
63
- getFormattedRow(rowIndex) {
119
+ getFormattedSuggestionRow(rowIndex) {
64
120
  const rowStr = this.getRow(rowIndex);
65
- let prefix = this.getPrefixByIndex(rowIndex);
66
- const { brightWhite, reset, dim } = terminal_1.colors;
121
+ let prefix = this.getSuggestionPrefix(rowIndex, rowStr);
122
+ const { reset, dim } = terminal_1.colors;
67
123
  let color = dim;
68
124
  if (this.index === rowIndex) {
69
125
  color = terminal_1.colors.purple;
70
- if (rowIndex !== 0) {
71
- prefix = "> ";
72
- }
126
+ }
127
+ return `${color}${prefix}${rowStr}${reset}`;
128
+ }
129
+ getSuggestionPrefix(rowIndex, rowStr) {
130
+ let prefix = this.getPrefixByIndex(rowIndex);
131
+ if (this.index === rowIndex && rowIndex !== 0) {
132
+ prefix = `${prefix}> `;
73
133
  }
74
134
  if (rowIndex !== 0 && !rowStr) {
75
135
  // The edge of the top or bottom panel
76
136
  prefix = "---";
77
137
  }
78
- return `${color}${prefix}${rowStr}${reset}`;
138
+ return prefix;
139
+ }
140
+ getFormattedPromptRow(lineIndex, lineText, promptSelected) {
141
+ const { reset, dim } = terminal_1.colors;
142
+ const color = promptSelected ? terminal_1.colors.purple : dim;
143
+ const prefix = this.getPromptPrefix(lineIndex);
144
+ return `${color}${prefix}${lineText}${reset}`;
79
145
  }
80
146
  getCurrentRow() {
81
147
  return this.getRow(this.index);
@@ -89,50 +155,67 @@ class Carousel {
89
155
  }
90
156
  setInputBuffer(value, cursorPos = value.length) {
91
157
  this.inputBuffer = value;
92
- this.inputCursor = Math.max(0, Math.min(cursorPos, this.inputBuffer.length));
158
+ this.cursorIndex = Math.max(0, Math.min(cursorPos, this.inputBuffer.length));
159
+ }
160
+ getInputBuffer() {
161
+ return this.inputBuffer;
93
162
  }
94
163
  resetIndex() {
95
164
  this.index = 0;
165
+ this.cursorIndex = Math.min(this.cursorIndex, this.inputBuffer.length);
96
166
  }
97
167
  adoptSelectionIntoInput() {
168
+ // When you highlighted a suggestion row (history/AI) and then type
169
+ // or edit, we want to pull that selected row into the input buffer
98
170
  if (this.index === 0)
99
171
  return;
100
172
  const current = this.getRow(this.index);
101
- this.setInputBuffer(current, current.length);
173
+ this.setInputBuffer(current, Math.min(this.cursorIndex, current.length));
102
174
  this.index = 0;
103
175
  }
176
+ getActiveRowLength() {
177
+ if (this.isPromptRowSelected())
178
+ return this.inputBuffer.length;
179
+ return this.getRow(this.index).length;
180
+ }
181
+ canMoveRight() {
182
+ return this.cursorIndex < this.getActiveRowLength();
183
+ }
184
+ clampCursorToActiveRow() {
185
+ const len = this.getActiveRowLength();
186
+ this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, len));
187
+ }
104
188
  insertAtCursor(text) {
105
189
  if (!text)
106
190
  return;
107
191
  this.adoptSelectionIntoInput();
108
- const before = this.inputBuffer.slice(0, this.inputCursor);
109
- const after = this.inputBuffer.slice(this.inputCursor);
192
+ const before = this.inputBuffer.slice(0, this.cursorIndex);
193
+ const after = this.inputBuffer.slice(this.cursorIndex);
110
194
  this.inputBuffer = `${before}${text}${after}`;
111
- this.inputCursor += text.length;
195
+ this.cursorIndex += text.length;
112
196
  }
113
197
  deleteBeforeCursor() {
114
198
  this.adoptSelectionIntoInput();
115
- if (this.inputCursor === 0)
199
+ if (this.cursorIndex === 0)
116
200
  return;
117
- const before = this.inputBuffer.slice(0, this.inputCursor - 1);
118
- const after = this.inputBuffer.slice(this.inputCursor);
201
+ const before = this.inputBuffer.slice(0, this.cursorIndex - 1);
202
+ const after = this.inputBuffer.slice(this.cursorIndex);
119
203
  this.inputBuffer = `${before}${after}`;
120
- this.inputCursor -= 1;
204
+ this.cursorIndex -= 1;
121
205
  }
122
206
  moveCursorLeft() {
123
- this.adoptSelectionIntoInput();
124
- if (this.inputCursor === 0)
207
+ if (this.cursorIndex === 0)
125
208
  return;
126
- this.inputCursor -= 1;
209
+ this.cursorIndex -= 1;
127
210
  }
128
211
  isWhitespace(char) {
129
212
  return /\s/.test(char);
130
213
  }
131
214
  moveCursorWordLeft() {
132
215
  this.adoptSelectionIntoInput();
133
- if (this.inputCursor === 0)
216
+ if (this.cursorIndex === 0)
134
217
  return;
135
- let pos = this.inputCursor;
218
+ let pos = this.cursorIndex;
136
219
  // Skip any whitespace directly to the left of the cursor
137
220
  while (pos > 0 && this.isWhitespace(this.inputBuffer[pos - 1])) {
138
221
  pos -= 1;
@@ -141,19 +224,46 @@ class Carousel {
141
224
  while (pos > 0 && !this.isWhitespace(this.inputBuffer[pos - 1])) {
142
225
  pos -= 1;
143
226
  }
144
- this.inputCursor = pos;
227
+ this.cursorIndex = pos;
145
228
  }
146
229
  moveCursorRight() {
230
+ if (!this.canMoveRight())
231
+ return;
232
+ this.cursorIndex += 1;
233
+ }
234
+ shouldUpMoveMultilineCursor() {
235
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
236
+ return this.isPromptRowSelected() && info.lineIndex > 0;
237
+ }
238
+ shouldDownMoveMultilineCursor() {
239
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
240
+ return this.isPromptRowSelected() && info.lineIndex < info.lines.length - 1;
241
+ }
242
+ moveMultilineCursorUp() {
243
+ this.adoptSelectionIntoInput();
244
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
245
+ if (info.lineIndex === 0)
246
+ return;
247
+ const targetIndex = info.lineIndex - 1;
248
+ const targetStart = this.getLineStartIndex(targetIndex, info.lines);
249
+ const targetLen = info.lines[targetIndex].length;
250
+ this.cursorIndex = targetStart + Math.min(info.column, targetLen);
251
+ }
252
+ moveMultilineCursorDown() {
147
253
  this.adoptSelectionIntoInput();
148
- if (this.inputCursor >= this.inputBuffer.length)
254
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
255
+ if (info.lineIndex >= info.lines.length - 1)
149
256
  return;
150
- this.inputCursor += 1;
257
+ const targetIndex = info.lineIndex + 1;
258
+ const targetStart = this.getLineStartIndex(targetIndex, info.lines);
259
+ const targetLen = info.lines[targetIndex].length;
260
+ this.cursorIndex = targetStart + Math.min(info.column, targetLen);
151
261
  }
152
262
  moveCursorWordRight() {
153
263
  this.adoptSelectionIntoInput();
154
- if (this.inputCursor >= this.inputBuffer.length)
264
+ if (this.cursorIndex >= this.inputBuffer.length)
155
265
  return;
156
- let pos = this.inputCursor;
266
+ let pos = this.cursorIndex;
157
267
  const len = this.inputBuffer.length;
158
268
  // Skip any whitespace to the right of the cursor
159
269
  while (pos < len && this.isWhitespace(this.inputBuffer[pos])) {
@@ -163,30 +273,35 @@ class Carousel {
163
273
  while (pos < len && !this.isWhitespace(this.inputBuffer[pos])) {
164
274
  pos += 1;
165
275
  }
166
- this.inputCursor = pos;
276
+ this.cursorIndex = pos;
167
277
  }
168
278
  moveCursorHome() {
169
279
  this.adoptSelectionIntoInput();
170
- this.inputCursor = 0;
280
+ this.cursorIndex = this.getLineInfoAtPosition(this.cursorIndex).lineStart;
171
281
  }
172
282
  moveCursorEnd() {
173
283
  this.adoptSelectionIntoInput();
174
- this.inputCursor = this.inputBuffer.length;
284
+ this.cursorIndex = this.getLineInfoAtPosition(this.cursorIndex).lineEnd;
175
285
  }
176
286
  deleteAtCursor() {
177
287
  this.adoptSelectionIntoInput();
178
- if (this.inputCursor >= this.inputBuffer.length)
288
+ if (this.cursorIndex >= this.inputBuffer.length)
179
289
  return;
180
- const before = this.inputBuffer.slice(0, this.inputCursor);
181
- const after = this.inputBuffer.slice(this.inputCursor + 1);
290
+ const before = this.inputBuffer.slice(0, this.cursorIndex);
291
+ const after = this.inputBuffer.slice(this.cursorIndex + 1);
182
292
  this.inputBuffer = `${before}${after}`;
183
293
  }
184
294
  deleteToLineStart() {
185
295
  this.adoptSelectionIntoInput();
186
- if (this.inputCursor === 0)
296
+ if (this.cursorIndex === 0)
187
297
  return;
188
- const after = this.inputBuffer.slice(this.inputCursor);
189
- this.setInputBuffer(after, 0);
298
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
299
+ if (info.column === 0)
300
+ return;
301
+ const before = this.inputBuffer.slice(0, info.lineStart);
302
+ const after = this.inputBuffer.slice(this.cursorIndex);
303
+ this.inputBuffer = `${before}${after}`;
304
+ this.cursorIndex = info.lineStart;
190
305
  }
191
306
  clearInput() {
192
307
  this.adoptSelectionIntoInput();
@@ -200,14 +315,14 @@ class Carousel {
200
315
  return this.index === 0;
201
316
  }
202
317
  getInputCursor() {
203
- return this.inputCursor;
318
+ return this.cursorIndex;
204
319
  }
205
320
  getWordInfoAtCursor() {
206
- let start = this.inputCursor;
321
+ let start = this.cursorIndex;
207
322
  while (start > 0 && !this.isWhitespace(this.inputBuffer[start - 1])) {
208
323
  start -= 1;
209
324
  }
210
- let end = this.inputCursor;
325
+ let end = this.cursorIndex;
211
326
  const len = this.inputBuffer.length;
212
327
  while (end < len && !this.isWhitespace(this.inputBuffer[end])) {
213
328
  end += 1;
@@ -215,33 +330,98 @@ class Carousel {
215
330
  return {
216
331
  start,
217
332
  end,
218
- prefix: this.inputBuffer.slice(start, this.inputCursor),
333
+ prefix: this.inputBuffer.slice(start, this.cursorIndex),
219
334
  word: this.inputBuffer.slice(start, end),
220
335
  };
221
336
  }
337
+ getInputLineInfoAtCursor() {
338
+ return this.getLineInfoAtPosition(this.cursorIndex);
339
+ }
222
340
  getPromptCursorColumn() {
223
- const prefix = this.getPrefixByIndex(0);
224
- return prefix.length + this.inputCursor;
341
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
342
+ const prefix = this.getPromptPrefix(info.lineIndex);
343
+ const linePrefix = info.lineText.slice(0, info.column);
344
+ return getDisplayWidth(prefix) + getDisplayWidth(linePrefix);
345
+ }
346
+ getLineInfoAtPosition(pos) {
347
+ // Map a buffer index to its line/column and line boundaries.
348
+ const lines = this.getInputLines();
349
+ let start = 0;
350
+ for (let i = 0; i < lines.length; i++) {
351
+ const line = lines[i];
352
+ const end = start + line.length;
353
+ if (pos <= end) {
354
+ // Cursor is on this line or at its end.
355
+ return {
356
+ lines,
357
+ lineIndex: i,
358
+ lineText: line,
359
+ lineStart: start,
360
+ lineEnd: end,
361
+ column: pos - start,
362
+ };
363
+ }
364
+ start = end + 1;
365
+ }
366
+ const lastIndex = Math.max(0, lines.length - 1);
367
+ const lastStart = Math.max(0, this.inputBuffer.length - lines[lastIndex].length);
368
+ // Fallback when pos is beyond the buffer end.
369
+ return {
370
+ lines,
371
+ lineIndex: lastIndex,
372
+ lineText: lines[lastIndex] ?? "",
373
+ lineStart: lastStart,
374
+ lineEnd: lastStart + (lines[lastIndex]?.length ?? 0),
375
+ column: Math.max(0, pos - lastStart),
376
+ };
377
+ }
378
+ getInputLines() {
379
+ return this.inputBuffer.split("\n");
380
+ }
381
+ getLineStartIndex(lineIndex, lines) {
382
+ let start = 0;
383
+ for (let i = 0; i < lineIndex; i++) {
384
+ start += lines[i].length + 1;
385
+ }
386
+ return start;
387
+ }
388
+ getPromptPrefix(lineIndex) {
389
+ return lineIndex === 0 ? "$> " : "> ";
225
390
  }
226
391
  render() {
227
392
  (0, logs_1.logLine)("Rendering carousel");
228
- // Draw all the lines
229
393
  const width = process.stdout.columns || 80;
230
- const { brightWhite, reset, dim } = terminal_1.colors;
231
394
  const lines = [];
232
- const start = this.index + this.topRowCount;
233
395
  const rowCount = this.topRowCount + this.bottomRowCount + 1;
396
+ const start = this.index + this.topRowCount;
234
397
  const end = start - rowCount;
235
- for (let i = start; i > end; i--) {
236
- lines.push({
237
- rowIndex: i,
238
- text: this.getFormattedRow(i).slice(0, width - 2),
239
- });
398
+ const promptLines = this.getInputLines();
399
+ const promptSelected = this.index === 0;
400
+ const lineInfo = this.getLineInfoAtPosition(this.cursorIndex);
401
+ let cursorRow = 0;
402
+ let cursorCol = 0;
403
+ for (let rowIndex = start; rowIndex > end; rowIndex--) {
404
+ if (rowIndex === 0) {
405
+ for (let i = 0; i < promptLines.length; i++) {
406
+ if (this.index === 0 && i === lineInfo.lineIndex) {
407
+ cursorRow = lines.length;
408
+ cursorCol = this.getPromptCursorColumn();
409
+ }
410
+ lines.push(this.getFormattedPromptRow(i, promptLines[i], promptSelected));
411
+ }
412
+ }
413
+ else {
414
+ if (this.index === rowIndex) {
415
+ const rowStr = this.getRow(rowIndex);
416
+ const prefix = this.getSuggestionPrefix(rowIndex, rowStr);
417
+ cursorRow = lines.length;
418
+ const cursorText = rowStr.slice(0, Math.min(this.cursorIndex, rowStr.length));
419
+ cursorCol = getDisplayWidth(prefix) + getDisplayWidth(cursorText);
420
+ }
421
+ lines.push(this.getFormattedSuggestionRow(rowIndex));
422
+ }
240
423
  }
241
- const promptLineIndex = lines.findIndex((line) => line.rowIndex === 0);
242
- const cursorRow = this.topRowCount;
243
- const cursorCol = this.getPromptCursorColumn();
244
- this.terminal.renderBlock(lines.map((line) => line.text), cursorRow, cursorCol);
424
+ this.terminal.renderBlock(lines.map((line) => line.slice(0, width - 2)), cursorRow, cursorCol);
245
425
  }
246
426
  setTopSuggester(suggester) {
247
427
  if (this.top === suggester)
package/dist/keyboard.js CHANGED
@@ -1,45 +1,66 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Keyboard = void 0;
3
+ exports.Keyboard = exports.KEY_SEQUENCES = void 0;
4
+ exports.keySequence = keySequence;
4
5
  const events_1 = require("events");
5
- // Map escape/control sequences to semantic key names
6
- const KEYMAP = {
6
+ // Map semantic key names to escape/control sequences
7
+ const KEY_DEFINITIONS = {
7
8
  // Control keys
8
- '\u0003': { name: 'ctrl-c', ctrl: true }, // ^C
9
- '\u0004': { name: 'ctrl-d', ctrl: true }, // ^D
10
- '\u0015': { name: 'ctrl-u', ctrl: true }, // ^U
11
- '\t': { name: 'tab' },
12
- '\r': { name: 'enter' },
13
- '\n': { name: 'enter' },
14
- '\u007f': { name: 'backspace' }, // DEL
15
- '\u0008': { name: 'backspace' }, // BS (Windows)
16
- '\u001b': { name: 'escape' },
9
+ 'ctrl-c': [{ sequence: '\u0003', ctrl: true }], // ^C
10
+ 'ctrl-d': [{ sequence: '\u0004', ctrl: true }], // ^D
11
+ 'ctrl-u': [{ sequence: '\u0015', ctrl: true }], // ^U
12
+ tab: [{ sequence: '\t' }],
13
+ enter: [{ sequence: '\r' }, { sequence: '\n' }],
14
+ backspace: [{ sequence: '\u007f' }, { sequence: '\u0008' }], // DEL, BS (Windows)
15
+ escape: [{ sequence: '\u001b' }],
17
16
  // Arrows (ANSI)
18
- '\u001b[A': { name: 'up' },
19
- '\u001b[B': { name: 'down' },
20
- '\u001b[C': { name: 'right' },
21
- '\u001b[D': { name: 'left' },
22
- '\u001b[1;5C': { name: 'ctrl-right', ctrl: true },
23
- '\u001b[1;5D': { name: 'ctrl-left', ctrl: true },
24
- '\u001b[5C': { name: 'ctrl-right', ctrl: true },
25
- '\u001b[5D': { name: 'ctrl-left', ctrl: true },
26
- // Option/Alt-based word jumps (macOS/iTerm send meta-modified arrows or ESC+b/f)
27
- '\u001b[1;3C': { name: 'ctrl-right', meta: true },
28
- '\u001b[1;3D': { name: 'ctrl-left', meta: true },
29
- '\u001b[1;9C': { name: 'ctrl-right', meta: true },
30
- '\u001b[1;9D': { name: 'ctrl-left', meta: true },
31
- '\u001bf': { name: 'ctrl-right', meta: true },
32
- '\u001bb': { name: 'ctrl-left', meta: true },
17
+ up: [{ sequence: '\u001b[A' }],
18
+ down: [{ sequence: '\u001b[B' }],
19
+ right: [{ sequence: '\u001b[C' }],
20
+ left: [{ sequence: '\u001b[D' }],
21
+ 'ctrl-right': [
22
+ { sequence: '\u001b[1;5C', ctrl: true },
23
+ { sequence: '\u001b[5C', ctrl: true },
24
+ // Option/Alt-based word jumps (macOS/iTerm send meta-modified arrows or ESC+b/f)
25
+ { sequence: '\u001b[1;3C', meta: true },
26
+ { sequence: '\u001b[1;9C', meta: true },
27
+ { sequence: '\u001bf', meta: true },
28
+ ],
29
+ 'ctrl-left': [
30
+ { sequence: '\u001b[1;5D', ctrl: true },
31
+ { sequence: '\u001b[5D', ctrl: true },
32
+ // Option/Alt-based word jumps (macOS/iTerm send meta-modified arrows or ESC+b/f)
33
+ { sequence: '\u001b[1;3D', meta: true },
34
+ { sequence: '\u001b[1;9D', meta: true },
35
+ { sequence: '\u001bb', meta: true },
36
+ ],
33
37
  // Home/End/Delete variants
34
- '\u001b[H': { name: 'home' },
35
- '\u001b[F': { name: 'end' },
36
- '\u001b[1~': { name: 'home' },
37
- '\u001b[4~': { name: 'end' },
38
- '\u001b[3~': { name: 'delete' },
38
+ home: [{ sequence: '\u001b[H' }, { sequence: '\u001b[1~' }],
39
+ end: [{ sequence: '\u001b[F' }, { sequence: '\u001b[4~' }],
40
+ delete: [{ sequence: '\u001b[3~' }],
39
41
  // Focus in/out (sent by some terminals on focus change - swallow these)
40
- '\u001b[I': { name: 'focus-in' },
41
- '\u001b[O': { name: 'focus-out' },
42
+ 'focus-in': [{ sequence: '\u001b[I' }],
43
+ 'focus-out': [{ sequence: '\u001b[O' }],
42
44
  };
45
+ exports.KEY_SEQUENCES = Object.fromEntries(Object.entries(KEY_DEFINITIONS).map(([name, defs]) => [
46
+ name,
47
+ defs.map((def) => def.sequence),
48
+ ]));
49
+ function keySequence(name) {
50
+ return exports.KEY_SEQUENCES[name][0];
51
+ }
52
+ // Map escape/control sequences to semantic key names
53
+ const KEYMAP = {};
54
+ for (const [name, defs] of Object.entries(KEY_DEFINITIONS)) {
55
+ for (const def of defs) {
56
+ KEYMAP[def.sequence] = {
57
+ name,
58
+ ctrl: def.ctrl,
59
+ meta: def.meta,
60
+ shift: def.shift,
61
+ };
62
+ }
63
+ }
43
64
  // For efficient prefix checks
44
65
  const KEY_PREFIXES = new Set();
45
66
  for (const seq of Object.keys(KEYMAP)) {
package/dist/spawner.js CHANGED
@@ -7,6 +7,18 @@ const logs_1 = require("./logs");
7
7
  const isWin = process.platform === "win32";
8
8
  const shellBinary = isWin ? "cmd.exe" : "/bin/bash";
9
9
  const shellArgs = isWin ? ["/d", "/s", "/c"] : ["-lc"];
10
+ const dirStack = [];
11
+ // Track last-known cwd per drive so `E:` switches like cmd.exe
12
+ // into the folder you were in before switching drives.
13
+ const driveCwds = {};
14
+ function updateDriveCwd(cwd = process.cwd()) {
15
+ if (!isWin)
16
+ return;
17
+ const drive = cwd.slice(0, 2).toUpperCase();
18
+ if (/^[A-Z]:$/.test(drive)) {
19
+ driveCwds[drive] = cwd;
20
+ }
21
+ }
10
22
  const builtInCommands = {
11
23
  cd: async (args) => {
12
24
  if (args.length === 1) {
@@ -16,6 +28,7 @@ const builtInCommands = {
16
28
  const dest = expandVars(args[1]);
17
29
  try {
18
30
  process.chdir(dest);
31
+ updateDriveCwd();
19
32
  }
20
33
  catch (err) {
21
34
  process.stderr.write(`cd: ${err.message}\n`);
@@ -23,11 +36,66 @@ const builtInCommands = {
23
36
  }
24
37
  return true;
25
38
  },
39
+ pushd: async (args) => {
40
+ const current = process.cwd();
41
+ if (args.length === 1) {
42
+ const next = dirStack.shift();
43
+ if (!next) {
44
+ process.stderr.write("pushd: no other directory\n");
45
+ return false;
46
+ }
47
+ dirStack.unshift(current);
48
+ try {
49
+ process.chdir(next);
50
+ updateDriveCwd();
51
+ }
52
+ catch (err) {
53
+ process.stderr.write(`pushd: ${err.message}\n`);
54
+ dirStack.shift();
55
+ return false;
56
+ }
57
+ writeDirStack();
58
+ return true;
59
+ }
60
+ const dest = expandVars(args[1]);
61
+ try {
62
+ process.chdir(dest);
63
+ updateDriveCwd();
64
+ }
65
+ catch (err) {
66
+ process.stderr.write(`pushd: ${err.message}\n`);
67
+ return false;
68
+ }
69
+ dirStack.unshift(current);
70
+ writeDirStack();
71
+ return true;
72
+ },
73
+ popd: async () => {
74
+ const next = dirStack.shift();
75
+ if (!next) {
76
+ process.stderr.write("popd: directory stack empty\n");
77
+ return false;
78
+ }
79
+ try {
80
+ process.chdir(next);
81
+ updateDriveCwd();
82
+ }
83
+ catch (err) {
84
+ process.stderr.write(`popd: ${err.message}\n`);
85
+ return false;
86
+ }
87
+ writeDirStack();
88
+ return true;
89
+ },
26
90
  exit: async () => {
27
91
  (0, process_1.exit)(0);
28
92
  return false;
29
93
  },
30
94
  };
95
+ function writeDirStack() {
96
+ const parts = [process.cwd(), ...dirStack];
97
+ process.stdout.write(parts.join(" ") + "\n");
98
+ }
31
99
  function expandVars(input) {
32
100
  let out = input;
33
101
  if (isWin) {
@@ -52,6 +120,20 @@ async function runUserCommand(command) {
52
120
  const trimmed = command.trim();
53
121
  if (!trimmed)
54
122
  return false;
123
+ if (isWin && /^[a-zA-Z]:$/.test(trimmed)) {
124
+ // Windows drive switch (eg "E:") should restore that drive's last cwd.
125
+ const drive = trimmed.toUpperCase();
126
+ const target = driveCwds[drive] ?? `${drive}\\`;
127
+ try {
128
+ process.chdir(target);
129
+ updateDriveCwd();
130
+ return true;
131
+ }
132
+ catch (err) {
133
+ process.stderr.write(`${trimmed}: ${err.message}\n`);
134
+ return false;
135
+ }
136
+ }
55
137
  const args = command.split(/\s+/);
56
138
  if (typeof args[0] === "string" && builtInCommands[args[0]]) {
57
139
  return await builtInCommands[args[0]](args);
@@ -70,3 +152,4 @@ async function runUserCommand(command) {
70
152
  // many times until we fix it.
71
153
  return true;
72
154
  }
155
+ updateDriveCwd();
package/dist/terminal.js CHANGED
@@ -29,6 +29,13 @@ class Terminal {
29
29
  enableWrites() {
30
30
  this.writesDisabled = false;
31
31
  }
32
+ reset() {
33
+ // Some apps (such as vim) change the terminal cursor mode.
34
+ // We need to reset it to the default. To avoid arrow keys causing this:
35
+ // $> OAOBOCODODODODOAOAOCOB
36
+ const RESET_CURSOR_MODE = "\x1b[?1l";
37
+ this.write(RESET_CURSOR_MODE);
38
+ }
32
39
  canWrite() {
33
40
  return !this.writesDisabled;
34
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caroushell",
3
- "version": "0.1.20",
3
+ "version": "0.1.23",
4
4
  "description": "Terminal carousel that suggests commands from history, config, and AI.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/main.js",