caroushell 0.1.21 → 0.1.24

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.
@@ -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
@@ -5,24 +5,26 @@ const terminal_1 = require("./terminal");
5
5
  const keyboard_1 = require("./keyboard");
6
6
  const carousel_1 = require("./carousel");
7
7
  const history_suggester_1 = require("./history-suggester");
8
- const ai_suggester_1 = require("./ai-suggester");
9
8
  const file_suggester_1 = require("./file-suggester");
10
9
  const spawner_1 = require("./spawner");
11
10
  const logs_1 = require("./logs");
11
+ function collapseLineContinuations(input) {
12
+ return input.replace(/\\\r?\n/g, "");
13
+ }
12
14
  class App {
13
15
  constructor(deps = {}) {
14
16
  this.usingFileSuggestions = false;
15
17
  this.terminal = deps.terminal ?? new terminal_1.Terminal();
16
18
  this.keyboard = deps.keyboard ?? new keyboard_1.Keyboard();
17
19
  this.history = deps.topPanel ?? new history_suggester_1.HistorySuggester();
18
- this.ai = deps.bottomPanel ?? new ai_suggester_1.AISuggester();
20
+ this.bottomSuggester = deps.bottomPanel ?? new carousel_1.NullSuggester();
19
21
  this.files = deps.files ?? new file_suggester_1.FileSuggester();
20
- this.suggesters = deps.suggesters ?? [this.history, this.ai, this.files];
22
+ this.suggesters = deps.suggesters ?? [this.history, this.bottomSuggester, this.files];
21
23
  this.carousel = new carousel_1.Carousel({
22
24
  top: this.history,
23
- bottom: this.ai,
25
+ bottom: this.bottomSuggester,
24
26
  topRows: 2,
25
- bottomRows: 2,
27
+ bottomRows: this.bottomSuggester instanceof carousel_1.NullSuggester ? 0 : 2,
26
28
  terminal: this.terminal,
27
29
  });
28
30
  this.queueUpdateSuggestions = () => {
@@ -63,17 +65,12 @@ class App {
63
65
  if (this.tryAcceptHighlightedFileSuggestion()) {
64
66
  return;
65
67
  }
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();
68
+ if (this.carousel.isPromptRowSelected()) {
69
+ await this.enterOnPrompt();
70
+ }
71
+ else {
72
+ await this.enterOnSuggestion();
73
+ }
77
74
  },
78
75
  char: (evt) => {
79
76
  this.carousel.insertAtCursor(evt.sequence);
@@ -83,10 +80,20 @@ class App {
83
80
  this.queueUpdateSuggestions();
84
81
  },
85
82
  up: () => {
83
+ if (this.carousel.shouldUpMoveMultilineCursor()) {
84
+ this.carousel.moveMultilineCursorUp();
85
+ this.render();
86
+ return;
87
+ }
86
88
  this.carousel.up();
87
89
  this.render();
88
90
  },
89
91
  down: () => {
92
+ if (this.carousel.shouldDownMoveMultilineCursor()) {
93
+ this.carousel.moveMultilineCursorDown();
94
+ this.render();
95
+ return;
96
+ }
90
97
  this.carousel.down();
91
98
  this.render();
92
99
  },
@@ -129,20 +136,28 @@ class App {
129
136
  };
130
137
  }
131
138
  async init() {
132
- await this.history.init();
133
- await this.ai.init();
134
- await this.files.init();
139
+ for (const s of this.suggesters) {
140
+ await s.init();
141
+ }
135
142
  }
136
143
  async run() {
137
144
  await this.init();
138
145
  this.keyboard.enableCapture();
139
- this.keyboard.on("key", (evt) => {
146
+ this.onKeyHandler = (evt) => {
140
147
  void this.handleKey(evt);
141
- });
148
+ };
149
+ this.keyboard.on("key", this.onKeyHandler);
142
150
  // Initial draw
143
151
  this.render();
144
152
  await this.carousel.updateSuggestions();
145
153
  }
154
+ end() {
155
+ if (this.onKeyHandler) {
156
+ this.keyboard.off("key", this.onKeyHandler);
157
+ this.onKeyHandler = undefined;
158
+ }
159
+ this.keyboard.disableCapture();
160
+ }
146
161
  async handleKey(evt) {
147
162
  const fn = this.handlers[evt.name];
148
163
  if (fn) {
@@ -169,6 +184,7 @@ class App {
169
184
  this.terminal.write("\n");
170
185
  this.keyboard.disableCapture();
171
186
  this.terminal.disableWrites();
187
+ await this.preBroadcastCommand(cmd);
172
188
  try {
173
189
  const storeInHistory = await (0, spawner_1.runUserCommand)(cmd);
174
190
  if (storeInHistory) {
@@ -181,10 +197,40 @@ class App {
181
197
  this.keyboard.enableCapture();
182
198
  }
183
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
+ }
184
230
  exit() {
185
231
  // Clear terminal contents before shutting down to leave a clean screen.
186
232
  this.terminal.renderBlock([]);
187
- this.keyboard.disableCapture();
233
+ this.end();
188
234
  process.exit(0);
189
235
  }
190
236
  async tryAutocompleteFile() {
@@ -245,6 +291,19 @@ class App {
245
291
  this.usingFileSuggestions = true;
246
292
  this.carousel.setTopSuggester(this.files);
247
293
  }
294
+ async preBroadcastCommand(cmd) {
295
+ const listeners = this.suggesters
296
+ .map((suggester) => suggester.onCommandWillRun?.(cmd))
297
+ .filter(Boolean);
298
+ if (listeners.length === 0)
299
+ return;
300
+ try {
301
+ await Promise.all(listeners);
302
+ }
303
+ catch (err) {
304
+ (0, logs_1.logLine)("suggester onCommandWillRun error: " + err?.message);
305
+ }
306
+ }
248
307
  async broadcastCommand(cmd) {
249
308
  const listeners = this.suggesters
250
309
  .map((suggester) => suggester.onCommandRan?.(cmd))
package/dist/carousel.js CHANGED
@@ -1,13 +1,77 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Carousel = void 0;
3
+ exports.Carousel = exports.NullSuggester = 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
+ }
60
+ class NullSuggester {
61
+ constructor() {
62
+ this.prefix = "";
63
+ }
64
+ async init() { }
65
+ async refreshSuggestions() { }
66
+ latest() { return []; }
67
+ descriptionForAi() { return ""; }
68
+ }
69
+ exports.NullSuggester = NullSuggester;
6
70
  class Carousel {
7
71
  constructor(opts) {
8
72
  this.index = 0;
9
73
  this.inputBuffer = "";
10
- this.inputCursor = 0;
74
+ this.cursorIndex = 0;
11
75
  this.terminal = opts.terminal;
12
76
  this.top = opts.top;
13
77
  this.bottom = opts.bottom;
@@ -27,6 +91,7 @@ class Carousel {
27
91
  if (this.index >= topLength) {
28
92
  this.index = topLength;
29
93
  }
94
+ this.clampCursorToActiveRow();
30
95
  }
31
96
  down() {
32
97
  this.index -= 1;
@@ -34,6 +99,7 @@ class Carousel {
34
99
  if (-this.index >= bottomLength) {
35
100
  this.index = -bottomLength;
36
101
  }
102
+ this.clampCursorToActiveRow();
37
103
  }
38
104
  getRow(rowIndex) {
39
105
  const latestTop = this.top.latest();
@@ -60,22 +126,32 @@ class Carousel {
60
126
  }
61
127
  return "$> ";
62
128
  }
63
- getFormattedRow(rowIndex) {
129
+ getFormattedSuggestionRow(rowIndex) {
64
130
  const rowStr = this.getRow(rowIndex);
65
- let prefix = this.getPrefixByIndex(rowIndex);
66
- const { brightWhite, reset, dim } = terminal_1.colors;
131
+ let prefix = this.getSuggestionPrefix(rowIndex, rowStr);
132
+ const { reset, dim } = terminal_1.colors;
67
133
  let color = dim;
68
134
  if (this.index === rowIndex) {
69
135
  color = terminal_1.colors.purple;
70
- if (rowIndex !== 0) {
71
- prefix = "> ";
72
- }
136
+ }
137
+ return `${color}${prefix}${rowStr}${reset}`;
138
+ }
139
+ getSuggestionPrefix(rowIndex, rowStr) {
140
+ let prefix = this.getPrefixByIndex(rowIndex);
141
+ if (this.index === rowIndex && rowIndex !== 0) {
142
+ prefix = `${prefix}> `;
73
143
  }
74
144
  if (rowIndex !== 0 && !rowStr) {
75
145
  // The edge of the top or bottom panel
76
146
  prefix = "---";
77
147
  }
78
- return `${color}${prefix}${rowStr}${reset}`;
148
+ return prefix;
149
+ }
150
+ getFormattedPromptRow(lineIndex, lineText, promptSelected) {
151
+ const { reset, dim } = terminal_1.colors;
152
+ const color = promptSelected ? terminal_1.colors.purple : dim;
153
+ const prefix = this.getPromptPrefix(lineIndex);
154
+ return `${color}${prefix}${lineText}${reset}`;
79
155
  }
80
156
  getCurrentRow() {
81
157
  return this.getRow(this.index);
@@ -89,50 +165,67 @@ class Carousel {
89
165
  }
90
166
  setInputBuffer(value, cursorPos = value.length) {
91
167
  this.inputBuffer = value;
92
- this.inputCursor = Math.max(0, Math.min(cursorPos, this.inputBuffer.length));
168
+ this.cursorIndex = Math.max(0, Math.min(cursorPos, this.inputBuffer.length));
169
+ }
170
+ getInputBuffer() {
171
+ return this.inputBuffer;
93
172
  }
94
173
  resetIndex() {
95
174
  this.index = 0;
175
+ this.cursorIndex = Math.min(this.cursorIndex, this.inputBuffer.length);
96
176
  }
97
177
  adoptSelectionIntoInput() {
178
+ // When you highlighted a suggestion row (history/AI) and then type
179
+ // or edit, we want to pull that selected row into the input buffer
98
180
  if (this.index === 0)
99
181
  return;
100
182
  const current = this.getRow(this.index);
101
- this.setInputBuffer(current, current.length);
183
+ this.setInputBuffer(current, Math.min(this.cursorIndex, current.length));
102
184
  this.index = 0;
103
185
  }
186
+ getActiveRowLength() {
187
+ if (this.isPromptRowSelected())
188
+ return this.inputBuffer.length;
189
+ return this.getRow(this.index).length;
190
+ }
191
+ canMoveRight() {
192
+ return this.cursorIndex < this.getActiveRowLength();
193
+ }
194
+ clampCursorToActiveRow() {
195
+ const len = this.getActiveRowLength();
196
+ this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, len));
197
+ }
104
198
  insertAtCursor(text) {
105
199
  if (!text)
106
200
  return;
107
201
  this.adoptSelectionIntoInput();
108
- const before = this.inputBuffer.slice(0, this.inputCursor);
109
- const after = this.inputBuffer.slice(this.inputCursor);
202
+ const before = this.inputBuffer.slice(0, this.cursorIndex);
203
+ const after = this.inputBuffer.slice(this.cursorIndex);
110
204
  this.inputBuffer = `${before}${text}${after}`;
111
- this.inputCursor += text.length;
205
+ this.cursorIndex += text.length;
112
206
  }
113
207
  deleteBeforeCursor() {
114
208
  this.adoptSelectionIntoInput();
115
- if (this.inputCursor === 0)
209
+ if (this.cursorIndex === 0)
116
210
  return;
117
- const before = this.inputBuffer.slice(0, this.inputCursor - 1);
118
- const after = this.inputBuffer.slice(this.inputCursor);
211
+ const before = this.inputBuffer.slice(0, this.cursorIndex - 1);
212
+ const after = this.inputBuffer.slice(this.cursorIndex);
119
213
  this.inputBuffer = `${before}${after}`;
120
- this.inputCursor -= 1;
214
+ this.cursorIndex -= 1;
121
215
  }
122
216
  moveCursorLeft() {
123
- this.adoptSelectionIntoInput();
124
- if (this.inputCursor === 0)
217
+ if (this.cursorIndex === 0)
125
218
  return;
126
- this.inputCursor -= 1;
219
+ this.cursorIndex -= 1;
127
220
  }
128
221
  isWhitespace(char) {
129
222
  return /\s/.test(char);
130
223
  }
131
224
  moveCursorWordLeft() {
132
225
  this.adoptSelectionIntoInput();
133
- if (this.inputCursor === 0)
226
+ if (this.cursorIndex === 0)
134
227
  return;
135
- let pos = this.inputCursor;
228
+ let pos = this.cursorIndex;
136
229
  // Skip any whitespace directly to the left of the cursor
137
230
  while (pos > 0 && this.isWhitespace(this.inputBuffer[pos - 1])) {
138
231
  pos -= 1;
@@ -141,19 +234,46 @@ class Carousel {
141
234
  while (pos > 0 && !this.isWhitespace(this.inputBuffer[pos - 1])) {
142
235
  pos -= 1;
143
236
  }
144
- this.inputCursor = pos;
237
+ this.cursorIndex = pos;
145
238
  }
146
239
  moveCursorRight() {
240
+ if (!this.canMoveRight())
241
+ return;
242
+ this.cursorIndex += 1;
243
+ }
244
+ shouldUpMoveMultilineCursor() {
245
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
246
+ return this.isPromptRowSelected() && info.lineIndex > 0;
247
+ }
248
+ shouldDownMoveMultilineCursor() {
249
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
250
+ return this.isPromptRowSelected() && info.lineIndex < info.lines.length - 1;
251
+ }
252
+ moveMultilineCursorUp() {
253
+ this.adoptSelectionIntoInput();
254
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
255
+ if (info.lineIndex === 0)
256
+ return;
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);
261
+ }
262
+ moveMultilineCursorDown() {
147
263
  this.adoptSelectionIntoInput();
148
- if (this.inputCursor >= this.inputBuffer.length)
264
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
265
+ if (info.lineIndex >= info.lines.length - 1)
149
266
  return;
150
- this.inputCursor += 1;
267
+ const targetIndex = info.lineIndex + 1;
268
+ const targetStart = this.getLineStartIndex(targetIndex, info.lines);
269
+ const targetLen = info.lines[targetIndex].length;
270
+ this.cursorIndex = targetStart + Math.min(info.column, targetLen);
151
271
  }
152
272
  moveCursorWordRight() {
153
273
  this.adoptSelectionIntoInput();
154
- if (this.inputCursor >= this.inputBuffer.length)
274
+ if (this.cursorIndex >= this.inputBuffer.length)
155
275
  return;
156
- let pos = this.inputCursor;
276
+ let pos = this.cursorIndex;
157
277
  const len = this.inputBuffer.length;
158
278
  // Skip any whitespace to the right of the cursor
159
279
  while (pos < len && this.isWhitespace(this.inputBuffer[pos])) {
@@ -163,30 +283,35 @@ class Carousel {
163
283
  while (pos < len && !this.isWhitespace(this.inputBuffer[pos])) {
164
284
  pos += 1;
165
285
  }
166
- this.inputCursor = pos;
286
+ this.cursorIndex = pos;
167
287
  }
168
288
  moveCursorHome() {
169
289
  this.adoptSelectionIntoInput();
170
- this.inputCursor = 0;
290
+ this.cursorIndex = this.getLineInfoAtPosition(this.cursorIndex).lineStart;
171
291
  }
172
292
  moveCursorEnd() {
173
293
  this.adoptSelectionIntoInput();
174
- this.inputCursor = this.inputBuffer.length;
294
+ this.cursorIndex = this.getLineInfoAtPosition(this.cursorIndex).lineEnd;
175
295
  }
176
296
  deleteAtCursor() {
177
297
  this.adoptSelectionIntoInput();
178
- if (this.inputCursor >= this.inputBuffer.length)
298
+ if (this.cursorIndex >= this.inputBuffer.length)
179
299
  return;
180
- const before = this.inputBuffer.slice(0, this.inputCursor);
181
- const after = this.inputBuffer.slice(this.inputCursor + 1);
300
+ const before = this.inputBuffer.slice(0, this.cursorIndex);
301
+ const after = this.inputBuffer.slice(this.cursorIndex + 1);
182
302
  this.inputBuffer = `${before}${after}`;
183
303
  }
184
304
  deleteToLineStart() {
185
305
  this.adoptSelectionIntoInput();
186
- if (this.inputCursor === 0)
306
+ if (this.cursorIndex === 0)
187
307
  return;
188
- const after = this.inputBuffer.slice(this.inputCursor);
189
- this.setInputBuffer(after, 0);
308
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
309
+ if (info.column === 0)
310
+ return;
311
+ const before = this.inputBuffer.slice(0, info.lineStart);
312
+ const after = this.inputBuffer.slice(this.cursorIndex);
313
+ this.inputBuffer = `${before}${after}`;
314
+ this.cursorIndex = info.lineStart;
190
315
  }
191
316
  clearInput() {
192
317
  this.adoptSelectionIntoInput();
@@ -200,14 +325,14 @@ class Carousel {
200
325
  return this.index === 0;
201
326
  }
202
327
  getInputCursor() {
203
- return this.inputCursor;
328
+ return this.cursorIndex;
204
329
  }
205
330
  getWordInfoAtCursor() {
206
- let start = this.inputCursor;
331
+ let start = this.cursorIndex;
207
332
  while (start > 0 && !this.isWhitespace(this.inputBuffer[start - 1])) {
208
333
  start -= 1;
209
334
  }
210
- let end = this.inputCursor;
335
+ let end = this.cursorIndex;
211
336
  const len = this.inputBuffer.length;
212
337
  while (end < len && !this.isWhitespace(this.inputBuffer[end])) {
213
338
  end += 1;
@@ -215,33 +340,98 @@ class Carousel {
215
340
  return {
216
341
  start,
217
342
  end,
218
- prefix: this.inputBuffer.slice(start, this.inputCursor),
343
+ prefix: this.inputBuffer.slice(start, this.cursorIndex),
219
344
  word: this.inputBuffer.slice(start, end),
220
345
  };
221
346
  }
347
+ getInputLineInfoAtCursor() {
348
+ return this.getLineInfoAtPosition(this.cursorIndex);
349
+ }
222
350
  getPromptCursorColumn() {
223
- const prefix = this.getPrefixByIndex(0);
224
- return prefix.length + this.inputCursor;
351
+ const info = this.getLineInfoAtPosition(this.cursorIndex);
352
+ const prefix = this.getPromptPrefix(info.lineIndex);
353
+ const linePrefix = info.lineText.slice(0, info.column);
354
+ return getDisplayWidth(prefix) + getDisplayWidth(linePrefix);
355
+ }
356
+ getLineInfoAtPosition(pos) {
357
+ // Map a buffer index to its line/column and line boundaries.
358
+ const lines = this.getInputLines();
359
+ let start = 0;
360
+ for (let i = 0; i < lines.length; i++) {
361
+ const line = lines[i];
362
+ const end = start + line.length;
363
+ if (pos <= end) {
364
+ // Cursor is on this line or at its end.
365
+ return {
366
+ lines,
367
+ lineIndex: i,
368
+ lineText: line,
369
+ lineStart: start,
370
+ lineEnd: end,
371
+ column: pos - start,
372
+ };
373
+ }
374
+ start = end + 1;
375
+ }
376
+ const lastIndex = Math.max(0, lines.length - 1);
377
+ const lastStart = Math.max(0, this.inputBuffer.length - lines[lastIndex].length);
378
+ // Fallback when pos is beyond the buffer end.
379
+ return {
380
+ lines,
381
+ lineIndex: lastIndex,
382
+ lineText: lines[lastIndex] ?? "",
383
+ lineStart: lastStart,
384
+ lineEnd: lastStart + (lines[lastIndex]?.length ?? 0),
385
+ column: Math.max(0, pos - lastStart),
386
+ };
387
+ }
388
+ getInputLines() {
389
+ return this.inputBuffer.split("\n");
390
+ }
391
+ getLineStartIndex(lineIndex, lines) {
392
+ let start = 0;
393
+ for (let i = 0; i < lineIndex; i++) {
394
+ start += lines[i].length + 1;
395
+ }
396
+ return start;
397
+ }
398
+ getPromptPrefix(lineIndex) {
399
+ return lineIndex === 0 ? "$> " : "> ";
225
400
  }
226
401
  render() {
227
402
  (0, logs_1.logLine)("Rendering carousel");
228
- // Draw all the lines
229
403
  const width = process.stdout.columns || 80;
230
- const { brightWhite, reset, dim } = terminal_1.colors;
231
404
  const lines = [];
232
- const start = this.index + this.topRowCount;
233
405
  const rowCount = this.topRowCount + this.bottomRowCount + 1;
406
+ const start = this.index + this.topRowCount;
234
407
  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
- });
408
+ const promptLines = this.getInputLines();
409
+ const promptSelected = this.index === 0;
410
+ const lineInfo = this.getLineInfoAtPosition(this.cursorIndex);
411
+ let cursorRow = 0;
412
+ let cursorCol = 0;
413
+ for (let rowIndex = start; rowIndex > end; rowIndex--) {
414
+ if (rowIndex === 0) {
415
+ for (let i = 0; i < promptLines.length; i++) {
416
+ if (this.index === 0 && i === lineInfo.lineIndex) {
417
+ cursorRow = lines.length;
418
+ cursorCol = this.getPromptCursorColumn();
419
+ }
420
+ lines.push(this.getFormattedPromptRow(i, promptLines[i], promptSelected));
421
+ }
422
+ }
423
+ else {
424
+ if (this.index === rowIndex) {
425
+ const rowStr = this.getRow(rowIndex);
426
+ const prefix = this.getSuggestionPrefix(rowIndex, rowStr);
427
+ cursorRow = lines.length;
428
+ const cursorText = rowStr.slice(0, Math.min(this.cursorIndex, rowStr.length));
429
+ cursorCol = getDisplayWidth(prefix) + getDisplayWidth(cursorText);
430
+ }
431
+ lines.push(this.getFormattedSuggestionRow(rowIndex));
432
+ }
240
433
  }
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);
434
+ this.terminal.renderBlock(lines.map((line) => line.slice(0, width - 2)), cursorRow, cursorCol);
245
435
  }
246
436
  setTopSuggester(suggester) {
247
437
  if (this.top === suggester)
package/dist/config.js CHANGED
@@ -88,7 +88,6 @@ async function doesConfigExist() {
88
88
  }
89
89
  }
90
90
  async function getConfig() {
91
- const configPath = getConfigPath();
92
91
  const raw = await readConfigFile();
93
92
  const envApiKey = process.env.CAROUSHELL_API_KEY || process.env.GEMINI_API_KEY || undefined;
94
93
  const envApiUrl = process.env.CAROUSHELL_API_URL || undefined;
@@ -107,9 +106,6 @@ async function getConfig() {
107
106
  if (!resolved.model && geminiApiKey) {
108
107
  resolved.model = GEMINI_DEFAULT_MODEL;
109
108
  }
110
- if (!resolved.apiUrl || !resolved.apiKey || !resolved.model) {
111
- throw new Error(`Config at ${configPath} is missing required fields. Please include apiUrl, apiKey, and model (or just GEMINI_API_KEY).`);
112
- }
113
109
  return resolved;
114
110
  }
115
111
  function isGeminiUrl(url) {
@@ -70,7 +70,23 @@ async function runHelloNewUserFlow(configPath) {
70
70
  await fs_1.promises.mkdir(dir, { recursive: true });
71
71
  console.log("");
72
72
  console.log("Welcome to Caroushell!");
73
- console.log(`Let's set up AI suggestions. You'll need an API endpoint URL, a key, and model id. These will be stored at ${configPath}`);
73
+ console.log("");
74
+ const rl = readline_1.default.createInterface({
75
+ input: process.stdin,
76
+ output: process.stdout,
77
+ });
78
+ const wantsAi = (await prompt("Do you want to set up AI auto-complete? (y/n): ", rl))
79
+ .trim()
80
+ .toLowerCase();
81
+ if (wantsAi !== "y" && wantsAi !== "yes") {
82
+ rl.close();
83
+ // Writing noAi or any key will skip the new user flow next run
84
+ await fs_1.promises.writeFile(configPath, "noAi = true\n", "utf8");
85
+ console.log("\nSkipping AI setup. You can set it up later by editing " + configPath);
86
+ console.log("");
87
+ return null;
88
+ }
89
+ console.log(`\nLet's set up AI suggestions. You'll need an API endpoint URL, a key, and model id. These will be stored at ${configPath}`);
74
90
  console.log("");
75
91
  console.log("Some example endpoints you can paste:");
76
92
  console.log(" - OpenRouter: https://openrouter.ai/api/v1");
@@ -78,10 +94,6 @@ async function runHelloNewUserFlow(configPath) {
78
94
  console.log(" - Google: https://generativelanguage.googleapis.com/v1beta/openai");
79
95
  console.log("");
80
96
  console.log("Press Ctrl+C any time to abort.\n");
81
- const rl = readline_1.default.createInterface({
82
- input: process.stdin,
83
- output: process.stdout,
84
- });
85
97
  let apiUrl = "";
86
98
  while (!apiUrl) {
87
99
  const answer = (await prompt("API URL: ", rl)).trim();
@@ -48,9 +48,10 @@ class HistorySuggester {
48
48
  .catch(() => { });
49
49
  await fs_1.promises.appendFile(this.filePath, this.serializeHistoryEntry(command), "utf8");
50
50
  }
51
- async onCommandRan(command) {
51
+ async onCommandWillRun(command) {
52
52
  await this.add(command);
53
53
  }
54
+ async onCommandRan(command) { }
54
55
  latest() {
55
56
  return this.filteredItems;
56
57
  }
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/main.js CHANGED
@@ -4,6 +4,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const fs_1 = require("fs");
5
5
  const path_1 = require("path");
6
6
  const app_1 = require("./app");
7
+ const ai_suggester_1 = require("./ai-suggester");
8
+ const carousel_1 = require("./carousel");
7
9
  const hello_new_user_1 = require("./hello-new-user");
8
10
  const logs_1 = require("./logs");
9
11
  const config_1 = require("./config");
@@ -25,7 +27,11 @@ async function main() {
25
27
  if (!(await (0, config_1.doesConfigExist)())) {
26
28
  await (0, hello_new_user_1.runHelloNewUserFlow)((0, config_1.getConfigPath)());
27
29
  }
28
- const app = new app_1.App();
30
+ const config = await (0, config_1.getConfig)();
31
+ const bottomPanel = config.apiUrl && config.apiKey && config.model
32
+ ? new ai_suggester_1.AISuggester()
33
+ : new carousel_1.NullSuggester();
34
+ const app = new app_1.App({ bottomPanel });
29
35
  await app.run();
30
36
  }
31
37
  main().catch((err) => {
package/dist/spawner.js CHANGED
@@ -144,10 +144,19 @@ async function runUserCommand(command) {
144
144
  stdio: "inherit",
145
145
  windowsVerbatimArguments: true,
146
146
  });
147
- await new Promise((resolve, reject) => {
148
- proc.on("error", reject);
149
- proc.on("close", () => resolve());
150
- });
147
+ // While a user command owns the terminal, Ctrl+C should interrupt that command
148
+ // without taking down the parent Caroushell process.
149
+ const ignoreSigint = () => { };
150
+ process.on("SIGINT", ignoreSigint);
151
+ try {
152
+ await new Promise((resolve, reject) => {
153
+ proc.on("error", reject);
154
+ proc.on("close", () => resolve());
155
+ });
156
+ }
157
+ finally {
158
+ process.off("SIGINT", ignoreSigint);
159
+ }
151
160
  // Why save failed commands? Well eg sometimes we want to run a test
152
161
  // many times until we fix it.
153
162
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caroushell",
3
- "version": "0.1.21",
3
+ "version": "0.1.24",
4
4
  "description": "Terminal carousel that suggests commands from history, config, and AI.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/main.js",