prior-cli 1.7.11 → 1.7.13

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/bin/prior.js +191 -0
  2. package/package.json +1 -1
package/bin/prior.js CHANGED
@@ -230,6 +230,30 @@ function expandFileRefs(input, cwd) {
230
230
  return { message: `${input}\n\n[Attached file context]\n${ctx}`, attached };
231
231
  }
232
232
 
233
+ // Live completions for an @file token being typed. `partial` is the text
234
+ // after @ (e.g. "lib/to"). Returns up to `limit` matches, alphabetical (files + folders mixed).
235
+ const REF_SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.cache', 'coverage', 'vendor']);
236
+ function fileRefSuggestions(partial, cwd, limit = 10) {
237
+ const slash = Math.max(partial.lastIndexOf('/'), partial.lastIndexOf('\\'));
238
+ const dirPart = slash >= 0 ? partial.slice(0, slash) : '';
239
+ const prefix = (slash >= 0 ? partial.slice(slash + 1) : partial).toLowerCase();
240
+ const baseDir = dirPart
241
+ ? (path.isAbsolute(dirPart) ? dirPart : path.resolve(cwd, dirPart))
242
+ : cwd;
243
+ let entries;
244
+ try { entries = fs.readdirSync(baseDir, { withFileTypes: true }); } catch { return []; }
245
+ const out = [];
246
+ for (const e of entries) {
247
+ if (e.name.startsWith('.') && !prefix.startsWith('.')) continue; // hide dotfiles unless asked
248
+ if (e.isDirectory() && REF_SKIP_DIRS.has(e.name)) continue;
249
+ if (!e.name.toLowerCase().startsWith(prefix)) continue;
250
+ const ref = (dirPart ? dirPart.replace(/\\/g, '/') + '/' : '') + e.name;
251
+ out.push({ ref, isDir: e.isDirectory() });
252
+ }
253
+ out.sort((a, b) => a.ref.toLowerCase().localeCompare(b.ref.toLowerCase())); // alphabetical, files + folders mixed
254
+ return out.slice(0, limit);
255
+ }
256
+
233
257
  function fmtElapsed(ms) {
234
258
  const s = Math.floor(ms / 1000);
235
259
  if (s < 60) return `${s}s`;
@@ -863,6 +887,13 @@ async function startChat(opts = {}) {
863
887
  terminal: true,
864
888
  historySize: 100,
865
889
  completer: line => {
890
+ // @file completion — complete the @token at the end of the line
891
+ const at = line.match(/(?:^|\s)@([^\s]*)$/);
892
+ if (at) {
893
+ const matches = fileRefSuggestions(at[1], process.cwd(), 20)
894
+ .map(s => '@' + (s.isDir ? s.ref + '/' : s.ref));
895
+ return [matches, '@' + at[1]];
896
+ }
866
897
  const cmds = ['/help', '/clear', '/censored', '/uncensored', '/login', '/logout', '/exit'];
867
898
  if (!line.startsWith('/')) return [[], line];
868
899
  const hits = cmds.filter(cmd => cmd.startsWith(line));
@@ -985,6 +1016,158 @@ async function startChat(opts = {}) {
985
1016
  _subRowCount = 1;
986
1017
  }
987
1018
 
1019
+ // ── Interactive @file picker ─────────────────────────────────
1020
+ // While open, it OWNS the keyboard (↑/↓/Enter/Tab/Esc/typing) so the
1021
+ // arrow keys don't fall through to readline's history. We swap readline's
1022
+ // keypress listener out for ours, then restore it when the picker closes.
1023
+ let _atActive = false;
1024
+ let _atItems = [];
1025
+ let _atSel = 0;
1026
+ let _atToken = '';
1027
+ let _atStart = 0;
1028
+ let _savedKp = null;
1029
+ let _atRows = 0; // rows currently drawn below the input by the picker
1030
+ let _atView = 0; // index of the first visible item (scrolling viewport)
1031
+ const AT_MAX_VISIBLE = 8;
1032
+
1033
+ function currentAtToken() {
1034
+ const line = rl.line || '';
1035
+ const before = line.slice(0, rl.cursor);
1036
+ const m = before.match(/(?:^|\s)@([^\s]*)$/);
1037
+ if (!m) return null;
1038
+ return { token: m[1], start: rl.cursor - m[1].length - 1 };
1039
+ }
1040
+
1041
+ function refreshAtItems() {
1042
+ const t = currentAtToken();
1043
+ if (!t) return false;
1044
+ _atToken = t.token;
1045
+ _atStart = t.start;
1046
+ _atItems = fileRefSuggestions(_atToken, process.cwd(), 500); // fetch all; viewport scrolls
1047
+ _atView = 0;
1048
+ if (_atSel >= _atItems.length) _atSel = Math.max(0, _atItems.length - 1);
1049
+ return _atItems.length > 0;
1050
+ }
1051
+
1052
+ // Column of the readline cursor on the input line (visible prompt is 4 cols wide)
1053
+ function atInputCol() { return 4 + (rl.cursor || 0); }
1054
+
1055
+ // Return the terminal cursor to the input line, `fromRows` lines above us.
1056
+ // Uses RELATIVE moves so it survives the terminal scrolling when the picker
1057
+ // is drawn near the bottom of the screen (the bug where the hint overlapped @).
1058
+ function atMoveToInput(fromRows) {
1059
+ if (fromRows > 0) process.stdout.write(`\x1b[${fromRows}A`);
1060
+ process.stdout.write('\r');
1061
+ const col = atInputCol();
1062
+ if (col > 0) process.stdout.write(`\x1b[${col}C`);
1063
+ }
1064
+
1065
+ function clearAtRows() {
1066
+ if (_atRows > 0) {
1067
+ for (let i = 0; i < _atRows; i++) process.stdout.write('\x1b[B\r\x1b[2K');
1068
+ atMoveToInput(_atRows);
1069
+ _atRows = 0;
1070
+ }
1071
+ _subRowCount = 0;
1072
+ }
1073
+
1074
+ function renderAtList() {
1075
+ clearAtRows(); // assumes cursor is on the input line
1076
+ const total = _atItems.length;
1077
+ // Keep the selected item inside the visible window
1078
+ if (_atSel < _atView) _atView = _atSel;
1079
+ if (_atSel >= _atView + AT_MAX_VISIBLE) _atView = _atSel - AT_MAX_VISIBLE + 1;
1080
+ const end = Math.min(_atView + AT_MAX_VISIBLE, total);
1081
+
1082
+ const lines = [];
1083
+ for (let i = _atView; i < end; i++) {
1084
+ const it = _atItems[i];
1085
+ const label = it.isDir ? it.ref + '/' : it.ref;
1086
+ const arrow = (i === end - 1 && end < total) ? c.dim(' ↓')
1087
+ : (i === _atView && _atView > 0) ? c.dim(' ↑') : '';
1088
+ lines.push((i === _atSel
1089
+ ? c.brand('❯ ') + c.bold(label)
1090
+ : c.muted(' ') + c.white(label)) + arrow);
1091
+ }
1092
+ const pos = total > AT_MAX_VISIBLE ? ` · ${_atSel + 1}/${total}` : '';
1093
+ lines.push(c.dim('↑↓ select · enter/tab insert · esc dismiss' + pos));
1094
+
1095
+ for (const ln of lines) process.stdout.write(`\x1b[B\r\x1b[2K ${ln}`);
1096
+ atMoveToInput(lines.length); // relative — scroll-safe
1097
+ _atRows = lines.length;
1098
+ _subRowCount = lines.length;
1099
+ }
1100
+
1101
+ function openAtPicker() {
1102
+ _atSel = 0;
1103
+ if (!refreshAtItems()) return;
1104
+ _atActive = true;
1105
+ // Take over keypress handling from readline
1106
+ _savedKp = process.stdin.listeners('keypress').slice();
1107
+ process.stdin.removeAllListeners('keypress');
1108
+ process.stdin.on('keypress', atKeyHandler);
1109
+ renderAtList();
1110
+ }
1111
+
1112
+ function closeAtPicker() {
1113
+ if (!_atActive) return;
1114
+ _atActive = false;
1115
+ _atItems = [];
1116
+ _atSel = 0;
1117
+ if (_savedKp) {
1118
+ process.stdin.removeListener('keypress', atKeyHandler);
1119
+ for (const l of _savedKp) process.stdin.on('keypress', l);
1120
+ _savedKp = null;
1121
+ }
1122
+ clearAtRows();
1123
+ }
1124
+
1125
+ function acceptAt() {
1126
+ const it = _atItems[_atSel];
1127
+ if (!it) { closeAtPicker(); return; }
1128
+ const insert = it.isDir ? it.ref + '/' : it.ref;
1129
+ const line = rl.line;
1130
+ const before = line.slice(0, _atStart + 1); // up to & incl. '@'
1131
+ const after = line.slice(_atStart + 1 + _atToken.length);
1132
+ const newLine = before + insert + after;
1133
+ rl.line = newLine;
1134
+ rl.cursor = (before + insert).length;
1135
+ rl._refreshLine();
1136
+ if (it.isDir) { // drill into the folder, keep picking
1137
+ _atSel = 0;
1138
+ if (refreshAtItems()) { renderAtList(); return; }
1139
+ }
1140
+ closeAtPicker();
1141
+ }
1142
+
1143
+ function atKeyHandler(ch, key) {
1144
+ if (!key) return;
1145
+ if (key.name === 'up') { _atSel = (_atSel - 1 + _atItems.length) % _atItems.length; renderAtList(); return; }
1146
+ if (key.name === 'down') { _atSel = (_atSel + 1) % _atItems.length; renderAtList(); return; }
1147
+ if (key.name === 'escape') { closeAtPicker(); return; }
1148
+ if (key.name === 'tab' || key.name === 'return' || key.name === 'enter') { acceptAt(); return; }
1149
+ if (key.ctrl && key.name === 'c') { closeAtPicker(); process.exit(0); }
1150
+ if (key.name === 'backspace') {
1151
+ if (rl.cursor > 0) {
1152
+ rl.line = rl.line.slice(0, rl.cursor - 1) + rl.line.slice(rl.cursor);
1153
+ rl.cursor = rl.cursor - 1;
1154
+ rl._refreshLine();
1155
+ }
1156
+ if (!refreshAtItems()) { closeAtPicker(); return; }
1157
+ renderAtList();
1158
+ return;
1159
+ }
1160
+ // Printable character — insert it, keep filtering
1161
+ if (ch && !key.ctrl && !key.meta && ch.length === 1 && ch >= ' ') {
1162
+ rl.line = rl.line.slice(0, rl.cursor) + ch + rl.line.slice(rl.cursor);
1163
+ rl.cursor = rl.cursor + 1;
1164
+ rl._refreshLine();
1165
+ if (ch === ' ') { closeAtPicker(); return; } // space ends the @token
1166
+ if (!refreshAtItems()) { closeAtPicker(); return; }
1167
+ renderAtList();
1168
+ }
1169
+ }
1170
+
988
1171
  process.stdin.on('keypress', (ch, key) => {
989
1172
  if (!key) return;
990
1173
 
@@ -1023,6 +1206,14 @@ async function startChat(opts = {}) {
1023
1206
  // Redraw sub-rows on every keypress so backspace / typing never wipes them
1024
1207
  _suggTimer = setTimeout(() => {
1025
1208
  _suggTimer = null;
1209
+ // If the user just started an @file token, hand control to the picker
1210
+ if (!_atActive) {
1211
+ const t = currentAtToken();
1212
+ if (t && fileRefSuggestions(t.token, process.cwd(), 1).length) {
1213
+ openAtPicker();
1214
+ return;
1215
+ }
1216
+ }
1026
1217
  renderSubRows(rl.line || '');
1027
1218
  }, 50);
1028
1219
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prior-cli",
3
- "version": "1.7.11",
3
+ "version": "1.7.13",
4
4
  "description": "Prior Network AI — command-line interface",
5
5
  "author": "Prior Network",
6
6
  "homepage": "https://priornetwork.com",