minercon 3.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/CHANGELOG.md +439 -0
- package/LICENSE +22 -0
- package/README.md +401 -0
- package/images/icon.png +0 -0
- package/out/ansi.js +123 -0
- package/out/argumentHint.js +43 -0
- package/out/bukkitHelpParsing.js +62 -0
- package/out/cli.js +253 -0
- package/out/cliConfig.js +141 -0
- package/out/commandLine.js +28 -0
- package/out/commandSuggestions.js +202 -0
- package/out/commandTree.js +46 -0
- package/out/commandTreeCache.js +171 -0
- package/out/commandTreeCrawler.js +583 -0
- package/out/commandTreeParsingBrigadier.js +426 -0
- package/out/commandTreeParsingBukkit.js +116 -0
- package/out/commandTreeSuggestions.js +142 -0
- package/out/completionBackend.js +94 -0
- package/out/completionEngine.js +376 -0
- package/out/completionQueries.js +86 -0
- package/out/completionsBackend.js +97 -0
- package/out/connectionManager.js +209 -0
- package/out/displayArgumentHint.js +43 -0
- package/out/displayCommandTree.js +115 -0
- package/out/displaySuggestion.js +282 -0
- package/out/extension.js +190 -0
- package/out/helpTextParsing.js +445 -0
- package/out/historySearch.js +46 -0
- package/out/historyStore.js +126 -0
- package/out/lineEditor.js +525 -0
- package/out/localCommandTree.js +541 -0
- package/out/logger.js +14 -0
- package/out/minercon +253 -0
- package/out/pager.js +168 -0
- package/out/pagination.js +142 -0
- package/out/rconClient.js +97 -0
- package/out/rconConnectionManager.js +238 -0
- package/out/rconProtocol.js +421 -0
- package/out/rconSession.js +920 -0
- package/out/rconTerminal.js +80 -0
- package/out/suggestionDisplay.js +286 -0
- package/out/terminalOutput.js +110 -0
- package/out/unpaginate.js +30 -0
- package/package.json +138 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/completionBackend.ts
|
|
3
|
+
//
|
|
4
|
+
// The completionEngine state machine knows it needs completions and usage
|
|
5
|
+
// text for a given input line — it doesn't know or care whether those come
|
|
6
|
+
// from a network round trip or an in-memory lookup. A CompletionBackend is
|
|
7
|
+
// that boundary: one implementation asks the server-side TabComplete
|
|
8
|
+
// plugin over RCON, the other asks the locally-built command tree.
|
|
9
|
+
//
|
|
10
|
+
// Driving both modes through the same backend interface (and therefore the
|
|
11
|
+
// same engine, the same dispatch plumbing, and the same rendering) is what
|
|
12
|
+
// lets RconSession be entirely mode-blind — "which backend" is decided once,
|
|
13
|
+
// not branched on at every call site.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.LocalCompletionBackend = exports.RconCompletionBackend = void 0;
|
|
16
|
+
const completionQueries_1 = require("./completionQueries");
|
|
17
|
+
/**
|
|
18
|
+
* Server-side completions via the TabComplete plugin's `tabcomplete`/`cmdusage` commands.
|
|
19
|
+
*
|
|
20
|
+
* Takes a `controller` thunk rather than a fixed `RconController` — the
|
|
21
|
+
* connection manager replaces its controller wholesale on every reconnect, so
|
|
22
|
+
* capturing one instance up front would silently start sending through a dead
|
|
23
|
+
* (disconnected) controller after the first reconnect, with every fetch
|
|
24
|
+
* throwing "Not connected" (swallowed by the caller as "no completions").
|
|
25
|
+
* Looking it up fresh on each call always reaches whatever's live.
|
|
26
|
+
*/
|
|
27
|
+
class RconCompletionBackend {
|
|
28
|
+
getController;
|
|
29
|
+
constructor(getController) {
|
|
30
|
+
this.getController = getController;
|
|
31
|
+
}
|
|
32
|
+
async fetchCompletions(line) {
|
|
33
|
+
const query = (0, completionQueries_1.buildCompletionsQuery)(line);
|
|
34
|
+
if (query === null) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const response = await this.getController().send(`tabcomplete ${query}`);
|
|
38
|
+
return (0, completionQueries_1.parseCompletionsResponse)(response);
|
|
39
|
+
}
|
|
40
|
+
async fetchUsage(line) {
|
|
41
|
+
const query = (0, completionQueries_1.buildUsageQuery)(line);
|
|
42
|
+
if (query === null) {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
const response = await this.getController().send(`cmdusage ${query}`);
|
|
46
|
+
return (0, completionQueries_1.parseUsageResponse)(response);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.RconCompletionBackend = RconCompletionBackend;
|
|
50
|
+
/**
|
|
51
|
+
* Local completions via the command tree CommandTreeCrawler builds at
|
|
52
|
+
* startup — a synchronous in-memory lookup, wrapped in `async` so it flows
|
|
53
|
+
* through exactly the same dispatch path as the RCON backend (the `await`
|
|
54
|
+
* still yields to the microtask queue, so a `dispatchToEngine` call made from
|
|
55
|
+
* here lands *after* the effect loop that triggered it has finished — same
|
|
56
|
+
* ordering guarantee the real async backend gets for free from the network).
|
|
57
|
+
*/
|
|
58
|
+
class LocalCompletionBackend {
|
|
59
|
+
commandTree;
|
|
60
|
+
// getSuggestions sometimes returns undefined argumentHelp when the user is
|
|
61
|
+
// typing a free-form argument value the tree has no completions for (e.g. a
|
|
62
|
+
// player name). Rather than letting the hint flicker to blank, cache the last
|
|
63
|
+
// resolved usage and return it until the commandPath changes — each distinct
|
|
64
|
+
// navigated path (e.g. "mv worldborder" vs "mv worldborder damage buffer")
|
|
65
|
+
// gets its own cache entry so deeper subcommand navigation produces the
|
|
66
|
+
// correct, more-specific usage string rather than a stale parent-level one.
|
|
67
|
+
cachedCommandPath = null;
|
|
68
|
+
cachedHelp = null;
|
|
69
|
+
constructor(commandTree) {
|
|
70
|
+
this.commandTree = commandTree;
|
|
71
|
+
}
|
|
72
|
+
async fetchCompletions(line) {
|
|
73
|
+
return this.commandTree.getSuggestions(line).suggestions;
|
|
74
|
+
}
|
|
75
|
+
async fetchUsage(line) {
|
|
76
|
+
const result = this.commandTree.getSuggestions(line);
|
|
77
|
+
if (result.argumentHelp !== undefined) {
|
|
78
|
+
// Match the shape of the server's `cmdusage` response (e.g. "clear
|
|
79
|
+
// [<targets>] [<item>]") - formatArgumentHint derives the command
|
|
80
|
+
// prefix from this leading commandPath, so it must be present even
|
|
81
|
+
// when there's no argument help (e.g. "reload").
|
|
82
|
+
const usage = result.argumentHelp ? `${result.commandPath} ${result.argumentHelp}` : result.commandPath;
|
|
83
|
+
if (result.commandPath !== this.cachedCommandPath) {
|
|
84
|
+
this.cachedCommandPath = result.commandPath ?? null;
|
|
85
|
+
this.cachedHelp = usage;
|
|
86
|
+
return usage;
|
|
87
|
+
}
|
|
88
|
+
return this.cachedHelp ?? usage;
|
|
89
|
+
}
|
|
90
|
+
return this.cachedHelp ?? '';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
exports.LocalCompletionBackend = LocalCompletionBackend;
|
|
94
|
+
//# sourceMappingURL=completionBackend.js.map
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/completionEngine.ts
|
|
3
|
+
//
|
|
4
|
+
// Pure decision core for server-side ("plugin mode") tab completion.
|
|
5
|
+
//
|
|
6
|
+
// This module knows nothing about VS Code, sockets, or wall-clock time — it's
|
|
7
|
+
// a reducer: (Machine, Event) -> { machine: Machine, effects: Effect[] }. The
|
|
8
|
+
// shell (RconSession) feeds it terminal events and executes the Effects it
|
|
9
|
+
// returns (RCON sends, ANSI writes, applying text to the line). That makes
|
|
10
|
+
// every async race in here something you can drive with a scripted event
|
|
11
|
+
// sequence in a test, rather than something that happens to you against a
|
|
12
|
+
// real server at 2am.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.applySuggestion = applySuggestion;
|
|
15
|
+
exports.longestCommonPrefix = longestCommonPrefix;
|
|
16
|
+
exports.createMachine = createMachine;
|
|
17
|
+
exports.step = step;
|
|
18
|
+
const commandLine_1 = require("./commandLine");
|
|
19
|
+
const completionQueries_1 = require("./completionQueries");
|
|
20
|
+
// ───────────────────────── completion-list helpers ─────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Splices a raw completion candidate (e.g. "adventure", "distance=", "[")
|
|
23
|
+
* into the line as typed so far, the same way a real client reconciles a
|
|
24
|
+
* server-suggested replacement against partial input.
|
|
25
|
+
*
|
|
26
|
+
* The server only ever hands back the candidate text — never which part of
|
|
27
|
+
* the line it replaces — and that part isn't reliably "the last
|
|
28
|
+
* space-delimited word": selector/NBT syntax nests further completion
|
|
29
|
+
* boundaries inside a single token (typing "@a[dist" and completing to
|
|
30
|
+
* "@a[distance=" should only replace "dist"; typing the *complete* selector
|
|
31
|
+
* "@a" and getting "[" appended makes "@a[", where nothing of "@a" overlaps
|
|
32
|
+
* the candidate at all).
|
|
33
|
+
*
|
|
34
|
+
* So instead of guessing where the word boundary is, this finds the longest
|
|
35
|
+
* suffix of what's typed that the candidate continues (a prefix-overlap,
|
|
36
|
+
* checked longest-first so e.g. "dist" wins over the shorter-but-also-
|
|
37
|
+
* matching "t"), and splices the candidate in over just that overlap. An
|
|
38
|
+
* overlap of zero naturally degrades to appending — exactly right for
|
|
39
|
+
* refinement suggestions ("@a" + "[") and for completions chosen right after
|
|
40
|
+
* a trailing space ("adventure " + "@a").
|
|
41
|
+
*/
|
|
42
|
+
function applySuggestion(line, suggestionText) {
|
|
43
|
+
let overlap = 0;
|
|
44
|
+
for (let len = Math.min(line.length, suggestionText.length); len > 0; len--) {
|
|
45
|
+
if (suggestionText.startsWith(line.slice(line.length - len))) {
|
|
46
|
+
overlap = len;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return line.slice(0, line.length - overlap) + suggestionText;
|
|
51
|
+
}
|
|
52
|
+
/** The longest string every item in `items` starts with (`''` if `items` is empty). */
|
|
53
|
+
function longestCommonPrefix(items) {
|
|
54
|
+
if (items.length === 0) {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
let prefix = items[0];
|
|
58
|
+
for (let i = 1; i < items.length && prefix.length > 0; i++) {
|
|
59
|
+
let len = 0;
|
|
60
|
+
const max = Math.min(prefix.length, items[i].length);
|
|
61
|
+
while (len < max && prefix[len] === items[i][len]) {
|
|
62
|
+
len++;
|
|
63
|
+
}
|
|
64
|
+
prefix = prefix.slice(0, len);
|
|
65
|
+
}
|
|
66
|
+
return prefix;
|
|
67
|
+
}
|
|
68
|
+
function usageMatches(usage, line) {
|
|
69
|
+
return usage.kind !== 'none' && usage.forQuery === line;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Once a command has resolved to a single usage line, that usage stays valid
|
|
73
|
+
* across further keystrokes that only change the *arguments* — the command
|
|
74
|
+
* portion itself doesn't change, and `formatArgumentHint` already recomputes
|
|
75
|
+
* which argument is highlighted purely from `(usage, line)`. "Covers" means
|
|
76
|
+
* the cached query's words are a prefix of the current line's words, i.e. the
|
|
77
|
+
* command portion hasn't changed (only what comes after it has, or the user
|
|
78
|
+
* is still extending it). An empty-text usage (ambiguous prefix or "too
|
|
79
|
+
* broad") never covers anything — there's nothing resolved to stick to, so
|
|
80
|
+
* the next natural pause point should ask again.
|
|
81
|
+
*
|
|
82
|
+
* Exception: if the usage's first argument token is a `(choice|list)` — a
|
|
83
|
+
* subcommand branch point — and the user has typed beyond the cached query,
|
|
84
|
+
* they have navigated *into* one of those choices and the usage is stale;
|
|
85
|
+
* argument values (`<foo>`, `[<foo>]`) don't trigger this.
|
|
86
|
+
*/
|
|
87
|
+
function usageCoversLine(usage, line) {
|
|
88
|
+
if (usage.kind !== 'ready' || usage.text === '') {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const cachedWords = (0, commandLine_1.splitCommandLine)(usage.forQuery).parts;
|
|
92
|
+
const lineWords = (0, commandLine_1.splitCommandLine)(line).parts;
|
|
93
|
+
if (lineWords.length < cachedWords.length) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (!cachedWords.every((word, i) => word === lineWords[i])) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (lineWords.length > cachedWords.length && /\([^)]+\)/.test(usage.text)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
function createMachine() {
|
|
105
|
+
return { seq: 0, phase: { kind: 'closed' }, fetch: { kind: 'idle' } };
|
|
106
|
+
}
|
|
107
|
+
function step(m, event) {
|
|
108
|
+
switch (event.kind) {
|
|
109
|
+
case 'lineChanged': return onLineChanged(m, event.line);
|
|
110
|
+
case 'tab': return onTabOrShiftTab(m, event.line, 'tab');
|
|
111
|
+
case 'shiftTab': return onTabOrShiftTab(m, event.line, 'shiftTab');
|
|
112
|
+
case 'arrow': return onArrow(m, event.direction);
|
|
113
|
+
case 'selectIndex': return onSelectIndex(m, event.index);
|
|
114
|
+
case 'escape': return onEscape(m);
|
|
115
|
+
case 'completionsResult': return onCompletionsResult(m, event.requestId, event.items);
|
|
116
|
+
case 'usageResult': return onUsageResult(m, event.requestId, event.text);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function unchanged(m) { return { machine: m, effects: [] }; }
|
|
120
|
+
function renderEffect(phase) {
|
|
121
|
+
return {
|
|
122
|
+
kind: 'render',
|
|
123
|
+
items: phase.items,
|
|
124
|
+
selectedIndex: phase.selectedIndex,
|
|
125
|
+
usage: phase.usage.kind === 'ready' ? phase.usage.text : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function closeAndHide(m) {
|
|
129
|
+
return { machine: { seq: m.seq, phase: { kind: 'closed' }, fetch: { kind: 'idle' } }, effects: [{ kind: 'hide' }] };
|
|
130
|
+
}
|
|
131
|
+
/** Issue a fresh `tabcomplete` fetch for `line` (used for first-time queries and re-derives after a queued line wins). */
|
|
132
|
+
function fetchCompletionsFor(m, line, reason) {
|
|
133
|
+
const query = (0, completionQueries_1.buildCompletionsQuery)(line);
|
|
134
|
+
if (query === null) {
|
|
135
|
+
return closeAndHide(m);
|
|
136
|
+
}
|
|
137
|
+
const requestId = m.seq + 1;
|
|
138
|
+
const machine = {
|
|
139
|
+
seq: requestId,
|
|
140
|
+
phase: m.phase,
|
|
141
|
+
fetch: { kind: 'busy', requestId, purpose: { kind: 'completions', reason }, forLine: line, queued: null },
|
|
142
|
+
};
|
|
143
|
+
return { machine, effects: [{ kind: 'fetchCompletions', requestId, query }] };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Open `basePhase` while kicking off a background `cmdusage` fetch for
|
|
147
|
+
* `forLine`: marks the usage `loading`, allocates the request id, and returns
|
|
148
|
+
* the busy machine plus the `fetchUsage` + `render` effects. Centralizes the
|
|
149
|
+
* seq/requestId bookkeeping that several call sites would otherwise repeat.
|
|
150
|
+
* Callers must have already checked the wire is free (`fetch.kind === 'idle'`).
|
|
151
|
+
*/
|
|
152
|
+
function fetchUsageInBackground(m, basePhase, forLine, usageQuery) {
|
|
153
|
+
const requestId = m.seq + 1;
|
|
154
|
+
const phase = { ...basePhase, usage: { kind: 'loading', forQuery: forLine } };
|
|
155
|
+
return {
|
|
156
|
+
machine: { seq: requestId, phase, fetch: { kind: 'busy', requestId, purpose: { kind: 'usage' }, forLine, queued: null } },
|
|
157
|
+
effects: [{ kind: 'fetchUsage', requestId, query: usageQuery }, renderEffect(phase)],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// ── lineChanged: the user typed or erased a character ──
|
|
161
|
+
function onLineChanged(m, line) {
|
|
162
|
+
const query = (0, completionQueries_1.buildCompletionsQuery)(line);
|
|
163
|
+
if (query === null) {
|
|
164
|
+
if (m.phase.kind === 'closed' && m.fetch.kind === 'idle') {
|
|
165
|
+
return unchanged(m);
|
|
166
|
+
}
|
|
167
|
+
return closeAndHide(m);
|
|
168
|
+
}
|
|
169
|
+
if (m.fetch.kind === 'busy') {
|
|
170
|
+
// RCON serializes requests — only one fetch in flight at a time. Remember
|
|
171
|
+
// the newest input and refetch for it once the outstanding one resolves.
|
|
172
|
+
return { machine: { ...m, fetch: { ...m.fetch, queued: { line, reason: 'typing' } } }, effects: [] };
|
|
173
|
+
}
|
|
174
|
+
return fetchCompletionsFor(m, line, 'typing');
|
|
175
|
+
}
|
|
176
|
+
// ── tab / shiftTab ──
|
|
177
|
+
function onTabOrShiftTab(m, line, which) {
|
|
178
|
+
const query = (0, completionQueries_1.buildCompletionsQuery)(line);
|
|
179
|
+
if (query === null) {
|
|
180
|
+
return m.phase.kind === 'open'
|
|
181
|
+
? { machine: { ...m, phase: { kind: 'closed' } }, effects: [{ kind: 'hide' }] }
|
|
182
|
+
: unchanged(m);
|
|
183
|
+
}
|
|
184
|
+
const phase = m.phase;
|
|
185
|
+
const delta = which === 'tab' ? 1 : -1;
|
|
186
|
+
// Already cycling and nothing's been typed since the last suggestion was
|
|
187
|
+
// applied: advance to the next/previous item. This is an exact comparison
|
|
188
|
+
// (not a time window) — as soon as the line diverges from what was applied,
|
|
189
|
+
// we fall through and re-derive for the new line instead.
|
|
190
|
+
if (phase.kind === 'open' && phase.mode.kind === 'cycling' && phase.items.length > 0
|
|
191
|
+
&& line === applySuggestion(phase.query, phase.items[phase.selectedIndex])) {
|
|
192
|
+
return advance(m, phase, phase.selectedIndex, delta);
|
|
193
|
+
}
|
|
194
|
+
// We already have fresh items for exactly this input — they were pulled
|
|
195
|
+
// live as the user typed, so there's no need to hit the server again.
|
|
196
|
+
if (phase.kind === 'open' && phase.query === line && phase.items.length > 0) {
|
|
197
|
+
// First Tab with multiple candidates: if they share a prefix longer than
|
|
198
|
+
// what's typed, complete to that prefix (bash-style) and leave the list
|
|
199
|
+
// open — a follow-up Tab with nothing further to gain falls through to
|
|
200
|
+
// cycling below. A single candidate's "common prefix" is itself, but
|
|
201
|
+
// that case is left to the cycling branch, which already fills it in
|
|
202
|
+
// (and fetches its usage line) on the first press.
|
|
203
|
+
if (which === 'tab' && phase.items.length > 1) {
|
|
204
|
+
const lcp = longestCommonPrefix(phase.items);
|
|
205
|
+
const completed = applySuggestion(line, lcp);
|
|
206
|
+
if (completed !== line) {
|
|
207
|
+
const newPhase = { ...phase, query: completed };
|
|
208
|
+
return { machine: { ...m, phase: newPhase }, effects: [{ kind: 'applySuggestion', text: lcp }, renderEffect(newPhase)] };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Tab keeps the existing selection (e.g. from arrow keys); Shift-Tab steps
|
|
212
|
+
// back from it — both rules match the pre-refactor behavior.
|
|
213
|
+
const base = (phase.selectedIndex >= 0 && phase.selectedIndex < phase.items.length) ? phase.selectedIndex : 0;
|
|
214
|
+
const initialDelta = which === 'tab' ? 0 : -1;
|
|
215
|
+
return advance(m, phase, base, initialDelta, /* maybeFetchUsage */ true);
|
|
216
|
+
}
|
|
217
|
+
// Need fresh completions from the server, but only one fetch at a time —
|
|
218
|
+
// e.g. a background usage fetch may still be outstanding. Remember what the
|
|
219
|
+
// user actually wants and act on it the moment the wire frees up, rather
|
|
220
|
+
// than silently swallowing the keypress.
|
|
221
|
+
if (m.fetch.kind === 'busy') {
|
|
222
|
+
return { machine: { ...m, fetch: { ...m.fetch, queued: { line, reason: which } } }, effects: [] };
|
|
223
|
+
}
|
|
224
|
+
return fetchCompletionsFor(m, line, which);
|
|
225
|
+
}
|
|
226
|
+
function advance(m, phase, baseIndex, delta, maybeFetchUsage = false) {
|
|
227
|
+
const selectedIndex = (baseIndex + delta + phase.items.length) % phase.items.length;
|
|
228
|
+
const newPhase = { ...phase, selectedIndex, mode: { kind: 'cycling' } };
|
|
229
|
+
const applyEffect = { kind: 'applySuggestion', text: phase.items[selectedIndex] };
|
|
230
|
+
// The usage line is purely supplementary — fetch it in the background
|
|
231
|
+
// (once the wire is free) rather than making the user wait on it before
|
|
232
|
+
// the completion itself is applied.
|
|
233
|
+
if (maybeFetchUsage && !usageMatches(newPhase.usage, phase.query) && m.fetch.kind === 'idle') {
|
|
234
|
+
const usageQuery = (0, completionQueries_1.buildUsageQuery)(phase.query);
|
|
235
|
+
if (usageQuery !== null) {
|
|
236
|
+
const usageStep = fetchUsageInBackground(m, newPhase, phase.query, usageQuery);
|
|
237
|
+
return { machine: usageStep.machine, effects: [applyEffect, ...usageStep.effects] };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { machine: { ...m, phase: newPhase }, effects: [applyEffect, renderEffect(newPhase)] };
|
|
241
|
+
}
|
|
242
|
+
// ── arrow keys: browse the list without committing to anything ──
|
|
243
|
+
function onArrow(m, direction) {
|
|
244
|
+
if (m.phase.kind !== 'open' || m.phase.items.length === 0) {
|
|
245
|
+
return unchanged(m);
|
|
246
|
+
}
|
|
247
|
+
const { items, selectedIndex } = m.phase;
|
|
248
|
+
const delta = direction === 'down' ? 1 : -1;
|
|
249
|
+
const next = (selectedIndex + delta + items.length) % items.length;
|
|
250
|
+
const newPhase = { ...m.phase, selectedIndex: next, mode: { kind: 'preview' } };
|
|
251
|
+
return { machine: { ...m, phase: newPhase }, effects: [renderEffect(newPhase)] };
|
|
252
|
+
}
|
|
253
|
+
// ── jump/page: shell-computed target index, same "just browsing" semantics as arrows ──
|
|
254
|
+
function onSelectIndex(m, index) {
|
|
255
|
+
if (m.phase.kind !== 'open' || index < 0 || index >= m.phase.items.length) {
|
|
256
|
+
return unchanged(m);
|
|
257
|
+
}
|
|
258
|
+
const newPhase = { ...m.phase, selectedIndex: index, mode: { kind: 'preview' } };
|
|
259
|
+
return { machine: { ...m, phase: newPhase }, effects: [renderEffect(newPhase)] };
|
|
260
|
+
}
|
|
261
|
+
// ── escape: close, restoring the pre-completion line if we'd applied one ──
|
|
262
|
+
function onEscape(m) {
|
|
263
|
+
if (m.phase.kind !== 'open') {
|
|
264
|
+
return unchanged(m);
|
|
265
|
+
}
|
|
266
|
+
const effects = [];
|
|
267
|
+
if (m.phase.mode.kind === 'cycling') {
|
|
268
|
+
effects.push({ kind: 'restoreLine', text: m.phase.query });
|
|
269
|
+
}
|
|
270
|
+
effects.push({ kind: 'hide' });
|
|
271
|
+
return { machine: { ...m, phase: { kind: 'closed' } }, effects };
|
|
272
|
+
}
|
|
273
|
+
// ── server replies ──
|
|
274
|
+
function onCompletionsResult(m, requestId, items) {
|
|
275
|
+
if (m.fetch.kind !== 'busy' || m.fetch.requestId !== requestId || m.fetch.purpose.kind !== 'completions') {
|
|
276
|
+
return unchanged(m); // stale/superseded response — ignore
|
|
277
|
+
}
|
|
278
|
+
const { forLine, queued, purpose } = m.fetch;
|
|
279
|
+
if (queued !== null) {
|
|
280
|
+
// The user has since moved on to different input (or pressed Tab again) —
|
|
281
|
+
// discard these results and immediately act on what they actually want now.
|
|
282
|
+
return fetchCompletionsFor(m, queued.line, queued.reason);
|
|
283
|
+
}
|
|
284
|
+
if (items.length === 0) {
|
|
285
|
+
if (purpose.reason !== 'typing') {
|
|
286
|
+
return closeAndHide(m);
|
|
287
|
+
}
|
|
288
|
+
// No completions for this input, but it still looks like a command in
|
|
289
|
+
// progress — rather than closing, show what argument comes next (e.g.
|
|
290
|
+
// "/gamemode creative " has no completions for the target selector, but
|
|
291
|
+
// there's still a usage hint worth displaying). Mirrors the "open with an
|
|
292
|
+
// empty item list, fill in usage in the background" shape used elsewhere.
|
|
293
|
+
const usageQuery = (0, completionQueries_1.buildUsageQuery)(forLine);
|
|
294
|
+
if (usageQuery === null) {
|
|
295
|
+
return closeAndHide(m);
|
|
296
|
+
}
|
|
297
|
+
const basePhase = {
|
|
298
|
+
kind: 'open', query: forLine, items: [], selectedIndex: -1,
|
|
299
|
+
usage: { kind: 'none' }, mode: { kind: 'preview' },
|
|
300
|
+
};
|
|
301
|
+
return fetchUsageInBackground(m, basePhase, forLine, usageQuery);
|
|
302
|
+
}
|
|
303
|
+
if (purpose.reason === 'typing') {
|
|
304
|
+
// Preserve the existing selection across re-fetches if still in range —
|
|
305
|
+
// keeps arrow-key navigation stable while the user keeps typing.
|
|
306
|
+
const previous = m.phase.kind === 'open' ? m.phase.selectedIndex : -1;
|
|
307
|
+
const selectedIndex = (previous >= 0 && previous < items.length) ? previous : 0;
|
|
308
|
+
// Sticky usage: once a command has resolved to a single usage line, keep
|
|
309
|
+
// showing it across further keystrokes within that same command — only
|
|
310
|
+
// the highlighted argument changes, which formatArgumentHint derives
|
|
311
|
+
// purely from (usage, line), no fetch needed. A new command (or one that
|
|
312
|
+
// hasn't resolved yet) starts fresh — the old usage no longer applies.
|
|
313
|
+
const carriedUsage = (m.phase.kind === 'open' && usageCoversLine(m.phase.usage, forLine))
|
|
314
|
+
? m.phase.usage
|
|
315
|
+
: { kind: 'none' };
|
|
316
|
+
const basePhase = { kind: 'open', query: forLine, items, selectedIndex, usage: carriedUsage, mode: { kind: 'preview' } };
|
|
317
|
+
// Nothing resolved for this command yet, but the line looks like a
|
|
318
|
+
// natural pause point — the user just finished a token (trailing space),
|
|
319
|
+
// the same "word boundary" signal the empty-completions hint-phase above
|
|
320
|
+
// keys off. Worth asking whether it's now resolved to a single command.
|
|
321
|
+
// (Asking on *every* keystroke would double the round trips per
|
|
322
|
+
// character typed — the sticky cache plus this pause-point trigger keeps
|
|
323
|
+
// it to roughly one usage fetch per command, not one per character.)
|
|
324
|
+
if (carriedUsage.kind === 'none' && forLine.endsWith(' ')) {
|
|
325
|
+
const usageQuery = (0, completionQueries_1.buildUsageQuery)(forLine);
|
|
326
|
+
if (usageQuery !== null) {
|
|
327
|
+
return fetchUsageInBackground(m, basePhase, forLine, usageQuery);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { machine: { ...m, phase: basePhase, fetch: { kind: 'idle' } }, effects: [renderEffect(basePhase)] };
|
|
331
|
+
}
|
|
332
|
+
// First Tab landing fresh completions for multiple candidates: same
|
|
333
|
+
// common-prefix completion as the "items already on hand" path in
|
|
334
|
+
// onTabOrShiftTab, just reached via a server round trip instead.
|
|
335
|
+
if (purpose.reason === 'tab' && items.length > 1) {
|
|
336
|
+
const lcp = longestCommonPrefix(items);
|
|
337
|
+
const completed = applySuggestion(forLine, lcp);
|
|
338
|
+
if (completed !== forLine) {
|
|
339
|
+
const phase = {
|
|
340
|
+
kind: 'open', query: completed, items, selectedIndex: 0,
|
|
341
|
+
usage: { kind: 'none' }, mode: { kind: 'preview' },
|
|
342
|
+
};
|
|
343
|
+
return { machine: { ...m, phase, fetch: { kind: 'idle' } }, effects: [{ kind: 'applySuggestion', text: lcp }, renderEffect(phase)] };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// purpose.reason is 'tab' or 'shiftTab': apply a suggestion right away and
|
|
347
|
+
// enter cycling mode; fetch the usage line afterwards without blocking on it.
|
|
348
|
+
const selectedIndex = purpose.reason === 'shiftTab' ? items.length - 1 : 0;
|
|
349
|
+
const basePhase = {
|
|
350
|
+
kind: 'open', query: forLine, items, selectedIndex,
|
|
351
|
+
usage: { kind: 'none' }, mode: { kind: 'cycling' },
|
|
352
|
+
};
|
|
353
|
+
const applyEffect = { kind: 'applySuggestion', text: items[selectedIndex] };
|
|
354
|
+
const usageQuery = (0, completionQueries_1.buildUsageQuery)(forLine);
|
|
355
|
+
if (usageQuery === null) {
|
|
356
|
+
return { machine: { ...m, phase: basePhase, fetch: { kind: 'idle' } }, effects: [applyEffect, renderEffect(basePhase)] };
|
|
357
|
+
}
|
|
358
|
+
const usageStep = fetchUsageInBackground(m, basePhase, forLine, usageQuery);
|
|
359
|
+
return { machine: usageStep.machine, effects: [applyEffect, ...usageStep.effects] };
|
|
360
|
+
}
|
|
361
|
+
function onUsageResult(m, requestId, text) {
|
|
362
|
+
if (m.fetch.kind !== 'busy' || m.fetch.requestId !== requestId || m.fetch.purpose.kind !== 'usage') {
|
|
363
|
+
return unchanged(m);
|
|
364
|
+
}
|
|
365
|
+
const { forLine, queued } = m.fetch;
|
|
366
|
+
if (queued !== null) {
|
|
367
|
+
return fetchCompletionsFor(m, queued.line, queued.reason);
|
|
368
|
+
}
|
|
369
|
+
if (m.phase.kind !== 'open' || m.phase.query !== forLine) {
|
|
370
|
+
// The list has moved on (closed, or now showing a different query) — discard.
|
|
371
|
+
return { machine: { ...m, fetch: { kind: 'idle' } }, effects: [] };
|
|
372
|
+
}
|
|
373
|
+
const phase = { ...m.phase, usage: { kind: 'ready', forQuery: forLine, text } };
|
|
374
|
+
return { machine: { ...m, phase, fetch: { kind: 'idle' } }, effects: [renderEffect(phase)] };
|
|
375
|
+
}
|
|
376
|
+
//# sourceMappingURL=completionEngine.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/completionQueries.ts
|
|
3
|
+
//
|
|
4
|
+
// Pure adapters between an input line and the server-side TabComplete plugin's
|
|
5
|
+
// `tabcomplete`/`cmdusage` wire format: build the argument string to send, and
|
|
6
|
+
// parse the response back. No state and no dependency on the completion state
|
|
7
|
+
// machine — shared by both `completionEngine` (which builds queries) and
|
|
8
|
+
// `RconCompletionBackend` (which builds queries and parses responses), so the
|
|
9
|
+
// backend needn't pull in the reducer just for these.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.buildCompletionsQuery = buildCompletionsQuery;
|
|
12
|
+
exports.buildUsageQuery = buildUsageQuery;
|
|
13
|
+
exports.parseCompletionsResponse = parseCompletionsResponse;
|
|
14
|
+
exports.parseUsageResponse = parseUsageResponse;
|
|
15
|
+
const ansi_1 = require("./ansi");
|
|
16
|
+
const commandLine_1 = require("./commandLine");
|
|
17
|
+
/**
|
|
18
|
+
* Builds the argument string to send to the `tabcomplete` plugin command for
|
|
19
|
+
* a given input line, or null if the line isn't a command at all.
|
|
20
|
+
*
|
|
21
|
+
* A trailing "-" is the plugin's convention for "the user typed a trailing
|
|
22
|
+
* space here" (some RCON clients strip trailing whitespace before it reaches
|
|
23
|
+
* the plugin). A bare "-" with no other parts asks for root-level completions:
|
|
24
|
+
* Brigadier suggests by prefix-matching the *remaining* input, and an empty
|
|
25
|
+
* remaining string matches every root command name.
|
|
26
|
+
*/
|
|
27
|
+
function buildCompletionsQuery(input) {
|
|
28
|
+
if (input.startsWith('.')) {
|
|
29
|
+
return input;
|
|
30
|
+
} // builtin commands: pass through for local handling
|
|
31
|
+
if (!input.startsWith('/')) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const { parts, hasTrailingSpace } = (0, commandLine_1.splitCommandLine)(input);
|
|
35
|
+
if (parts.length === 0) {
|
|
36
|
+
return '-';
|
|
37
|
+
}
|
|
38
|
+
return hasTrailingSpace ? `${parts.join(' ')} -` : parts.join(' ');
|
|
39
|
+
}
|
|
40
|
+
/** Builds the argument string for `cmdusage`, or null if there's nothing to ask about yet. */
|
|
41
|
+
function buildUsageQuery(input) {
|
|
42
|
+
if (!input.startsWith('/')) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const withoutSlash = input.slice(1).trim();
|
|
46
|
+
return withoutSlash.length > 0 ? withoutSlash : null;
|
|
47
|
+
}
|
|
48
|
+
/** Every failure/meta message from both `tabcomplete` and `cmdusage` starts with "(". */
|
|
49
|
+
function isFailureResponse(response) {
|
|
50
|
+
return !response || response.trim().startsWith('(');
|
|
51
|
+
}
|
|
52
|
+
function parseCompletionsResponse(response) {
|
|
53
|
+
if (isFailureResponse(response)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return response.split('\n').map(s => s.trim()).filter(s => s.length > 0);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `cmdusage` resolves an input to one of three shapes: a clean failure like
|
|
60
|
+
* "(too broad — use /help mvp or provide a subcommand)" (caught above), a
|
|
61
|
+
* single matching command's usage line, or — when the input is still an
|
|
62
|
+
* ambiguous prefix of multiple subcommands (e.g. "mvp c" matching both "mvp
|
|
63
|
+
* create" and "mvp config") — one usage line per candidate, newline-separated.
|
|
64
|
+
*
|
|
65
|
+
* Only the single-match shape represents *the* usage for what's been typed —
|
|
66
|
+
* multiple candidates means the command still hasn't resolved to one thing,
|
|
67
|
+
* so (same as the explicit failure case) there's nothing unambiguous to show
|
|
68
|
+
* yet. This is the actual "is there a single usage line" signal — the server
|
|
69
|
+
* already does the resolution; we just need to recognize its shape.
|
|
70
|
+
*
|
|
71
|
+
* `cmdusage` echoes the command's help text verbatim, Minecraft `§` color
|
|
72
|
+
* codes and all (e.g. "§b§bmvp create§b §a <portal-name> [destination]"). The
|
|
73
|
+
* hint display applies its own ANSI styling on top of the plain usage string,
|
|
74
|
+
* so any embedded color codes need to come out here, at the parsing boundary
|
|
75
|
+
* — otherwise they show up as literal `§b` noise mixed in with our own escapes.
|
|
76
|
+
*/
|
|
77
|
+
function parseUsageResponse(response) {
|
|
78
|
+
if (isFailureResponse(response)) {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
const lines = response.split('\n')
|
|
82
|
+
.map(line => (0, ansi_1.stripColors)(line).trim())
|
|
83
|
+
.filter(line => line.length > 0);
|
|
84
|
+
return lines.length === 1 ? lines[0] : '';
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=completionQueries.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/completionsBackend.ts
|
|
3
|
+
//
|
|
4
|
+
// The completionEngine state machine knows it needs completions and usage
|
|
5
|
+
// text for a given input line — it doesn't know or care whether those come
|
|
6
|
+
// from a network round trip or an in-memory lookup. A CompletionsBackend is
|
|
7
|
+
// that boundary: one implementation asks the server-side TabComplete
|
|
8
|
+
// plugin over RCON, the other asks the locally-built command tree.
|
|
9
|
+
//
|
|
10
|
+
// Driving both modes through the same backend interface (and therefore the
|
|
11
|
+
// same engine, the same dispatch plumbing, and the same rendering) is what
|
|
12
|
+
// lets RconSession be entirely mode-blind — "which backend" is decided once,
|
|
13
|
+
// not branched on at every call site.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.LocalCompletionsBackend = exports.RconCompletionsBackend = void 0;
|
|
16
|
+
const completionEngine_1 = require("./completionEngine");
|
|
17
|
+
/**
|
|
18
|
+
* Server-side completions via the TabComplete plugin's `tabcomplete`/`cmdusage` commands.
|
|
19
|
+
*
|
|
20
|
+
* Takes a `controller` thunk rather than a fixed `RconController` — the
|
|
21
|
+
* connection manager replaces its controller wholesale on every reconnect, so
|
|
22
|
+
* capturing one instance up front would silently start sending through a dead
|
|
23
|
+
* (disconnected) controller after the first reconnect, with every fetch
|
|
24
|
+
* throwing "Not connected" (swallowed by the caller as "no completions").
|
|
25
|
+
* Looking it up fresh on each call always reaches whatever's live.
|
|
26
|
+
*/
|
|
27
|
+
class RconCompletionsBackend {
|
|
28
|
+
getController;
|
|
29
|
+
constructor(getController) {
|
|
30
|
+
this.getController = getController;
|
|
31
|
+
}
|
|
32
|
+
async fetchCompletions(line) {
|
|
33
|
+
const query = (0, completionEngine_1.buildCompletionsQuery)(line);
|
|
34
|
+
if (query === null) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const response = await this.getController().send(`tabcomplete ${query}`);
|
|
38
|
+
return (0, completionEngine_1.parseCompletionsResponse)(response ?? undefined);
|
|
39
|
+
}
|
|
40
|
+
async fetchUsage(line) {
|
|
41
|
+
const query = (0, completionEngine_1.buildUsageQuery)(line);
|
|
42
|
+
if (query === null) {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
const response = await this.getController().send(`cmdusage ${query}`);
|
|
46
|
+
return (0, completionEngine_1.parseUsageResponse)(response ?? undefined);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.RconCompletionsBackend = RconCompletionsBackend;
|
|
50
|
+
/**
|
|
51
|
+
* Local completions via the command tree LocalCommandTree builds at
|
|
52
|
+
* startup — a synchronous in-memory lookup, wrapped in `async` so it flows
|
|
53
|
+
* through exactly the same dispatch path as the RCON backend (the `await`
|
|
54
|
+
* still yields to the microtask queue, so a `dispatchToEngine` call made from
|
|
55
|
+
* here lands *after* the effect loop that triggered it has finished — same
|
|
56
|
+
* ordering guarantee the real async backend gets for free from the network).
|
|
57
|
+
*/
|
|
58
|
+
class LocalCompletionsBackend {
|
|
59
|
+
commandTree;
|
|
60
|
+
// getSuggestions sometimes returns incomplete argument help for inputs that
|
|
61
|
+
// are mid-command (e.g. once the user is typing a free-form argument like a
|
|
62
|
+
// player name, the locally-built tree may not have anything to say). Stick
|
|
63
|
+
// with the first full version we saw for this command rather than flicker
|
|
64
|
+
// between that and "(nothing)" — mirrors the original local-mode behavior.
|
|
65
|
+
cachedCommand = null;
|
|
66
|
+
cachedHelp = null;
|
|
67
|
+
constructor(commandTree) {
|
|
68
|
+
this.commandTree = commandTree;
|
|
69
|
+
}
|
|
70
|
+
async fetchCompletions(line) {
|
|
71
|
+
return this.commandTree.getSuggestions(line).suggestions;
|
|
72
|
+
}
|
|
73
|
+
async fetchUsage(line) {
|
|
74
|
+
const result = this.commandTree.getSuggestions(line);
|
|
75
|
+
// Everything up to the first space, including any "namespace:" prefix -
|
|
76
|
+
// \S rather than \w so "minecraft:clear" isn't truncated to "minecraft".
|
|
77
|
+
const commandName = (line.match(/^\/?(\S+)/) || [])[1] || '';
|
|
78
|
+
if (commandName !== this.cachedCommand) {
|
|
79
|
+
this.cachedCommand = commandName;
|
|
80
|
+
this.cachedHelp = null;
|
|
81
|
+
}
|
|
82
|
+
if (result.argumentHelp !== undefined) {
|
|
83
|
+
// Match the shape of the server's `cmdusage` response (e.g. "clear
|
|
84
|
+
// [<targets>] [<item>]") - formatArgumentHint derives the command
|
|
85
|
+
// prefix from this leading commandPath, so it must be present even
|
|
86
|
+
// when there's no argument help (e.g. "reload").
|
|
87
|
+
const usage = result.argumentHelp ? `${result.commandPath} ${result.argumentHelp}` : result.commandPath;
|
|
88
|
+
if (this.cachedHelp === null) {
|
|
89
|
+
this.cachedHelp = usage;
|
|
90
|
+
}
|
|
91
|
+
return this.cachedHelp;
|
|
92
|
+
}
|
|
93
|
+
return this.cachedHelp ?? '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.LocalCompletionsBackend = LocalCompletionsBackend;
|
|
97
|
+
//# sourceMappingURL=completionsBackend.js.map
|