@jacebenson/jsn 3.0.6 → 3.1.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.
@@ -1,7 +1,7 @@
1
1
  // Custom scraPI command: list/show individual operations + one-shot API definition creation
2
2
  //
3
3
  // The 'create' subcommand lets you build a full Scripted REST API in one call:
4
- // jsn dev scrapi create \
4
+ // jsn scrapi create \
5
5
  // --name "SL Managed Services" --service-id managed_services --namespace slapi \
6
6
  // --resource create=POST:/create:./create.js \
7
7
  // --resource comment=POST:/comment:./comment.js
@@ -74,7 +74,7 @@ export function scrapiCmd(wrap) {
74
74
  builder: (y) => y
75
75
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident")' })
76
76
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
77
- .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
77
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
78
78
  handler: wrap(async (argv, app) => {
79
79
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'sys_ws_definition', 'http_method', 'relative_path', 'sys_scope'];
80
80
  const query = argv.query || '';
@@ -97,8 +97,8 @@ export function scrapiCmd(wrap) {
97
97
  }, {
98
98
  summary: `${records.length} operation(s)`,
99
99
  breadcrumbs: [
100
- { action: 'create', cmd: 'jsn dev scrapi create --name "My API" --service-id my-api --namespace myapp --resource get=GET:/items:./script.js', description: 'Create a new REST API' },
101
- { action: 'show', cmd: 'jsn dev scrapi show <name>', description: 'Show operation details' },
100
+ { action: 'create', cmd: 'jsn scrapi create --name "My API" --service-id my-api --namespace myapp --resource get=GET:/items:./script.js', description: 'Create a new REST API' },
101
+ { action: 'show', cmd: 'jsn scrapi show <name>', description: 'Show operation details' },
102
102
  ],
103
103
  });
104
104
  }),
@@ -128,7 +128,7 @@ export function scrapiCmd(wrap) {
128
128
  return app.ok(records[0], {
129
129
  summary: `Operation: ${getStringField(records[0], 'name') || id}`,
130
130
  breadcrumbs: [
131
- { action: 'list', cmd: 'jsn dev scrapi list', description: 'Back to all operations' },
131
+ { action: 'list', cmd: 'jsn scrapi list', description: 'Back to all operations' },
132
132
  ],
133
133
  });
134
134
  }
@@ -182,7 +182,7 @@ export function scrapiCmd(wrap) {
182
182
  formatted += ` ${(method + ' ').slice(0, 7)} ${relPath.padEnd(30)} ${opName}${active !== 'true' ? ' (inactive)' : ''}\n`;
183
183
  }
184
184
  } else {
185
- formatted += `\n (no resources — run jsn dev scrapi create --resource ... to add some)\n`;
185
+ formatted += `\n (no resources — run jsn scrapi create --resource ... to add some)\n`;
186
186
  }
187
187
 
188
188
  app.ok({
@@ -207,8 +207,8 @@ export function scrapiCmd(wrap) {
207
207
  }, {
208
208
  summary: `API: ${defName} — ${childOps.length} resource(s)`,
209
209
  breadcrumbs: [
210
- { action: 'create', cmd: 'jsn dev scrapi create --name "..." --service-id ... --namespace ... --resource ...', description: 'Create a new API' },
211
- { action: 'list', cmd: 'jsn dev scrapi list', description: 'Back to all operations' },
210
+ { action: 'create', cmd: 'jsn scrapi create --name "..." --service-id ... --namespace ... --resource ...', description: 'Create a new API' },
211
+ { action: 'list', cmd: 'jsn scrapi list', description: 'Back to all operations' },
212
212
  ],
213
213
  });
214
214
  }),
@@ -316,8 +316,8 @@ export function scrapiCmd(wrap) {
316
316
  summary: `Created API "${apiName}" with ${operations.length} operation(s)`,
317
317
  notice: `View API: ${apiURL}`,
318
318
  breadcrumbs: [
319
- { action: 'show', cmd: `jsn dev scrapi list --query "web_service_definition=${defSysId}"`, description: 'List operations for this API' },
320
- { action: 'add', cmd: `jsn dev scrapi add-resource --api "${serviceId}" --name <name>=METHOD:/path:./script.js`, description: 'Add another resource' },
319
+ { action: 'show', cmd: `jsn scrapi list --query "web_service_definition=${defSysId}"`, description: 'List operations for this API' },
320
+ { action: 'add', cmd: `jsn scrapi add-resource --api "${serviceId}" --name <name>=METHOD:/path:./script.js`, description: 'Add another resource' },
321
321
  ],
322
322
  });
323
323
  }),
@@ -333,9 +333,9 @@ export function scrapiCmd(wrap) {
333
333
  console.log(' create Create a new API with operations (one-shot)');
334
334
  console.log('');
335
335
  console.log('Examples:');
336
- console.log(' jsn dev scrapi list');
337
- console.log(' jsn dev scrapi show my_operation');
338
- console.log(' jsn dev scrapi create \\');
336
+ console.log(' jsn scrapi list');
337
+ console.log(' jsn scrapi show my_operation');
338
+ console.log(' jsn scrapi create \\');
339
339
  console.log(' --name "My API" --service-id my-api --namespace myapp \\');
340
340
  console.log(' --resource "create=POST:/create:./create.js" \\');
341
341
  console.log(' --resource "list=GET:/list:./list.js"');
@@ -15,7 +15,7 @@ export function updateSetsCmd(wrap) {
15
15
  builder: (y) => y
16
16
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
17
17
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
18
- .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
19
19
  handler: wrap(async (argv, app) => {
20
20
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'state', 'application'];
21
21
  const query = argv.query || '';
@@ -24,6 +24,7 @@ export function updateSetsCmd(wrap) {
24
24
  app, table: 'sys_update_set', singular: 'update set', columns, limit: argv.limit, query, labelField: 'name',
25
25
  formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'state') || '?'}]`,
26
26
  });
27
+ if (picked === undefined) return; // user cancelled
27
28
  if (picked) {
28
29
  picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_update_set' };
29
30
  return app.ok(picked, { summary: `Update set: ${getStringField(picked, 'name')}` });
@@ -151,12 +152,12 @@ export function updateSetsCmd(wrap) {
151
152
  breadcrumbs: [
152
153
  {
153
154
  action: 'set',
154
- cmd: `jsn dev updatesets set "${argv.name}"`,
155
+ cmd: `jsn updatesets set "${argv.name}"`,
155
156
  description: 'Switch to this update set',
156
157
  },
157
158
  {
158
159
  action: 'complete',
159
- cmd: `jsn dev updatesets complete "${argv.name}"`,
160
+ cmd: `jsn updatesets complete "${argv.name}"`,
160
161
  description: 'Mark as complete when done',
161
162
  },
162
163
  ],
@@ -225,9 +226,9 @@ export function updateSetsCmd(wrap) {
225
226
  console.log(' export <name> Export an update set to XML');
226
227
  console.log(' complete <name> Mark an update set as complete (coming soon)');
227
228
  console.log(' yolo Silence the "Default update set" warning');
228
- console.log('\nRun "jsn dev updatesets <command> --help" for details.');
229
+ console.log('\nRun "jsn updatesets <command> --help" for details.');
229
230
  console.log('\nTip: Create an update set first:');
230
- console.log(' jsn dev updatesets create --name "My Feature"');
231
+ console.log(' jsn updatesets create --name "My Feature"');
231
232
  console.log(' # → Auto-set as current, then record changes are captured.');
232
233
  }
233
234
  },
@@ -74,7 +74,7 @@ export function recordsCmd(wrap) {
74
74
  const breadcrumbs = [
75
75
  { action: 'create', cmd: `jsn records create --table ${table} --data '{...}'`, description: 'Create a new record' },
76
76
  { action: 'filter', cmd: `jsn records list --table ${table} --query "priority=1"`, description: 'Filter: priority 1 only' },
77
- { action: 'columns', cmd: `jsn dev columns --table ${table}`, description: 'View available columns' },
77
+ { action: 'columns', cmd: `jsn columns --table ${table}`, description: 'View available columns' },
78
78
  ];
79
79
  if (argv['sys-id']) {
80
80
  breadcrumbs.unshift({
@@ -188,12 +188,12 @@ export function recordsCmd(wrap) {
188
188
  const breadcrumbs = [
189
189
  ...result.flows.map(f => ({
190
190
  action: 'show',
191
- cmd: `jsn dev flows show "${f.flow}"`,
191
+ cmd: `jsn flows show "${f.flow}"`,
192
192
  description: `View flow details for ${f.flow}`,
193
193
  })),
194
194
  ...result.businessRules.slice(0, 5).map(br => ({
195
195
  action: 'show',
196
- cmd: `jsn dev rules show "${br.name}"`,
196
+ cmd: `jsn rules show "${br.name}"`,
197
197
  description: `View business rule: ${br.name}`,
198
198
  })),
199
199
  ];
package/src/help.js CHANGED
@@ -9,23 +9,63 @@ const COMMAND_GROUPS = {
9
9
  { name: 'tasks', alias: 'task', desc: 'Manage catalog tasks' },
10
10
  { name: 'tickets', alias: 'ticket', desc: 'Query generic tickets' },
11
11
  ],
12
- 'DATA & ADMIN': [
12
+ 'AUTOMATION': [
13
+ { name: 'flows', desc: 'Manage Flow Designer flows' },
14
+ { name: 'actions', alias: 'action', desc: 'Manage flow actions (sys_hub_action_type_definition)' },
15
+ { name: 'rules', alias: 'br', desc: 'Manage business rules' },
16
+ { name: 'scrapi', desc: 'Manage Scripted REST APIs' },
17
+ { name: 'updatesets', desc: 'Manage update sets (set, yolo, export, import)' },
18
+ { name: 'eval', desc: 'Run server-side JavaScript' },
19
+ { name: 'rest', desc: 'Make raw REST API calls' },
20
+ ],
21
+ 'ACCESS': [
22
+ { name: 'acls', alias: 'acl', desc: 'Manage access controls' },
23
+ { name: 'roles', alias: 'role', desc: 'Manage roles' },
24
+ { name: 'scopes', desc: 'Manage application scopes' },
25
+ { name: 'properties', alias: 'prop', desc: 'Manage system properties' },
26
+ { name: 'privileges', alias: 'priv', desc: 'Manage scope privileges' },
27
+ { name: 'securitytypes', alias: 'st', desc: 'Manage security types' },
28
+ { name: 'aliases', alias: 'als', desc: 'Manage table aliases' },
29
+ ],
30
+ 'USER EXPERIENCE': [
31
+ { name: 'forms', desc: 'Manage form layouts' },
32
+ { name: 'lists', desc: 'Manage list layouts' },
33
+ { name: 'clientscripts', alias: 'cs', desc: 'Manage client scripts' },
34
+ { name: 'uipolicies', alias: 'up', desc: 'Manage UI policies' },
35
+ { name: 'uiactions', alias: 'ua', desc: 'Manage UI actions' },
36
+ { name: 'sppages', desc: 'Manage Service Portal pages' },
37
+ { name: 'spwidgets', desc: 'Manage Service Portal widgets' },
38
+ { name: 'uipages', desc: 'Manage UI pages' },
39
+ { name: 'appmenu', desc: 'Manage application menus' },
40
+ { name: 'listcontrols', alias: 'lc', desc: 'Manage list controls' },
41
+ { name: 'views', alias: 'vw', desc: 'Manage views' },
42
+ { name: 'uxscripts', alias: 'ux', desc: 'Manage UX source scripts' },
43
+ { name: 'catalogscripts', desc: 'Manage catalog client scripts' },
44
+ { name: 'cataloguipolicies', alias: 'cup', desc: 'Manage catalog UI policies' },
45
+ ],
46
+ 'DATA': [
13
47
  { name: 'records', desc: 'Generic Table API for any table' },
48
+ { name: 'tables', alias: 't', desc: 'Manage table definitions (sys_db_object)' },
49
+ { name: 'columns', alias: 'col', desc: 'Manage column definitions (sys_dictionary)' },
50
+ { name: 'includes', alias: 'si', desc: 'Manage script includes' },
51
+ { name: 'import', desc: 'Manage import sets' },
52
+ { name: 'logs', desc: 'View system logs' },
14
53
  { name: 'users', alias: 'user', desc: 'Manage ServiceNow users' },
15
54
  { name: 'groups', alias: 'group', desc: 'Manage groups' },
16
55
  { name: 'groupmembers', alias: 'gm', desc: 'Manage group memberships' },
17
56
  { name: 'grouproles', alias: 'gr', desc: 'Manage group roles' },
18
- ],
19
- 'DEVELOPMENT': [
20
- { name: 'dev', desc: 'Manage development artifacts (flows, includes, rules, ACLs, etc.)' },
21
- ],
22
- 'CONFIGURATION': [
23
- { name: 'setup', desc: 'Interactive first-time setup' },
24
- { name: 'auth', desc: 'Manage OAuth authentication' },
25
- { name: 'profiles', alias: 'profile', desc: 'Manage instance profiles' },
57
+ { name: 'relationships', alias: 'rel', desc: 'Manage table relationships' },
58
+ { name: 'appmodules', alias: 'am', desc: 'Manage application modules' },
26
59
  ],
27
60
  };
28
61
 
62
+ const CONFIGURATION_COMMANDS = [
63
+ { name: 'setup', desc: 'Interactive first-time setup' },
64
+ { name: 'auth', desc: 'Manage OAuth authentication' },
65
+ { name: 'profiles', alias: 'profile', desc: 'Manage instance profiles' },
66
+ { name: 'dev', desc: '[DEPRECATED] All dev subcommands are now top-level — use jsn flows, jsn acls, etc.' },
67
+ ];
68
+
29
69
  const UTILITY_COMMANDS = [
30
70
  { name: 'skill', desc: 'Manage the jsn AI agent skill file (show, fetch, install)' },
31
71
  { name: 'version', desc: 'Show version information (--check for npm updates)' },
@@ -67,6 +107,7 @@ LEARN MORE
67
107
 
68
108
  export function renderHelp() {
69
109
  const sections = Object.entries(COMMAND_GROUPS).map(([name, cmds]) => renderGroup(name, cmds));
110
+ const configSection = renderGroup('CONFIGURATION', CONFIGURATION_COMMANDS);
70
111
  const utilitySection = renderGroup('UTILITY', UTILITY_COMMANDS);
71
112
 
72
113
  return [
@@ -76,6 +117,8 @@ export function renderHelp() {
76
117
  '',
77
118
  ...sections,
78
119
  '',
120
+ configSection,
121
+ '',
79
122
  utilitySection,
80
123
  '',
81
124
  renderFlags(),
package/src/helpers.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Shared helper utilities
2
2
 
3
3
  import fs from 'node:fs';
4
- import search from '@inquirer/search';
4
+ import { paginatedSearch } from './paginated-search.js';
5
5
  import { isTTY, FormatAuto } from './output.js';
6
6
 
7
7
  export function getStringField(record, field) {
@@ -89,53 +89,56 @@ export function resolveFieldsParam(columns) {
89
89
  * @param {string} opts.labelField — field used to match selection (default: 'name')
90
90
  * @returns {Promise<void>|null} null if no selection made or non-interactive
91
91
  */
92
- export async function interactiveList({ app, table, singular, columns, limit = 20, query = '', formatLabel, labelField = 'name' }) {
92
+ export async function interactiveList({ app, table, singular, columns, limit = 50, query = '', formatLabel, labelField = 'name' }) {
93
+ app.requireInstance();
93
94
  const effectiveFormat = app.output.getFormat() === FormatAuto ? (isTTY(process.stdout) ? FormatAuto : FormatAuto) : app.output.getFormat();
94
95
  if (effectiveFormat !== FormatAuto || !isTTY(process.stdout) || !isTTY(process.stdin) || query) {
95
96
  return null; // not interactive — caller should fall back to text/table
96
97
  }
97
98
 
98
99
  const pickerColumns = ['sys_id', labelField, ...columns.filter(c => c !== labelField && c !== 'sys_id' && c !== '*')];
99
- const params = new URLSearchParams();
100
- params.set('sysparm_limit', String(limit));
101
- params.set('sysparm_display_value', 'all');
102
100
  const pickerFields = pickerColumns.join(',');
103
- if (pickerFields) params.set('sysparm_fields', pickerFields);
104
- params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
105
-
106
- const records = await app.sdk.list(table, params);
107
- if (records.length === 0) return null;
108
-
109
- const choices = records.map(r => ({
110
- name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
111
- value: r,
112
- }));
113
101
 
102
+ // Get total count
103
+ let totalCount = 0;
114
104
  try {
115
- const selected = await search({
116
- message: `Select ${vowelArticle(singular)} ${singular}:`,
117
- source: async (input) => {
118
- if (!input) return choices;
119
- const term = input.toLowerCase();
120
- return choices.filter(c => c.name.toLowerCase().includes(term));
121
- },
122
- });
123
- return selected; // the record object
124
- } catch (err) {
125
- if (err.name === 'ExitPromptError' || (err.message && err.message.includes('force closed'))) {
126
- return null;
105
+ totalCount = await app.sdk.aggregateCount(table, '');
106
+ } catch {
107
+ totalCount = 0;
108
+ }
109
+ if (totalCount === 0) return null;
110
+
111
+ // Server source adapter for paginatedSearch: (term, offset) => choices[]
112
+ async function serverSource(term, offset) {
113
+ const params = new URLSearchParams();
114
+ params.set('sysparm_limit', String(limit));
115
+ params.set('sysparm_display_value', 'all');
116
+ if (pickerFields) params.set('sysparm_fields', pickerFields);
117
+ if (term) {
118
+ params.set('sysparm_query', `${labelField}LIKE${term}^ORDERBYDESCsys_updated_on`);
119
+ } else {
120
+ params.set('sysparm_query', 'ORDERBYDESCsys_updated_on');
121
+ params.set('sysparm_offset', String(offset));
127
122
  }
128
- throw err;
123
+ const records = await app.sdk.list(table, params);
124
+ return records.map(r => ({
125
+ name: formatLabel ? formatLabel(r) : (getStringField(r, labelField) || getStringField(r, 'sys_id')),
126
+ value: r,
127
+ }));
129
128
  }
130
- }
131
129
 
132
- function vowelArticle(word) {
133
- const first = word.charAt(0).toLowerCase();
134
- return first === 'a' || first === 'e' || first === 'i' || first === 'o' || first === 'u' ? 'an' : 'a';
130
+ const selected = await paginatedSearch({
131
+ message: `Select ${singular === 'flow' ? 'a flow' : `a ${singular}`}`,
132
+ pageSize: 10,
133
+ totalCount,
134
+ source: serverSource,
135
+ });
136
+ // null = cancelled → undefined so callers skip fallback table
137
+ if (selected === null) return undefined;
138
+ return selected?.value; // unwrap {name, value} → raw record
135
139
  }
136
140
 
137
141
  /**
138
- * Known derived (read-only) ServiceNow fields that should not be set directly.
139
142
  * Maps table name → array of field names that are computed by the platform.
140
143
  * When a create/update payload contains these, a warning is emitted.
141
144
  */
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Paginated interactive search prompt with server-side pagination.
3
+ *
4
+ * Uses raw readline for terminal control — @inquirer/search doesn't support
5
+ * scroll-based pagination (source only fires on keystrokes, not on scroll).
6
+ *
7
+ * Returns the selected choice ({name, value}), or null on cancel.
8
+ */
9
+
10
+ import readline from 'node:readline';
11
+
12
+ export async function paginatedSearch({ message, pageSize, totalCount, pageSize: pgSize, source }) {
13
+ const PAGE = pageSize || pgSize || 10;
14
+ let choices = [];
15
+ let active = 0;
16
+ let loaded = 0;
17
+ let cancelled = false;
18
+ let term = '';
19
+ let outputLines = 0; // number of lines currently on screen
20
+
21
+ // Write + track: returns number of newlines written (cursor advance)
22
+ function writeLines(lines) {
23
+ const out = lines.join('\n');
24
+ process.stdout.write(out);
25
+ return lines.length - 1; // N elements = N-1 newlines = cursor advances this many
26
+ }
27
+
28
+ function render() {
29
+ if (outputLines > 0) {
30
+ process.stdout.moveCursor(0, -outputLines);
31
+ process.stdout.clearScreenDown();
32
+ } else {
33
+ // First render: ensure we start on a fresh line
34
+ process.stdout.cursorTo(0);
35
+ process.stdout.clearLine(0);
36
+ }
37
+
38
+ const buf = [];
39
+
40
+ // Header: show message + count
41
+ const countInfo = totalCount > 0 ? ` (${loaded} of ${totalCount} loaded)` : '';
42
+ buf.push(`${message}${countInfo}`);
43
+
44
+ // Visible window: center active item
45
+ const start = Math.max(0, active - Math.floor(PAGE / 2));
46
+ const end = Math.min(choices.length, start + PAGE);
47
+ const visible = choices.slice(start, end);
48
+
49
+ for (let i = 0; i < visible.length; i++) {
50
+ const choice = visible[i];
51
+ const isActive = (start + i) === active;
52
+ const prefix = isActive ? '\x1b[7m' : '';
53
+ const suffix = isActive ? '\x1b[0m' : '';
54
+ const display = choice.name.length > 70 ? choice.name.slice(0, 67) + '...' : choice.name;
55
+ buf.push(` ${prefix}${display}${suffix}`);
56
+ }
57
+
58
+ // Pad to constant height
59
+ while (buf.length < PAGE + 2) {
60
+ buf.push('');
61
+ }
62
+
63
+ // Footer
64
+ const more = loaded < totalCount;
65
+ const footer = more
66
+ ? `↑↓ navigate • ⏎ select • esc cancel • type to filter • ↓ at end loads more`
67
+ : `↑↓ navigate • ⏎ select • esc cancel • type to filter`;
68
+ buf.push('');
69
+ buf.push(footer);
70
+
71
+ // Search bar
72
+ buf.push(`> ${term}`);
73
+
74
+ outputLines = writeLines(buf);
75
+ }
76
+
77
+ function cleanup() {
78
+ if (outputLines > 0) {
79
+ process.stdout.moveCursor(0, -outputLines);
80
+ process.stdout.clearScreenDown();
81
+ outputLines = 0;
82
+ }
83
+ // Ensure raw mode is off
84
+ try { process.stdin.setRawMode(false); } catch { /* ok */ }
85
+ }
86
+
87
+ return new Promise((resolve) => {
88
+ const rl = readline.createInterface({
89
+ input: process.stdin,
90
+ output: process.stdout,
91
+ terminal: true,
92
+ });
93
+ try { process.stdin.setRawMode(true); } catch { /* non-TTY */ }
94
+
95
+ let inputBuf = '';
96
+
97
+ async function loadPage() {
98
+ if (loaded >= totalCount) return;
99
+ const newItems = await source('', loaded);
100
+ if (newItems.length > 0) {
101
+ choices = choices.concat(newItems);
102
+ loaded = Math.min(choices.length, totalCount);
103
+ }
104
+ }
105
+
106
+ // Initial load
107
+ (async () => {
108
+ const initial = await source('', 0);
109
+ if (cancelled) return;
110
+ choices = initial;
111
+ loaded = Math.min(choices.length, totalCount || Infinity);
112
+ active = 0;
113
+ render();
114
+ })();
115
+
116
+ rl.input.on('keypress', async (_str, key) => {
117
+ if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
118
+ cancelled = true;
119
+ rl.close();
120
+ return;
121
+ }
122
+
123
+ if (key.name === 'return' || key.name === 'enter') {
124
+ if (choices.length > 0 && active >= 0 && active < choices.length) {
125
+ rl.close();
126
+ return;
127
+ }
128
+ }
129
+
130
+ if (key.name === 'up' && active > 0) {
131
+ active--;
132
+ render();
133
+ return;
134
+ }
135
+
136
+ if (key.name === 'down') {
137
+ if (active < choices.length - 1) {
138
+ active++;
139
+ render();
140
+ } else if (loaded < totalCount) {
141
+ await loadPage();
142
+ if (active < choices.length - 1) active++;
143
+ render();
144
+ }
145
+ return;
146
+ }
147
+
148
+ if (key.name === 'backspace') {
149
+ if (inputBuf.length > 0) inputBuf = inputBuf.slice(0, -1);
150
+ } else if (key.sequence && key.sequence.length === 1 && !key.meta && !key.ctrl) {
151
+ inputBuf += key.sequence;
152
+ } else {
153
+ return;
154
+ }
155
+
156
+ // Search
157
+ term = inputBuf;
158
+ if (term) {
159
+ const results = await source(term, 0);
160
+ choices = results;
161
+ loaded = results.length;
162
+ } else {
163
+ // Reset to browse mode — reload first page
164
+ const results = await source('', 0);
165
+ choices = results;
166
+ loaded = Math.min(results.length, totalCount || Infinity);
167
+ }
168
+ active = 0;
169
+ render();
170
+ });
171
+
172
+ rl.on('close', () => {
173
+ cleanup();
174
+ const choice = (!cancelled && active >= 0 && active < choices.length) ? choices[active] : null;
175
+ resolve(cancelled ? null : choice);
176
+ });
177
+ });
178
+ }