hasina-gemini-cli 1.0.0
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/.env.example +4 -0
- package/README.md +334 -0
- package/bin/gemini-cli.js +3 -0
- package/data/sessions.json +3 -0
- package/package.json +51 -0
- package/src/app.js +285 -0
- package/src/config/env.js +93 -0
- package/src/config/gemini.js +294 -0
- package/src/index.js +23 -0
- package/src/services/chat.service.js +51 -0
- package/src/services/command.service.js +298 -0
- package/src/services/history.service.js +83 -0
- package/src/services/session.service.js +165 -0
- package/src/utils/banner.js +314 -0
- package/src/utils/file.js +57 -0
- package/src/utils/printer.js +147 -0
- package/src/utils/validators.js +67 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const figlet = require('figlet');
|
|
3
|
+
const readline = require('readline');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BANNER_OPTIONS = Object.freeze({
|
|
6
|
+
title: 'GEMINI CLI',
|
|
7
|
+
subtitle: 'Real Gemini API chat assistant for your terminal.',
|
|
8
|
+
fonts: ['Big', 'ANSI Shadow', 'Standard', 'Small'],
|
|
9
|
+
center: true,
|
|
10
|
+
animationDurationMs: 960,
|
|
11
|
+
frameIntervalMs: 120,
|
|
12
|
+
shadowLayers: Object.freeze([
|
|
13
|
+
{ offsetX: 3, offsetY: 1, color: '#20112f', dim: true },
|
|
14
|
+
{ offsetX: 1, offsetY: 0, color: '#5f42a1', dim: false },
|
|
15
|
+
]),
|
|
16
|
+
colors: Object.freeze({
|
|
17
|
+
foregroundStart: '#9985ff',
|
|
18
|
+
foregroundEnd: '#f0a3ff',
|
|
19
|
+
foregroundAccent: '#f7deff',
|
|
20
|
+
subtitle: '#9188a8',
|
|
21
|
+
divider: '#2d2344',
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function sleep(durationMs) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
setTimeout(resolve, durationMs);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mergeBannerOptions(options = {}) {
|
|
32
|
+
return {
|
|
33
|
+
...DEFAULT_BANNER_OPTIONS,
|
|
34
|
+
...options,
|
|
35
|
+
colors: {
|
|
36
|
+
...DEFAULT_BANNER_OPTIONS.colors,
|
|
37
|
+
...(options.colors || {}),
|
|
38
|
+
},
|
|
39
|
+
shadowLayers: Array.isArray(options.shadowLayers)
|
|
40
|
+
? options.shadowLayers
|
|
41
|
+
: DEFAULT_BANNER_OPTIONS.shadowLayers,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hexToRgb(hex) {
|
|
46
|
+
const normalized = hex.replace('#', '');
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
r: Number.parseInt(normalized.slice(0, 2), 16),
|
|
50
|
+
g: Number.parseInt(normalized.slice(2, 4), 16),
|
|
51
|
+
b: Number.parseInt(normalized.slice(4, 6), 16),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rgbToHex({ r, g, b }) {
|
|
56
|
+
return `#${[r, g, b]
|
|
57
|
+
.map((value) => Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, '0'))
|
|
58
|
+
.join('')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function blendHex(leftHex, rightHex, ratio) {
|
|
62
|
+
const left = hexToRgb(leftHex);
|
|
63
|
+
const right = hexToRgb(rightHex);
|
|
64
|
+
|
|
65
|
+
return rgbToHex({
|
|
66
|
+
r: left.r + (right.r - left.r) * ratio,
|
|
67
|
+
g: left.g + (right.g - left.g) * ratio,
|
|
68
|
+
b: left.b + (right.b - left.b) * ratio,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function lineWidth(value) {
|
|
73
|
+
return Array.from(value).length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getColumns() {
|
|
77
|
+
return Number.isInteger(process.stdout.columns) ? process.stdout.columns : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getMaxShadowOffsetX(settings) {
|
|
81
|
+
return Math.max(0, ...settings.shadowLayers.map((layer) => layer.offsetX || 0));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getMaxShadowOffsetY(settings) {
|
|
85
|
+
return Math.max(0, ...settings.shadowLayers.map((layer) => layer.offsetY || 0));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Render the title once per candidate font, then keep the largest font that still fits the terminal.
|
|
89
|
+
function selectAsciiTitle(settings) {
|
|
90
|
+
const columns = getColumns();
|
|
91
|
+
const extraAnimatedPadding = 1;
|
|
92
|
+
const terminalLimit = columns > 0 ? columns - 2 : Number.POSITIVE_INFINITY;
|
|
93
|
+
const renderedVariants = settings.fonts.map((font) => {
|
|
94
|
+
const rendered = figlet.textSync(settings.title, {
|
|
95
|
+
font,
|
|
96
|
+
horizontalLayout: 'fitted',
|
|
97
|
+
verticalLayout: 'default',
|
|
98
|
+
});
|
|
99
|
+
const lines = rendered.split(/\r?\n/).map((line) => line.replace(/\s+$/, ''));
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
font,
|
|
103
|
+
lines,
|
|
104
|
+
width: Math.max(...lines.map((line) => lineWidth(line)), 0),
|
|
105
|
+
height: lines.length,
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
renderedVariants.find(
|
|
111
|
+
(variant) =>
|
|
112
|
+
variant.width + getMaxShadowOffsetX(settings) + extraAnimatedPadding <= terminalLimit
|
|
113
|
+
) || renderedVariants[renderedVariants.length - 1]
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function createCanvas(width, height) {
|
|
118
|
+
return Array.from({ length: height }, () => Array.from({ length: width }, () => ' '));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildFrameState(frameIndex, animated) {
|
|
122
|
+
if (!animated) {
|
|
123
|
+
return {
|
|
124
|
+
foregroundShiftX: 0,
|
|
125
|
+
foregroundShiftY: 0,
|
|
126
|
+
shadowBumpX: 0,
|
|
127
|
+
shadowBumpY: 0,
|
|
128
|
+
glitchAmount: 0,
|
|
129
|
+
highlightBoost: 0,
|
|
130
|
+
seed: 0,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const frames = [
|
|
135
|
+
{ foregroundShiftX: 0, foregroundShiftY: 0, shadowBumpX: 0, shadowBumpY: 0, glitchAmount: 0.01, highlightBoost: 0.04, seed: 11 },
|
|
136
|
+
{ foregroundShiftX: 1, foregroundShiftY: 0, shadowBumpX: 1, shadowBumpY: 0, glitchAmount: 0.05, highlightBoost: 0.18, seed: 29 },
|
|
137
|
+
{ foregroundShiftX: 0, foregroundShiftY: 0, shadowBumpX: 0, shadowBumpY: 1, glitchAmount: 0.02, highlightBoost: 0.08, seed: 47 },
|
|
138
|
+
{ foregroundShiftX: 0, foregroundShiftY: 0, shadowBumpX: 0, shadowBumpY: 0, glitchAmount: 0, highlightBoost: 0, seed: 73 },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
return frames[frameIndex % frames.length];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function pseudoNoise(x, y, seed) {
|
|
145
|
+
const value = Math.sin((x + 1) * 12.9898 + (y + 1) * 78.233 + seed * 19.19) * 43758.5453;
|
|
146
|
+
return value - Math.floor(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function applyLayer(canvas, asciiLines, layer, styleCharacter) {
|
|
150
|
+
asciiLines.forEach((line, y) => {
|
|
151
|
+
Array.from(line).forEach((character, x) => {
|
|
152
|
+
if (character === ' ') {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const targetY = y + layer.offsetY;
|
|
157
|
+
const targetX = x + layer.offsetX;
|
|
158
|
+
|
|
159
|
+
if (!canvas[targetY] || typeof canvas[targetY][targetX] === 'undefined') {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
canvas[targetY][targetX] = styleCharacter(character, x, y, lineWidth(line));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function styleShadowCharacter(character, color, dim) {
|
|
169
|
+
const base = chalk.hex(color);
|
|
170
|
+
return dim ? base.dim(character) : base(character);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The foreground uses a left-to-right neon blend and a small, deterministic glitch accent.
|
|
174
|
+
function styleForegroundCharacter(character, x, y, width, settings, frameState) {
|
|
175
|
+
const positionRatio = width <= 1 ? 0 : x / (width - 1);
|
|
176
|
+
const baseColor = blendHex(
|
|
177
|
+
settings.colors.foregroundStart,
|
|
178
|
+
settings.colors.foregroundEnd,
|
|
179
|
+
positionRatio
|
|
180
|
+
);
|
|
181
|
+
const topGlow = Math.max(0, 0.18 - y * 0.03) + frameState.highlightBoost;
|
|
182
|
+
const neonColor = blendHex(baseColor, settings.colors.foregroundAccent, Math.min(topGlow, 0.35));
|
|
183
|
+
const shouldGlitch = pseudoNoise(x, y, frameState.seed) < frameState.glitchAmount;
|
|
184
|
+
const color = shouldGlitch ? settings.colors.foregroundAccent : neonColor;
|
|
185
|
+
|
|
186
|
+
return chalk.hex(color).bold(character);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderLayeredTitle(asciiTitle, settings, frameState) {
|
|
190
|
+
const width =
|
|
191
|
+
asciiTitle.width +
|
|
192
|
+
getMaxShadowOffsetX(settings) +
|
|
193
|
+
Math.max(0, frameState.foregroundShiftX || 0, frameState.shadowBumpX || 0);
|
|
194
|
+
const height =
|
|
195
|
+
asciiTitle.height +
|
|
196
|
+
getMaxShadowOffsetY(settings) +
|
|
197
|
+
Math.max(0, frameState.foregroundShiftY || 0, frameState.shadowBumpY || 0);
|
|
198
|
+
const canvas = createCanvas(width, height);
|
|
199
|
+
|
|
200
|
+
settings.shadowLayers.forEach((layer) => {
|
|
201
|
+
applyLayer(
|
|
202
|
+
canvas,
|
|
203
|
+
asciiTitle.lines,
|
|
204
|
+
{
|
|
205
|
+
offsetX: layer.offsetX + frameState.shadowBumpX,
|
|
206
|
+
offsetY: layer.offsetY + frameState.shadowBumpY,
|
|
207
|
+
},
|
|
208
|
+
(character) => styleShadowCharacter(character, layer.color, layer.dim)
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
applyLayer(
|
|
213
|
+
canvas,
|
|
214
|
+
asciiTitle.lines,
|
|
215
|
+
{
|
|
216
|
+
offsetX: frameState.foregroundShiftX,
|
|
217
|
+
offsetY: frameState.foregroundShiftY,
|
|
218
|
+
},
|
|
219
|
+
(character, x, y, widthForLine) =>
|
|
220
|
+
styleForegroundCharacter(character, x, y, widthForLine, settings, frameState)
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
width,
|
|
225
|
+
lines: canvas.map((row) => row.join('').replace(/\s+$/, '')),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function createDivider(width, settings) {
|
|
230
|
+
const dividerWidth = Math.max(24, Math.min(width, 54));
|
|
231
|
+
return {
|
|
232
|
+
width: dividerWidth,
|
|
233
|
+
text: chalk.hex(settings.colors.divider)('-'.repeat(dividerWidth)),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function createSubtitle(settings) {
|
|
238
|
+
return {
|
|
239
|
+
width: lineWidth(settings.subtitle),
|
|
240
|
+
text: chalk.hex(settings.colors.subtitle)(settings.subtitle),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function centerEntries(entries, settings) {
|
|
245
|
+
const blockWidth = Math.max(...entries.map((entry) => entry.width), 0);
|
|
246
|
+
const columns = getColumns();
|
|
247
|
+
const leftPadding =
|
|
248
|
+
settings.center && columns > blockWidth ? Math.floor((columns - blockWidth) / 2) : 0;
|
|
249
|
+
|
|
250
|
+
return entries.map((entry) => {
|
|
251
|
+
const localPadding = Math.max(0, Math.floor((blockWidth - entry.width) / 2));
|
|
252
|
+
return `${' '.repeat(leftPadding + localPadding)}${entry.text}`;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildBannerEntries(settings, frameIndex = 0, animated = false) {
|
|
257
|
+
const asciiTitle = selectAsciiTitle(settings);
|
|
258
|
+
const frameState = buildFrameState(frameIndex, animated);
|
|
259
|
+
const layeredTitle = renderLayeredTitle(asciiTitle, settings, frameState);
|
|
260
|
+
const divider = createDivider(layeredTitle.width, settings);
|
|
261
|
+
const subtitle = createSubtitle(settings);
|
|
262
|
+
|
|
263
|
+
return [
|
|
264
|
+
...layeredTitle.lines.map((line) => ({ text: line, width: layeredTitle.width })),
|
|
265
|
+
{ text: '', width: 0 },
|
|
266
|
+
divider,
|
|
267
|
+
subtitle,
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function renderGeminiBanner(options = {}) {
|
|
272
|
+
const settings = mergeBannerOptions(options);
|
|
273
|
+
const entries = buildBannerEntries(settings);
|
|
274
|
+
return centerEntries(entries, settings).join('\n');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function rewindBanner(lineCount) {
|
|
278
|
+
if (!process.stdout.isTTY || lineCount <= 0) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
readline.moveCursor(process.stdout, 0, -lineCount);
|
|
283
|
+
readline.cursorTo(process.stdout, 0);
|
|
284
|
+
readline.clearScreenDown(process.stdout);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function animateGeminiBanner(options = {}) {
|
|
288
|
+
const settings = mergeBannerOptions(options);
|
|
289
|
+
|
|
290
|
+
if (!process.stdout.isTTY) {
|
|
291
|
+
process.stdout.write(`${renderGeminiBanner(settings)}\n`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const frameCount = Math.max(1, Math.ceil(settings.animationDurationMs / settings.frameIntervalMs));
|
|
296
|
+
let lineCount = 0;
|
|
297
|
+
|
|
298
|
+
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
|
299
|
+
const frame = centerEntries(buildBannerEntries(settings, frameIndex, true), settings).join('\n');
|
|
300
|
+
rewindBanner(lineCount);
|
|
301
|
+
process.stdout.write(`${frame}\n`);
|
|
302
|
+
lineCount = frame.split('\n').length;
|
|
303
|
+
await sleep(settings.frameIntervalMs);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
rewindBanner(lineCount);
|
|
307
|
+
process.stdout.write(`${renderGeminiBanner(settings)}\n`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
DEFAULT_BANNER_OPTIONS,
|
|
312
|
+
animateGeminiBanner,
|
|
313
|
+
renderGeminiBanner,
|
|
314
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
async function ensureDirectory(directoryPath) {
|
|
5
|
+
await fs.mkdir(directoryPath, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function writeJsonFile(filePath, data) {
|
|
9
|
+
await ensureDirectory(path.dirname(filePath));
|
|
10
|
+
await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function ensureJsonFile(filePath, defaultValue) {
|
|
14
|
+
await ensureDirectory(path.dirname(filePath));
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await fs.access(filePath);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code !== 'ENOENT') {
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await writeJsonFile(filePath, defaultValue);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readJsonFile(filePath) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
30
|
+
|
|
31
|
+
if (!raw.trim()) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT') {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (error instanceof SyntaxError) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`The JSON file at "${filePath}" is malformed. Fix the JSON or replace the file with a valid one.`,
|
|
44
|
+
{ cause: error }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
ensureDirectory,
|
|
54
|
+
ensureJsonFile,
|
|
55
|
+
readJsonFile,
|
|
56
|
+
writeJsonFile,
|
|
57
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const { animateGeminiBanner, renderGeminiBanner } = require('./banner');
|
|
3
|
+
|
|
4
|
+
const USER_PREFIX = 'You >';
|
|
5
|
+
const ASSISTANT_PREFIX = 'Gemini >';
|
|
6
|
+
const COMMAND_PREFIX = 'Command >';
|
|
7
|
+
const INFO_PREFIX = 'Info >';
|
|
8
|
+
const SUCCESS_PREFIX = 'Success >';
|
|
9
|
+
const ERROR_PREFIX = 'Error >';
|
|
10
|
+
const SPINNER_FRAMES = ['.', '..', '...'];
|
|
11
|
+
|
|
12
|
+
function printPrefixed(prefix, colorize, message, addBlankLine = true) {
|
|
13
|
+
const lines = String(message).split(/\r?\n/);
|
|
14
|
+
const indent = ' '.repeat(prefix.length + 1);
|
|
15
|
+
|
|
16
|
+
lines.forEach((line, index) => {
|
|
17
|
+
if (index === 0) {
|
|
18
|
+
console.log(`${colorize(prefix)} ${line}`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log(`${indent}${line}`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (addBlankLine) {
|
|
26
|
+
console.log('');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function printBanner(options = {}) {
|
|
31
|
+
if (options.animated) {
|
|
32
|
+
await animateGeminiBanner(options);
|
|
33
|
+
} else {
|
|
34
|
+
process.stdout.write(`${renderGeminiBanner(options)}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function printInfo(message) {
|
|
41
|
+
printPrefixed(INFO_PREFIX, chalk.blueBright, message);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function printSuccess(message) {
|
|
45
|
+
printPrefixed(SUCCESS_PREFIX, chalk.greenBright, message);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function printError(message) {
|
|
49
|
+
printPrefixed(ERROR_PREFIX, chalk.redBright, message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function printCommandBlock(title, lines) {
|
|
53
|
+
console.log(chalk.magentaBright(`${COMMAND_PREFIX} ${title}`));
|
|
54
|
+
lines.forEach((line) => {
|
|
55
|
+
console.log(chalk.magenta(line));
|
|
56
|
+
});
|
|
57
|
+
console.log('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printAssistant(message) {
|
|
61
|
+
printPrefixed(ASSISTANT_PREFIX, chalk.cyanBright, message);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function printUser(message) {
|
|
65
|
+
printPrefixed(USER_PREFIX, chalk.yellowBright, message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getUserPrompt() {
|
|
69
|
+
return chalk.yellowBright(`${USER_PREFIX} `);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createLoadingIndicator(label = 'Gemini is thinking') {
|
|
73
|
+
let frameIndex = 0;
|
|
74
|
+
let timer = null;
|
|
75
|
+
let active = false;
|
|
76
|
+
let wroteFallbackLine = false;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
start() {
|
|
80
|
+
if (active) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
active = true;
|
|
85
|
+
|
|
86
|
+
if (!process.stdout.isTTY) {
|
|
87
|
+
process.stdout.write(chalk.gray(`${label}...\n`));
|
|
88
|
+
wroteFallbackLine = true;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
timer = setInterval(() => {
|
|
93
|
+
process.stdout.write(`\r${chalk.gray(`${label}${SPINNER_FRAMES[frameIndex]}`)}`);
|
|
94
|
+
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
|
95
|
+
}, 120);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
stop() {
|
|
99
|
+
if (!active) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
active = false;
|
|
104
|
+
|
|
105
|
+
if (timer) {
|
|
106
|
+
clearInterval(timer);
|
|
107
|
+
timer = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (process.stdout.isTTY) {
|
|
111
|
+
const clearWidth = label.length + 8;
|
|
112
|
+
process.stdout.write(`\r${' '.repeat(clearWidth)}\r`);
|
|
113
|
+
} else if (!wroteFallbackLine) {
|
|
114
|
+
process.stdout.write('\n');
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function startAssistantStream() {
|
|
121
|
+
process.stdout.write(`${chalk.cyanBright(ASSISTANT_PREFIX)} `);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function writeAssistantChunk(chunk) {
|
|
125
|
+
process.stdout.write(chunk);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function endAssistantStream() {
|
|
129
|
+
process.stdout.write('\n\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
createLoadingIndicator,
|
|
134
|
+
endAssistantStream,
|
|
135
|
+
getUserPrompt,
|
|
136
|
+
animateGeminiBanner,
|
|
137
|
+
printAssistant,
|
|
138
|
+
printBanner,
|
|
139
|
+
printCommandBlock,
|
|
140
|
+
printError,
|
|
141
|
+
printInfo,
|
|
142
|
+
renderGeminiBanner,
|
|
143
|
+
printSuccess,
|
|
144
|
+
printUser,
|
|
145
|
+
startAssistantStream,
|
|
146
|
+
writeAssistantChunk,
|
|
147
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function isNonEmptyString(value) {
|
|
2
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function assertNonEmptyString(value, label) {
|
|
6
|
+
if (!isNonEmptyString(value)) {
|
|
7
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return value.trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function parsePositiveInteger(value, label) {
|
|
14
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
15
|
+
|
|
16
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
17
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeModelName(modelName) {
|
|
24
|
+
const normalized = assertNonEmptyString(modelName, 'Model name');
|
|
25
|
+
|
|
26
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'Model name contains invalid characters. Use letters, numbers, dots, dashes, or underscores only.'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateHistoryMessage(message, index) {
|
|
36
|
+
if (!message || typeof message !== 'object') {
|
|
37
|
+
throw new Error(`History message at index ${index} must be an object.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (message.role !== 'user' && message.role !== 'assistant') {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`History message at index ${index} has an invalid role. Use "user" or "assistant".`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
role: message.role,
|
|
48
|
+
content: assertNonEmptyString(message.content, `History message content at index ${index}`),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertCommandArgument(rawArgs, usageMessage) {
|
|
53
|
+
if (!isNonEmptyString(rawArgs)) {
|
|
54
|
+
throw new Error(usageMessage);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return rawArgs.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
assertCommandArgument,
|
|
62
|
+
assertNonEmptyString,
|
|
63
|
+
isNonEmptyString,
|
|
64
|
+
normalizeModelName,
|
|
65
|
+
parsePositiveInteger,
|
|
66
|
+
validateHistoryMessage,
|
|
67
|
+
};
|