caroushell 0.1.13 → 0.1.15
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 +35 -5
- package/dist/ai-suggester.js +43 -2
- package/dist/app.js +3 -12
- package/dist/carousel.js +16 -25
- package/dist/file-suggester.js +7 -3
- package/dist/hello-new-user.js +19 -0
- package/dist/history-suggester.js +19 -12
- package/dist/spawner.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# Caroushell
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/caroushell)
|
|
4
|
+
[](https://www.npmjs.com/package/caroushell)
|
|
5
|
+
|
|
3
6
|
Caroushell is an interactive terminal carousel that suggests commands from your
|
|
4
7
|
history, and AI suggestions as you type.
|
|
5
8
|
|
|
6
9
|
## Features
|
|
7
10
|
|
|
8
|
-
- The top panel of the carousel shows history
|
|
11
|
+
- The top panel of the carousel shows history.
|
|
9
12
|
- The bottom panel of the carousel shows AI-generated command suggestions.
|
|
10
13
|
- Go up and down the carousel with arrow keys.
|
|
11
14
|
- Press `Enter` to run the highlighted command.
|
|
@@ -13,6 +16,32 @@ history, and AI suggestions as you type.
|
|
|
13
16
|
- Extensible config file (`~/.caroushell/config.toml`) so you can point the CLI
|
|
14
17
|
at different AI providers.
|
|
15
18
|
|
|
19
|
+
## UI
|
|
20
|
+
|
|
21
|
+
The UI layout looks like this:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
⌛history2
|
|
25
|
+
⌛history1
|
|
26
|
+
$> YOU TYPE YOUR SHELL COMMANDS HERE
|
|
27
|
+
🤖ai suggestion1
|
|
28
|
+
🤖ai suggestion2
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Here's an example using a comment to get AI autocompletion for ffmpeg:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
⌛echo 123
|
|
35
|
+
⌛cd
|
|
36
|
+
$> ffmpeg -i myvideo.mp4 # slowmo 50%
|
|
37
|
+
🤖ffmpeg -i myvideo.mp4 -filter:v "setpts=2.0*PTS" output_slow.mp4
|
|
38
|
+
🤖ffmpeg -i myvideo.mp4 -vf "setpts=0.5*PTS" output_fast.mp4
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It would look like this:
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
16
45
|
## Requirements
|
|
17
46
|
|
|
18
47
|
- Node.js 18 or newer.
|
|
@@ -46,7 +75,7 @@ npm install -g caroushell
|
|
|
46
75
|
caroushell
|
|
47
76
|
```
|
|
48
77
|
|
|
49
|
-
Or run it
|
|
78
|
+
Or run it with NPX:
|
|
50
79
|
|
|
51
80
|
```bash
|
|
52
81
|
npx caroushell
|
|
@@ -61,6 +90,8 @@ Caroushell opens an interactive prompt:
|
|
|
61
90
|
- Use arrow keys to move between suggestions in the carousel.
|
|
62
91
|
- Press `Enter` to run the highlighted command.
|
|
63
92
|
- Press `Ctrl+C` to exit. `Ctrl+D` exits when the current row is empty.
|
|
93
|
+
- Press `Tab` to autocomplete a file suggestion or browse files and folders with
|
|
94
|
+
the arrow keys.
|
|
64
95
|
|
|
65
96
|
Logs are written to `~/.caroushell/logs/MM-DD.txt`. Inspect these files if you
|
|
66
97
|
need to debug AI suggestions or the terminal renderer. Configuration lives at
|
|
@@ -69,9 +100,8 @@ need to debug AI suggestions or the terminal renderer. Configuration lives at
|
|
|
69
100
|
## Development
|
|
70
101
|
|
|
71
102
|
```bash
|
|
72
|
-
npm install
|
|
73
|
-
npm run dev
|
|
74
|
-
npm run build
|
|
103
|
+
npm install # install dependencies
|
|
104
|
+
npm run dev # run the shell
|
|
75
105
|
npm run test:generate # tests ai text generation
|
|
76
106
|
npm publish --dry-run # verify package contents before publishing
|
|
77
107
|
```
|
package/dist/ai-suggester.js
CHANGED
|
@@ -5,6 +5,27 @@ exports.generateContent = generateContent;
|
|
|
5
5
|
exports.listModels = listModels;
|
|
6
6
|
const logs_1 = require("./logs");
|
|
7
7
|
const config_1 = require("./config");
|
|
8
|
+
function debounceAsync(fn, delayMs) {
|
|
9
|
+
let tim = null;
|
|
10
|
+
let rejectLast = null;
|
|
11
|
+
return (...args) => {
|
|
12
|
+
if (tim)
|
|
13
|
+
clearTimeout(tim);
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
if (rejectLast) {
|
|
16
|
+
rejectLast(new Error("debounced"));
|
|
17
|
+
}
|
|
18
|
+
rejectLast = reject;
|
|
19
|
+
tim = setTimeout(() => {
|
|
20
|
+
tim = null;
|
|
21
|
+
rejectLast = null;
|
|
22
|
+
fn(...args)
|
|
23
|
+
.then(resolve)
|
|
24
|
+
.catch(reject);
|
|
25
|
+
}, delayMs);
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
}
|
|
8
29
|
async function generateContent(prompt, options) {
|
|
9
30
|
const apiKey = options?.apiKey ||
|
|
10
31
|
process.env.CAROUSHELL_API_KEY ||
|
|
@@ -100,6 +121,8 @@ async function extractText(json) {
|
|
|
100
121
|
class AISuggester {
|
|
101
122
|
constructor(opts) {
|
|
102
123
|
this.prefix = "🤖";
|
|
124
|
+
this.latestSuggestions = [];
|
|
125
|
+
this.debouncedSuggest = debounceAsync(this.runSuggestNow.bind(this), 350);
|
|
103
126
|
this.apiKey = opts?.apiKey;
|
|
104
127
|
this.apiUrl = opts?.apiUrl;
|
|
105
128
|
this.model = opts?.model;
|
|
@@ -118,11 +141,29 @@ class AISuggester {
|
|
|
118
141
|
descriptionForAi() {
|
|
119
142
|
return "";
|
|
120
143
|
}
|
|
121
|
-
|
|
144
|
+
latest() {
|
|
145
|
+
return this.latestSuggestions;
|
|
146
|
+
}
|
|
147
|
+
async refreshSuggestions(carousel, maxDisplayed) {
|
|
122
148
|
if (!this.apiKey || !this.apiUrl || !this.model) {
|
|
123
149
|
(0, logs_1.logLine)("AI generation skipped: missing API configuration");
|
|
124
|
-
|
|
150
|
+
this.latestSuggestions = [];
|
|
151
|
+
carousel.render();
|
|
152
|
+
return;
|
|
125
153
|
}
|
|
154
|
+
try {
|
|
155
|
+
const suggestions = await this.debouncedSuggest(carousel, maxDisplayed);
|
|
156
|
+
this.latestSuggestions = suggestions.slice(0, maxDisplayed);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
if (err?.message !== "debounced") {
|
|
160
|
+
(0, logs_1.logLine)("ai suggest error: " + err?.message);
|
|
161
|
+
}
|
|
162
|
+
this.latestSuggestions = [];
|
|
163
|
+
}
|
|
164
|
+
carousel.render();
|
|
165
|
+
}
|
|
166
|
+
async runSuggestNow(carousel, maxDisplayed) {
|
|
126
167
|
const descriptions = [];
|
|
127
168
|
for (const suggester of carousel.getSuggesters()) {
|
|
128
169
|
const desc = suggester.descriptionForAi();
|
package/dist/app.js
CHANGED
|
@@ -8,15 +8,6 @@ const history_suggester_1 = require("./history-suggester");
|
|
|
8
8
|
const ai_suggester_1 = require("./ai-suggester");
|
|
9
9
|
const file_suggester_1 = require("./file-suggester");
|
|
10
10
|
const spawner_1 = require("./spawner");
|
|
11
|
-
function debounce(fn, ms) {
|
|
12
|
-
// Debounce function to limit the rate at which a function can fire
|
|
13
|
-
let t = null;
|
|
14
|
-
return (...args) => {
|
|
15
|
-
if (t)
|
|
16
|
-
clearTimeout(t);
|
|
17
|
-
t = setTimeout(() => fn(...args), ms);
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
11
|
class App {
|
|
21
12
|
constructor() {
|
|
22
13
|
this.usingFileSuggestions = false;
|
|
@@ -32,9 +23,9 @@ class App {
|
|
|
32
23
|
bottomRows: 2,
|
|
33
24
|
terminal: this.terminal,
|
|
34
25
|
});
|
|
35
|
-
this.queueUpdateSuggestions =
|
|
36
|
-
|
|
37
|
-
}
|
|
26
|
+
this.queueUpdateSuggestions = () => {
|
|
27
|
+
void this.carousel.updateSuggestions();
|
|
28
|
+
};
|
|
38
29
|
this.handlers = {
|
|
39
30
|
"ctrl-c": () => {
|
|
40
31
|
if (this.carousel.isPromptRowSelected() && !this.carousel.hasInput()) {
|
package/dist/carousel.js
CHANGED
|
@@ -5,61 +5,49 @@ const logs_1 = require("./logs");
|
|
|
5
5
|
const terminal_1 = require("./terminal");
|
|
6
6
|
class Carousel {
|
|
7
7
|
constructor(opts) {
|
|
8
|
-
this.latestTop = [];
|
|
9
|
-
this.latestBottom = [];
|
|
10
8
|
this.index = 0;
|
|
11
9
|
this.inputBuffer = "";
|
|
12
10
|
this.inputCursor = 0;
|
|
13
|
-
this.emptyRow = "---";
|
|
14
11
|
this.terminal = opts.terminal;
|
|
15
12
|
this.top = opts.top;
|
|
16
13
|
this.bottom = opts.bottom;
|
|
17
14
|
this.topRowCount = opts.topRows;
|
|
18
15
|
this.bottomRowCount = opts.bottomRows;
|
|
19
|
-
this.latestTop = this.createEmptyRows(this.topRowCount);
|
|
20
|
-
this.latestBottom = this.createEmptyRows(this.bottomRowCount);
|
|
21
|
-
}
|
|
22
|
-
createEmptyRows(count) {
|
|
23
|
-
return Array(count).fill(this.emptyRow);
|
|
24
16
|
}
|
|
25
17
|
async updateSuggestions(input) {
|
|
26
18
|
if (typeof input === "string") {
|
|
27
19
|
this.setInputBuffer(input);
|
|
28
20
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
void topPromise.then((r) => {
|
|
32
|
-
this.latestTop = r;
|
|
33
|
-
this.render();
|
|
34
|
-
});
|
|
35
|
-
void bottomPromise.then((r) => {
|
|
36
|
-
this.latestBottom = r;
|
|
37
|
-
this.render();
|
|
38
|
-
});
|
|
21
|
+
void this.top.refreshSuggestions(this, this.topRowCount);
|
|
22
|
+
void this.bottom.refreshSuggestions(this, this.bottomRowCount);
|
|
39
23
|
}
|
|
40
24
|
up() {
|
|
41
25
|
this.index += 1;
|
|
42
|
-
|
|
43
|
-
|
|
26
|
+
const topLength = this.top.latest().length;
|
|
27
|
+
if (this.index >= topLength) {
|
|
28
|
+
this.index = topLength;
|
|
44
29
|
}
|
|
45
30
|
}
|
|
46
31
|
down() {
|
|
47
32
|
this.index -= 1;
|
|
48
|
-
|
|
49
|
-
|
|
33
|
+
const bottomLength = this.bottom.latest().length;
|
|
34
|
+
if (-this.index >= bottomLength) {
|
|
35
|
+
this.index = -bottomLength;
|
|
50
36
|
}
|
|
51
37
|
}
|
|
52
38
|
getRow(rowIndex) {
|
|
39
|
+
const latestTop = this.top.latest();
|
|
40
|
+
const latestBottom = this.bottom.latest();
|
|
53
41
|
if (rowIndex < 0) {
|
|
54
42
|
const bottomIndex = -rowIndex - 1;
|
|
55
|
-
return
|
|
43
|
+
return latestBottom[bottomIndex] || "";
|
|
56
44
|
}
|
|
57
45
|
if (rowIndex === 0) {
|
|
58
46
|
return this.inputBuffer;
|
|
59
47
|
}
|
|
60
48
|
if (rowIndex > 0) {
|
|
61
49
|
const topIndex = rowIndex - 1;
|
|
62
|
-
return
|
|
50
|
+
return latestTop[topIndex] || "";
|
|
63
51
|
}
|
|
64
52
|
return "";
|
|
65
53
|
}
|
|
@@ -259,7 +247,10 @@ class Carousel {
|
|
|
259
247
|
if (this.top === suggester)
|
|
260
248
|
return;
|
|
261
249
|
this.top = suggester;
|
|
262
|
-
this.
|
|
250
|
+
if (this.index > 0) {
|
|
251
|
+
const topLength = this.top.latest().length;
|
|
252
|
+
this.index = Math.min(this.index, topLength);
|
|
253
|
+
}
|
|
263
254
|
}
|
|
264
255
|
getSuggesters() {
|
|
265
256
|
return [this.top, this.bottom];
|
package/dist/file-suggester.js
CHANGED
|
@@ -12,6 +12,7 @@ class FileSuggester {
|
|
|
12
12
|
constructor() {
|
|
13
13
|
this.prefix = "📂";
|
|
14
14
|
this.files = [];
|
|
15
|
+
this.latestSuggestions = [];
|
|
15
16
|
}
|
|
16
17
|
async init() {
|
|
17
18
|
await this.refreshFiles();
|
|
@@ -75,10 +76,13 @@ class FileSuggester {
|
|
|
75
76
|
const converted = dirDisplay.replace(/\//g, path_1.default.sep);
|
|
76
77
|
return path_1.default.resolve(process.cwd(), converted);
|
|
77
78
|
}
|
|
78
|
-
|
|
79
|
+
latest() {
|
|
80
|
+
return this.latestSuggestions;
|
|
81
|
+
}
|
|
82
|
+
async refreshSuggestions(carousel, maxDisplayed) {
|
|
79
83
|
const { prefix } = carousel.getWordInfoAtCursor();
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
this.latestSuggestions = await this.getMatchingFiles(prefix);
|
|
85
|
+
carousel.render();
|
|
82
86
|
}
|
|
83
87
|
async findUniqueMatch(prefix) {
|
|
84
88
|
const normalized = prefix.trim();
|
package/dist/hello-new-user.js
CHANGED
|
@@ -41,6 +41,7 @@ const fs_1 = require("fs");
|
|
|
41
41
|
const path = __importStar(require("path"));
|
|
42
42
|
const readline_1 = __importDefault(require("readline"));
|
|
43
43
|
const ai_suggester_1 = require("./ai-suggester");
|
|
44
|
+
const preferredModels = ["gemini-2.5-flash-lite", "gpt-4o-mini"];
|
|
44
45
|
function isInteractive() {
|
|
45
46
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
46
47
|
}
|
|
@@ -49,6 +50,17 @@ async function prompt(question, rl) {
|
|
|
49
50
|
rl.question(question, (answer) => resolve(answer));
|
|
50
51
|
});
|
|
51
52
|
}
|
|
53
|
+
function findShortestMatches(models, preferredList) {
|
|
54
|
+
const matches = [];
|
|
55
|
+
for (const pref of preferredList) {
|
|
56
|
+
const hits = models.filter((modelId) => modelId.includes(pref));
|
|
57
|
+
if (hits.length) {
|
|
58
|
+
const shortest = hits.reduce((best, candidate) => candidate.length < best.length ? candidate : best);
|
|
59
|
+
matches.push(shortest);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...new Set(matches)];
|
|
63
|
+
}
|
|
52
64
|
async function runHelloNewUserFlow(configPath) {
|
|
53
65
|
if (!isInteractive()) {
|
|
54
66
|
throw new Error(`Missing config at ${configPath} and no interactive terminal is available.\n` +
|
|
@@ -92,10 +104,17 @@ async function runHelloNewUserFlow(configPath) {
|
|
|
92
104
|
}
|
|
93
105
|
const models = await (0, ai_suggester_1.listModels)(apiUrl, apiKey);
|
|
94
106
|
if (models.length > 0) {
|
|
107
|
+
const preferred = findShortestMatches(models, preferredModels);
|
|
95
108
|
console.log("Here are a few example model ids from your api service. Choose a fast and cheap model because AI suggestions happen as you type.");
|
|
96
109
|
for (const model of models.slice(0, 5)) {
|
|
97
110
|
console.log(` - ${model}`);
|
|
98
111
|
}
|
|
112
|
+
if (preferred.length) {
|
|
113
|
+
console.log("Recommended models from your provider:");
|
|
114
|
+
for (const model of preferred) {
|
|
115
|
+
console.log(` - ${model}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
99
118
|
}
|
|
100
119
|
let model = "";
|
|
101
120
|
while (!model) {
|
|
@@ -44,24 +44,31 @@ class HistorySuggester {
|
|
|
44
44
|
.catch(() => { });
|
|
45
45
|
await fs_1.promises.appendFile(this.filePath, this.serializeHistoryEntry(command), "utf8");
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
latest() {
|
|
48
|
+
return this.items;
|
|
49
|
+
}
|
|
50
|
+
async refreshSuggestions(carousel, maxDisplayed) {
|
|
48
51
|
const input = carousel.getCurrentRow();
|
|
52
|
+
let results;
|
|
49
53
|
if (!input) {
|
|
50
54
|
// this.items 0 index is newest
|
|
51
|
-
|
|
55
|
+
results = this.items;
|
|
52
56
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
seen.
|
|
61
|
-
|
|
57
|
+
else {
|
|
58
|
+
const q = input.toLowerCase();
|
|
59
|
+
const matched = [];
|
|
60
|
+
// iterate from newest to oldest so we skip older duplicates
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (let i = 0; i < this.items.length; i++) {
|
|
63
|
+
const it = this.items[i];
|
|
64
|
+
if (it.toLowerCase().includes(q) && !seen.has(it)) {
|
|
65
|
+
seen.add(it);
|
|
66
|
+
matched.push(it);
|
|
67
|
+
}
|
|
62
68
|
}
|
|
69
|
+
results = matched;
|
|
63
70
|
}
|
|
64
|
-
|
|
71
|
+
carousel.render();
|
|
65
72
|
}
|
|
66
73
|
descriptionForAi() {
|
|
67
74
|
const lines = [];
|
package/dist/spawner.js
CHANGED
|
@@ -54,8 +54,11 @@ async function runUserCommand(command) {
|
|
|
54
54
|
if (typeof args[0] === "string" && builtInCommands[args[0]]) {
|
|
55
55
|
return await builtInCommands[args[0]](args);
|
|
56
56
|
}
|
|
57
|
+
// "shell: true" to prevent the bug of `echo "asdf"` outputting
|
|
58
|
+
// \"Asdf\" instead of "Asdf"
|
|
57
59
|
const proc = (0, child_process_1.spawn)(shellBinary, [...shellArgs, command], {
|
|
58
60
|
stdio: "inherit",
|
|
61
|
+
shell: true,
|
|
59
62
|
});
|
|
60
63
|
await new Promise((resolve, reject) => {
|
|
61
64
|
proc.on("error", reject);
|