@iola_adm/iola-cli 0.1.38 → 0.1.40

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +178 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
package/src/cli.js CHANGED
@@ -330,14 +330,7 @@ const SLASH_COMMANDS = [
330
330
  { command: "/init", description: "проверить окружение" },
331
331
  { command: "/exit", description: "выйти" },
332
332
  ];
333
- const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
334
- │ │
335
- │\x1b[38;5;213m CLI-Йошкар-Ола \x1b[38;5;45m│
336
- │ │
337
- │\x1b[38;5;250m открытые данные • MCP • локальный AI \x1b[38;5;45m│
338
- │ │
339
- │\x1b[38;5;82m VERSION_LINE \x1b[38;5;45m│
340
- └────────────────────────────────────────────────────────────────────────────┘\x1b[0m`;
333
+ const BANNER_WIDTH = 76;
341
334
 
342
335
  const COMMANDS = new Map([
343
336
  ["help", showHelp],
@@ -632,6 +625,17 @@ async function startAgent() {
632
625
  console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
633
626
  await runHooks("SessionStart", { mode: "agent" });
634
627
 
628
+ if (input.isTTY && output.isTTY) {
629
+ await startAgentRawInput();
630
+ await runHooks("SessionEnd", { mode: "agent" });
631
+ return;
632
+ }
633
+
634
+ await startAgentReadline();
635
+ await runHooks("SessionEnd", { mode: "agent" });
636
+ }
637
+
638
+ async function startAgentReadline() {
635
639
  const rl = readline.createInterface({ input, output, prompt: "iola> " });
636
640
  const state = {
637
641
  history: [],
@@ -667,7 +671,86 @@ async function startAgent() {
667
671
  rl.close();
668
672
  }
669
673
  detachSlashSuggestions();
670
- await runHooks("SessionEnd", { mode: "agent" });
674
+ }
675
+
676
+ async function startAgentRawInput() {
677
+ const state = { history: [], buffer: "", selected: 0, slashOpen: false, running: false };
678
+ emitKeypressEvents(input);
679
+ const wasRaw = input.isRaw;
680
+ input.setRawMode(true);
681
+ input.resume();
682
+
683
+ const render = () => renderAgentInput(state);
684
+ render();
685
+
686
+ try {
687
+ while (true) {
688
+ const { str, key } = await readKeypress();
689
+ if (key?.ctrl && key.name === "c") break;
690
+ if (key?.name === "escape") {
691
+ state.slashOpen = false;
692
+ render();
693
+ continue;
694
+ }
695
+ if (key?.name === "backspace") {
696
+ state.buffer = [...state.buffer].slice(0, -1).join("");
697
+ updateSlashState(state);
698
+ render();
699
+ continue;
700
+ }
701
+ if (key?.name === "up" && state.slashOpen) {
702
+ const matches = currentSlashMatches(state);
703
+ state.selected = Math.max(0, Math.min(matches.length - 1, state.selected - 1));
704
+ render();
705
+ continue;
706
+ }
707
+ if (key?.name === "down" && state.slashOpen) {
708
+ const matches = currentSlashMatches(state);
709
+ state.selected = Math.max(0, Math.min(matches.length - 1, state.selected + 1));
710
+ render();
711
+ continue;
712
+ }
713
+ if (isShiftEnter(str, key)) {
714
+ state.buffer += "\n";
715
+ state.slashOpen = false;
716
+ render();
717
+ continue;
718
+ }
719
+ if (key?.name === "return" || key?.name === "enter") {
720
+ const matches = currentSlashMatches(state);
721
+ const selected = matches[state.selected];
722
+ if (state.slashOpen && selected && state.buffer.trim() !== selected.command) {
723
+ state.buffer = selected.command;
724
+ state.slashOpen = false;
725
+ render();
726
+ continue;
727
+ }
728
+ const line = state.buffer.trim();
729
+ state.buffer = "";
730
+ state.slashOpen = false;
731
+ clearAgentInputArea();
732
+ if (!line) {
733
+ render();
734
+ continue;
735
+ }
736
+ try {
737
+ const shouldExit = await handleAgentLine(line, state);
738
+ if (shouldExit) break;
739
+ } catch (error) {
740
+ console.error(error instanceof Error ? error.message : String(error));
741
+ }
742
+ render();
743
+ continue;
744
+ }
745
+ if (str && !key?.ctrl && !key?.meta) {
746
+ state.buffer += str;
747
+ updateSlashState(state);
748
+ render();
749
+ }
750
+ }
751
+ } finally {
752
+ if (!wasRaw) input.setRawMode(false);
753
+ }
671
754
  }
672
755
 
673
756
  async function handleAgentLine(line, state) {
@@ -1085,6 +1168,59 @@ function getSlashCommandMatches(filter = "") {
1085
1168
  || item.description.toLocaleLowerCase("ru-RU").includes(normalized));
1086
1169
  }
1087
1170
 
1171
+ function updateSlashState(state) {
1172
+ state.slashOpen = state.buffer.startsWith("/");
1173
+ state.selected = 0;
1174
+ }
1175
+
1176
+ function currentSlashMatches(state) {
1177
+ if (!state.buffer.startsWith("/")) return [];
1178
+ return getSlashCommandMatches(state.buffer.slice(1)).slice(0, 10);
1179
+ }
1180
+
1181
+ function renderAgentInput(state) {
1182
+ clearAgentInputArea();
1183
+ const prompt = "iola> ";
1184
+ const lines = state.buffer.split("\n");
1185
+ output.write(`${prompt}${lines[0] || ""}`);
1186
+ for (const line of lines.slice(1)) output.write(` ${line}\n`);
1187
+ if (state.slashOpen) {
1188
+ output.write("\n");
1189
+ const matches = currentSlashMatches(state);
1190
+ if (matches.length === 0) {
1191
+ output.write(" нет команд\n");
1192
+ } else {
1193
+ for (let index = 0; index < matches.length; index += 1) {
1194
+ const marker = index === state.selected ? ">" : " ";
1195
+ output.write(`${marker} ${matches[index].command.padEnd(24)} ${matches[index].description}\n`);
1196
+ }
1197
+ output.write(" ↑/↓ выбрать • Enter вставить/выполнить • Esc закрыть\n");
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ function clearAgentInputArea() {
1203
+ if (!output.isTTY) return;
1204
+ output.write("\x1b[2K\r");
1205
+ }
1206
+
1207
+ function readKeypress() {
1208
+ return new Promise((resolve) => {
1209
+ const handler = (str, key) => {
1210
+ input.off("keypress", handler);
1211
+ resolve({ str, key });
1212
+ };
1213
+ input.on("keypress", handler);
1214
+ });
1215
+ }
1216
+
1217
+ function isShiftEnter(str, key) {
1218
+ return (key?.name === "return" && key.shift)
1219
+ || (key?.name === "enter" && key.shift)
1220
+ || String(str || "").includes("[13;2")
1221
+ || String(str || "").includes("[27;2;13");
1222
+ }
1223
+
1088
1224
  function printAgentHistory(history) {
1089
1225
  if (history.length === 0) {
1090
1226
  console.log("История пуста.");
@@ -1123,7 +1259,6 @@ function safePrompt(rl, closed = false) {
1123
1259
  }
1124
1260
 
1125
1261
  try {
1126
- writePromptBottomPadding();
1127
1262
  rl.prompt();
1128
1263
  } catch {
1129
1264
  // The input stream can close while an async slash-command is still running.
@@ -1146,7 +1281,6 @@ function attachSlashSuggestions(rl) {
1146
1281
  lastFilter = filter;
1147
1282
  output.write("\n");
1148
1283
  printSlashMenu(filter, { compact: true, limit: 10 });
1149
- writePromptBottomPadding();
1150
1284
  rl.prompt(true);
1151
1285
  }, 0);
1152
1286
  };
@@ -1154,20 +1288,13 @@ function attachSlashSuggestions(rl) {
1154
1288
  return () => input.off("keypress", onKeypress);
1155
1289
  }
1156
1290
 
1157
- function writePromptBottomPadding() {
1158
- if (!output.isTTY) return;
1159
- const padding = Math.max(0, Math.min(5, Number(process.env.IOLA_PROMPT_BOTTOM_PADDING || 2)));
1160
- if (padding === 0) return;
1161
- output.write(`${"\n".repeat(padding)}\x1b[${padding}A`);
1162
- }
1163
-
1164
1291
  async function showBanner(options = {}) {
1165
1292
  const version = getPackageVersion();
1166
1293
  const latest = options.skipUpdate ? null : await getLatestNpmVersion("@iola_adm/iola-cli");
1167
1294
  const updateAvailable = latest && compareVersions(latest, version) > 0;
1168
1295
  const versionLine = updateAvailable ? `v${version} -> v${latest} • npm install -g @iola_adm/iola-cli@latest` : `v${version} • iola help`;
1169
1296
  if (process.stdout.isTTY && process.env.NO_COLOR !== "1") {
1170
- console.log(BANNER.replace("VERSION_LINE", padBannerLine(versionLine)));
1297
+ console.log(renderBanner(versionLine, true));
1171
1298
  if (updateAvailable) {
1172
1299
  console.log(`Доступно обновление: v${version} -> v${latest}`);
1173
1300
  console.log("Обновить: npm install -g @iola_adm/iola-cli@latest");
@@ -1176,13 +1303,41 @@ async function showBanner(options = {}) {
1176
1303
  }
1177
1304
 
1178
1305
  console.log(`CLI-Йошкар-Ола ${updateAvailable ? `v${version} -> v${latest}` : `v${version}`}`);
1179
- console.log("открытые данные • MCP • локальный AI");
1306
+ console.log("Йошкар-Ола • MCP • локальный AI");
1180
1307
  if (updateAvailable) console.log("Обновить: npm install -g @iola_adm/iola-cli@latest");
1181
1308
  }
1182
1309
 
1183
- function padBannerLine(value) {
1184
- const text = String(value).slice(0, 62);
1185
- return `${text}${" ".repeat(Math.max(0, 62 - bannerVisibleLength(text)))}`;
1310
+ function renderBanner(versionLine, color = false) {
1311
+ const c = color ? {
1312
+ border: "\x1b[38;5;45m",
1313
+ title: "\x1b[38;5;213m",
1314
+ muted: "\x1b[38;5;250m",
1315
+ version: "\x1b[38;5;82m",
1316
+ reset: "\x1b[0m",
1317
+ } : { border: "", title: "", muted: "", version: "", reset: "" };
1318
+ const line = (text = "", style = "") => {
1319
+ const value = centerBannerText(text);
1320
+ return `${c.border}│${style}${value}${c.border}│`;
1321
+ };
1322
+ return [
1323
+ `${c.border}┌${"─".repeat(BANNER_WIDTH)}┐`,
1324
+ line(),
1325
+ line("CLI-Йошкар-Ола", c.title),
1326
+ line(),
1327
+ line("Йошкар-Ола • MCP • локальный AI", c.muted),
1328
+ line(),
1329
+ line(versionLine, c.version),
1330
+ `${c.border}└${"─".repeat(BANNER_WIDTH)}┘${c.reset}`,
1331
+ ].join("\n");
1332
+ }
1333
+
1334
+ function centerBannerText(value) {
1335
+ const text = String(value || "");
1336
+ const length = bannerVisibleLength(text);
1337
+ if (length >= BANNER_WIDTH) return [...text].slice(0, BANNER_WIDTH).join("");
1338
+ const left = Math.floor((BANNER_WIDTH - length) / 2);
1339
+ const right = BANNER_WIDTH - length - left;
1340
+ return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
1186
1341
  }
1187
1342
 
1188
1343
  function bannerVisibleLength(value) {