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 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
@@ -26,6 +26,7 @@ class App {
26
26
  topRows: 2,
27
27
  bottomRows: this.bottomSuggester instanceof carousel_1.NullSuggester ? 0 : 2,
28
28
  terminal: this.terminal,
29
+ promptLine0: deps.promptLine0,
29
30
  });
30
31
  this.queueUpdateSuggestions = () => {
31
32
  void this.carousel.updateSuggestions();
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
+ }
@@ -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
- if (!isInteractive()) {
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 fs_1.promises.mkdir(dir, { recursive: true });
71
- console.log("");
72
- console.log("Welcome to Caroushell!");
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))
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
- 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("");
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
- 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}`);
90
- console.log("");
91
- console.log("Some example endpoints you can paste:");
92
- console.log(" - OpenRouter: https://openrouter.ai/api/v1");
93
- console.log(" - OpenAI: https://api.openai.com/v1");
94
- console.log(" - Google: https://generativelanguage.googleapis.com/v1beta/openai");
95
- console.log("");
96
- console.log("Press Ctrl+C any time to abort.\n");
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 prompt("API URL: ", rl)).trim();
156
+ const answer = (await prompter.ask("API URL: ")).trim();
100
157
  if (answer) {
101
158
  apiUrl = answer;
102
159
  }
103
160
  else {
104
- console.log("Please enter a URL (example: https://openrouter.ai/api/v1)");
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 prompt("API key: ", rl)).trim();
166
+ const answer = (await prompter.ask("API key: ")).trim();
110
167
  if (answer) {
111
168
  apiKey = answer;
112
169
  }
113
170
  else {
114
- console.log("Please enter an API key. The value is stored in the local config file.");
171
+ logFn("Please enter an API key. The value is stored in the local config file.");
115
172
  }
116
173
  }
117
- const models = await (0, ai_suggester_1.listModels)(apiUrl, apiKey);
174
+ const models = await listModelsFn(apiUrl, apiKey);
118
175
  if (models.length > 0) {
119
176
  const preferred = findShortestMatches(models, preferredModels);
120
- 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.");
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
- console.log(` - ${model}`);
179
+ logFn(` - ${model}`);
123
180
  }
124
181
  if (preferred.length) {
125
- console.log("Recommended models from your provider:");
182
+ logFn("Recommended models from your provider:");
126
183
  for (const model of preferred) {
127
- console.log(` - ${model}`);
184
+ logFn(` - ${model}`);
128
185
  }
129
186
  }
130
187
  }
131
188
  let model = "";
132
189
  while (!model) {
133
- const answer = (await prompt("Model id: ", rl)).trim();
190
+ const answer = (await prompter.ask("Model id: ")).trim();
134
191
  if (answer) {
135
192
  model = answer;
136
193
  }
137
194
  else {
138
- console.log("Please enter a model id (example: google/gemini-2.5-flash-lite, mistralai/mistral-small-24b-instruct-2501).");
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
- rl.close();
198
+ prompter.close();
142
199
  const config = {
143
200
  apiUrl,
144
201
  apiKey,
145
202
  model,
203
+ prompt: promptConfig,
146
204
  };
147
- const tomlBody = Object.entries(config)
148
- .map(([key, value]) => `${key} = ${JSON.stringify(value)}`)
149
- .join("\n");
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.write("\x1b[?25l");
76
+ if (!this.canWrite())
77
+ return;
78
+ this.out.write("\x1b[?25l");
77
79
  }
78
80
  showCursor() {
79
- this.write("\x1b[?25h");
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.24",
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
  },