caroushell 0.1.24 → 0.1.30
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 +33 -0
- package/dist/app.js +1 -0
- package/dist/carousel.js +2 -1
- package/dist/config.js +33 -0
- package/dist/hello-new-user.js +97 -42
- package/dist/main.js +1 -1
- package/dist/terminal.js +10 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -64,6 +64,39 @@ OpenAI, etc.) will work as long as the URL, key, and model are valid. If you
|
|
|
64
64
|
only provide a Gemini API key in the config, Caroushell will default to the
|
|
65
65
|
Gemini Flash Lite 2.5 endpoint and model.
|
|
66
66
|
|
|
67
|
+
## Prompt Display
|
|
68
|
+
|
|
69
|
+
Caroushell can render a custom prompt using a template string in
|
|
70
|
+
`~/.caroushell/config.toml`:
|
|
71
|
+
|
|
72
|
+
```toml
|
|
73
|
+
prompt = "{hostname} {short-directory} $>"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Available tokens:
|
|
77
|
+
|
|
78
|
+
- `{hostname}`
|
|
79
|
+
- `{directory}`
|
|
80
|
+
- `{short-directory}`
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
|
|
84
|
+
```toml
|
|
85
|
+
prompt = "$> "
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```toml
|
|
89
|
+
prompt = "{directory} $> "
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
prompt = "{hostname} {short-directory} $>"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`{short-directory}` keeps the final directory name and shortens parent
|
|
97
|
+
directories to their first letter. For example, `/home/user/projects/my-app`
|
|
98
|
+
becomes `/h/u/p/my-app`.
|
|
99
|
+
|
|
67
100
|
## Installation
|
|
68
101
|
|
|
69
102
|
Install globally (recommended):
|
package/dist/app.js
CHANGED
package/dist/carousel.js
CHANGED
|
@@ -77,6 +77,7 @@ class Carousel {
|
|
|
77
77
|
this.bottom = opts.bottom;
|
|
78
78
|
this.topRowCount = opts.topRows;
|
|
79
79
|
this.bottomRowCount = opts.bottomRows;
|
|
80
|
+
this.promptLine0Getter = opts.promptLine0 ?? (() => "$> ");
|
|
80
81
|
}
|
|
81
82
|
async updateSuggestions(input) {
|
|
82
83
|
if (typeof input === "string") {
|
|
@@ -396,7 +397,7 @@ class Carousel {
|
|
|
396
397
|
return start;
|
|
397
398
|
}
|
|
398
399
|
getPromptPrefix(lineIndex) {
|
|
399
|
-
return lineIndex === 0 ?
|
|
400
|
+
return lineIndex === 0 ? this.promptLine0Getter() : "> ";
|
|
400
401
|
}
|
|
401
402
|
render() {
|
|
402
403
|
(0, logs_1.logLine)("Rendering carousel");
|
package/dist/config.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.configFolder = configFolder;
|
|
|
40
40
|
exports.getConfigPath = getConfigPath;
|
|
41
41
|
exports.doesConfigExist = doesConfigExist;
|
|
42
42
|
exports.getConfig = getConfig;
|
|
43
|
+
exports.buildPromptLine0 = buildPromptLine0;
|
|
43
44
|
const fs_1 = require("fs");
|
|
44
45
|
const path = __importStar(require("path"));
|
|
45
46
|
const os = __importStar(require("os"));
|
|
@@ -113,3 +114,35 @@ function isGeminiUrl(url) {
|
|
|
113
114
|
return (lower.includes("generativelanguage.googleapis.com") ||
|
|
114
115
|
lower.includes("gemini"));
|
|
115
116
|
}
|
|
117
|
+
function normalizeCwd(cwd) {
|
|
118
|
+
const normalized = cwd.replace(/\\/g, "/");
|
|
119
|
+
const home = os.homedir().replace(/\\/g, "/");
|
|
120
|
+
if (normalized === home)
|
|
121
|
+
return "~";
|
|
122
|
+
if (normalized.startsWith(home + "/")) {
|
|
123
|
+
return "~" + normalized.slice(home.length);
|
|
124
|
+
}
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
function shortenPath(p) {
|
|
128
|
+
const parts = p.split("/");
|
|
129
|
+
return parts
|
|
130
|
+
.map((part, i) => {
|
|
131
|
+
if (i === parts.length - 1)
|
|
132
|
+
return part;
|
|
133
|
+
if (part === "" || part === "~")
|
|
134
|
+
return part;
|
|
135
|
+
return part[0];
|
|
136
|
+
})
|
|
137
|
+
.join("/");
|
|
138
|
+
}
|
|
139
|
+
function buildPromptLine0(config) {
|
|
140
|
+
return () => {
|
|
141
|
+
const normalized = normalizeCwd(process.cwd());
|
|
142
|
+
const template = config.prompt ?? "$> ";
|
|
143
|
+
return template
|
|
144
|
+
.replace(/\{hostname\}/g, os.hostname())
|
|
145
|
+
.replace(/\{directory\}/g, normalized)
|
|
146
|
+
.replace(/\{short-directory\}/g, shortenPath(normalized));
|
|
147
|
+
};
|
|
148
|
+
}
|
package/dist/hello-new-user.js
CHANGED
|
@@ -42,6 +42,42 @@ const path = __importStar(require("path"));
|
|
|
42
42
|
const readline_1 = __importDefault(require("readline"));
|
|
43
43
|
const ai_suggester_1 = require("./ai-suggester");
|
|
44
44
|
const preferredModels = ["gemini-2.5-flash-lite", "gpt-4o-mini"];
|
|
45
|
+
const defaultPromptTemplate = "$> ";
|
|
46
|
+
function serializeToml(obj) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
const sections = [];
|
|
49
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
50
|
+
if (value !== undefined && typeof value === "object" && value !== null) {
|
|
51
|
+
sections.push([key, value]);
|
|
52
|
+
}
|
|
53
|
+
else if (value !== undefined) {
|
|
54
|
+
lines.push(`${key} = ${JSON.stringify(value)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const [section, values] of sections) {
|
|
58
|
+
lines.push(`\n[${section}]`);
|
|
59
|
+
for (const [key, value] of Object.entries(values)) {
|
|
60
|
+
if (value !== undefined) {
|
|
61
|
+
lines.push(`${key} = ${JSON.stringify(value)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
67
|
+
async function askPromptConfig(prompter, logFn) {
|
|
68
|
+
logFn("Customize your shell prompt with plain text and tokens.");
|
|
69
|
+
logFn("Available tokens:");
|
|
70
|
+
logFn(" {hostname}");
|
|
71
|
+
logFn(" {directory}");
|
|
72
|
+
logFn(" {short-directory}");
|
|
73
|
+
logFn(`Press Enter to keep the default prompt: ${defaultPromptTemplate}`);
|
|
74
|
+
const answer = await prompter.ask("Prompt template: ");
|
|
75
|
+
const trimmed = answer.trim();
|
|
76
|
+
if (!trimmed || trimmed === defaultPromptTemplate.trim()) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return answer;
|
|
80
|
+
}
|
|
45
81
|
function isInteractive() {
|
|
46
82
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
47
83
|
}
|
|
@@ -50,6 +86,25 @@ async function prompt(question, rl) {
|
|
|
50
86
|
rl.question(question, (answer) => resolve(answer));
|
|
51
87
|
});
|
|
52
88
|
}
|
|
89
|
+
function createReadlineTerminal() {
|
|
90
|
+
return {
|
|
91
|
+
createPrompter() {
|
|
92
|
+
const rl = readline_1.default.createInterface({
|
|
93
|
+
input: process.stdin,
|
|
94
|
+
output: process.stdout,
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
ask(question) {
|
|
98
|
+
return prompt(question, rl);
|
|
99
|
+
},
|
|
100
|
+
close() {
|
|
101
|
+
rl.close();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
isInteractive,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
53
108
|
function findShortestMatches(models, preferredList) {
|
|
54
109
|
const matches = [];
|
|
55
110
|
for (const pref of preferredList) {
|
|
@@ -61,94 +116,94 @@ function findShortestMatches(models, preferredList) {
|
|
|
61
116
|
}
|
|
62
117
|
return [...new Set(matches)];
|
|
63
118
|
}
|
|
64
|
-
async function runHelloNewUserFlow(configPath) {
|
|
65
|
-
|
|
119
|
+
async function runHelloNewUserFlow(configPath, deps = {}) {
|
|
120
|
+
const fileSystem = deps.fsOps ?? fs_1.promises;
|
|
121
|
+
const listModelsFn = deps.listModelsFn ?? ai_suggester_1.listModels;
|
|
122
|
+
const logFn = deps.logFn ?? console.log;
|
|
123
|
+
const terminal = deps.terminal ?? createReadlineTerminal();
|
|
124
|
+
if (!terminal.isInteractive()) {
|
|
66
125
|
throw new Error(`Missing config at ${configPath} and no interactive terminal is available.\n` +
|
|
67
126
|
"Create the file manually or run Caroushell from a TTY.");
|
|
68
127
|
}
|
|
69
128
|
const dir = path.dirname(configPath);
|
|
70
|
-
await
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
});
|
|
78
|
-
const wantsAi = (await prompt("Do you want to set up AI auto-complete? (y/n): ", rl))
|
|
129
|
+
await fileSystem.mkdir(dir, { recursive: true });
|
|
130
|
+
logFn("");
|
|
131
|
+
logFn("Welcome to Caroushell!");
|
|
132
|
+
logFn("");
|
|
133
|
+
const prompter = terminal.createPrompter();
|
|
134
|
+
const promptConfig = await askPromptConfig(prompter, logFn);
|
|
135
|
+
const wantsAi = (await prompter.ask("Do you want to set up AI auto-complete? (y/n): "))
|
|
79
136
|
.trim()
|
|
80
137
|
.toLowerCase();
|
|
81
138
|
if (wantsAi !== "y" && wantsAi !== "yes") {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
await
|
|
85
|
-
|
|
86
|
-
|
|
139
|
+
prompter.close();
|
|
140
|
+
const config = { noAi: true, prompt: promptConfig };
|
|
141
|
+
await fileSystem.writeFile(configPath, serializeToml(config) + "\n", "utf8");
|
|
142
|
+
logFn("\nSkipping AI setup. You can set it up later by editing " + configPath);
|
|
143
|
+
logFn("");
|
|
87
144
|
return null;
|
|
88
145
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
146
|
+
logFn(`\nLet's set up AI suggestions. You'll need an API endpoint URL, a key, and model id. These will be stored at ${configPath}`);
|
|
147
|
+
logFn("");
|
|
148
|
+
logFn("Some example endpoints you can paste:");
|
|
149
|
+
logFn(" - OpenRouter: https://openrouter.ai/api/v1");
|
|
150
|
+
logFn(" - OpenAI: https://api.openai.com/v1");
|
|
151
|
+
logFn(" - Google: https://generativelanguage.googleapis.com/v1beta/openai");
|
|
152
|
+
logFn("");
|
|
153
|
+
logFn("Press Ctrl+C any time to abort.\n");
|
|
97
154
|
let apiUrl = "";
|
|
98
155
|
while (!apiUrl) {
|
|
99
|
-
const answer = (await
|
|
156
|
+
const answer = (await prompter.ask("API URL: ")).trim();
|
|
100
157
|
if (answer) {
|
|
101
158
|
apiUrl = answer;
|
|
102
159
|
}
|
|
103
160
|
else {
|
|
104
|
-
|
|
161
|
+
logFn("Please enter a URL (example: https://openrouter.ai/api/v1)");
|
|
105
162
|
}
|
|
106
163
|
}
|
|
107
164
|
let apiKey = "";
|
|
108
165
|
while (!apiKey) {
|
|
109
|
-
const answer = (await
|
|
166
|
+
const answer = (await prompter.ask("API key: ")).trim();
|
|
110
167
|
if (answer) {
|
|
111
168
|
apiKey = answer;
|
|
112
169
|
}
|
|
113
170
|
else {
|
|
114
|
-
|
|
171
|
+
logFn("Please enter an API key. The value is stored in the local config file.");
|
|
115
172
|
}
|
|
116
173
|
}
|
|
117
|
-
const models = await (
|
|
174
|
+
const models = await listModelsFn(apiUrl, apiKey);
|
|
118
175
|
if (models.length > 0) {
|
|
119
176
|
const preferred = findShortestMatches(models, preferredModels);
|
|
120
|
-
|
|
177
|
+
logFn("Here are a few example model ids from your api service. Choose a fast and cheap model because AI suggestions happen as you type.");
|
|
121
178
|
for (const model of models.slice(0, 5)) {
|
|
122
|
-
|
|
179
|
+
logFn(` - ${model}`);
|
|
123
180
|
}
|
|
124
181
|
if (preferred.length) {
|
|
125
|
-
|
|
182
|
+
logFn("Recommended models from your provider:");
|
|
126
183
|
for (const model of preferred) {
|
|
127
|
-
|
|
184
|
+
logFn(` - ${model}`);
|
|
128
185
|
}
|
|
129
186
|
}
|
|
130
187
|
}
|
|
131
188
|
let model = "";
|
|
132
189
|
while (!model) {
|
|
133
|
-
const answer = (await
|
|
190
|
+
const answer = (await prompter.ask("Model id: ")).trim();
|
|
134
191
|
if (answer) {
|
|
135
192
|
model = answer;
|
|
136
193
|
}
|
|
137
194
|
else {
|
|
138
|
-
|
|
195
|
+
logFn("Please enter a model id (example: google/gemini-2.5-flash-lite, mistralai/mistral-small-24b-instruct-2501).");
|
|
139
196
|
}
|
|
140
197
|
}
|
|
141
|
-
|
|
198
|
+
prompter.close();
|
|
142
199
|
const config = {
|
|
143
200
|
apiUrl,
|
|
144
201
|
apiKey,
|
|
145
202
|
model,
|
|
203
|
+
prompt: promptConfig,
|
|
146
204
|
};
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
await fs_1.promises.writeFile(configPath, tomlBody + "\n", "utf8");
|
|
151
|
-
console.log(`\nSaved config to ${configPath}`);
|
|
152
|
-
console.log("You can edit this file later if you want to switch providers.\n");
|
|
205
|
+
await fileSystem.writeFile(configPath, serializeToml(config) + "\n", "utf8");
|
|
206
|
+
logFn(`\nSaved config to ${configPath}`);
|
|
207
|
+
logFn("You can edit this file later if you want to switch providers.\n");
|
|
153
208
|
return config;
|
|
154
209
|
}
|
package/dist/main.js
CHANGED
|
@@ -31,7 +31,7 @@ async function main() {
|
|
|
31
31
|
const bottomPanel = config.apiUrl && config.apiKey && config.model
|
|
32
32
|
? new ai_suggester_1.AISuggester()
|
|
33
33
|
: new carousel_1.NullSuggester();
|
|
34
|
-
const app = new app_1.App({ bottomPanel });
|
|
34
|
+
const app = new app_1.App({ bottomPanel, promptLine0: (0, config_1.buildPromptLine0)(config) });
|
|
35
35
|
await app.run();
|
|
36
36
|
}
|
|
37
37
|
main().catch((err) => {
|
package/dist/terminal.js
CHANGED
|
@@ -73,25 +73,30 @@ class Terminal {
|
|
|
73
73
|
this.out.write(text);
|
|
74
74
|
}
|
|
75
75
|
hideCursor() {
|
|
76
|
-
this.
|
|
76
|
+
if (!this.canWrite())
|
|
77
|
+
return;
|
|
78
|
+
this.out.write("\x1b[?25l");
|
|
77
79
|
}
|
|
78
80
|
showCursor() {
|
|
79
|
-
this.
|
|
81
|
+
if (!this.canWrite())
|
|
82
|
+
return;
|
|
83
|
+
this.out.write("\x1b[?25h");
|
|
80
84
|
}
|
|
81
85
|
// Render a block of lines by clearing previous block (if any) and writing fresh
|
|
82
86
|
renderBlock(lines, cursorRow, cursorCol) {
|
|
83
87
|
if (!this.canWrite())
|
|
84
88
|
return;
|
|
85
89
|
this.withCork(() => {
|
|
90
|
+
this.hideCursor();
|
|
86
91
|
this.moveCursorToTopOfBlock();
|
|
87
92
|
if (this.activeRows > 0) {
|
|
88
93
|
readline_1.default.cursorTo(this.out, 0);
|
|
89
94
|
readline_1.default.clearScreenDown(this.out);
|
|
90
95
|
}
|
|
91
96
|
for (let i = 0; i < lines.length; i++) {
|
|
92
|
-
this.write(lines[i]);
|
|
97
|
+
this.out.write(lines[i]);
|
|
93
98
|
if (i < lines.length - 1)
|
|
94
|
-
this.write("\n");
|
|
99
|
+
this.out.write("\n");
|
|
95
100
|
}
|
|
96
101
|
this.activeRows = lines.length;
|
|
97
102
|
this.cursorRow = Math.max(0, this.activeRows - 1);
|
|
@@ -105,6 +110,7 @@ class Terminal {
|
|
|
105
110
|
const targetCol = Math.max(0, cursorCol ?? this.cursorCol);
|
|
106
111
|
this.moveCursorTo(targetRow, targetCol);
|
|
107
112
|
}
|
|
113
|
+
this.showCursor();
|
|
108
114
|
});
|
|
109
115
|
}
|
|
110
116
|
moveCursorTo(lineIndex, column) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "caroushell",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "Terminal carousel that suggests commands from history, config, and AI.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "dist/main.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"prepare": "npm run build",
|
|
19
19
|
"start": "node dist/main.js",
|
|
20
20
|
"test": "node --import tsx --test tests/*.ts",
|
|
21
|
-
"test:generate": "tsx src/test-generate.ts",
|
|
21
|
+
"test:ai-generate": "tsx src/test-generate.ts",
|
|
22
22
|
"lint": "eslint . --ext .ts",
|
|
23
23
|
"release": "tsx scripts/release.ts"
|
|
24
24
|
},
|