Merge pull request +15489 from c9/ide-tune-predict-echo

Tune terminal prediction
This commit is contained in:
Harutyun Amirjanyan 2017-08-10 16:01:00 +04:00 committed by GitHub
commit bcbdb30548
2 changed files with 168 additions and 107 deletions

View File

@ -29,7 +29,7 @@ define(function(require, exports, module) {
var MIN_PREDICTION_WAIT = 500;
var PING_DEVIATION = 500;
var INSERTABLE_CHARS = /^[A-Za-z0-9!"#$%&'()*+,-\.\/:;<=>?!@[\] ^_`{|}~]+$/;
var INSERTABLE_CHARS = /^[A-Za-z0-9!"#$%&'()*+,-\.\/\\:;<=>?!@[\] ^_`{|}~]+$/;
var INPUT_BACKSPACE = "\u007F";
var ESC = "\u001B";
var OUTPUTS_BACKSPACE_ALL = ["\b" + ESC + "[K", "\b" + ESC + "[1K"];
@ -45,10 +45,9 @@ define(function(require, exports, module) {
var INPUT_RIGHT = ESC + "[C";
var OUTPUTS_RIGHT = [ESC + "[C", ESC + "[1C"];
var STATE_PREDICT = 0;
var STATE_WAIT_FOR_ECHO_OR_PROMPT = 1;
var STATE_WAIT_FOR_ECHO = 2;
var STATE_WAIT_FOR_PROMPT = 3;
var STATE_INITING = 4;
var STATE_WAIT_FOR_PROMPT_OR_ECHO = 1;
var STATE_WAIT_FOR_PROMPT = 2;
var STATE_INITING = 3;
var plugin = new Plugin("Ajax.org", main.consumes);
var emit = plugin.getEmitter();
@ -72,6 +71,9 @@ define(function(require, exports, module) {
terminal.on("beforeWrite", function(e) {
return e.session.$predictor.onBeforeWrite(e);
}, plugin);
terminal.on("afterWrite", function(e) {
return e.session.$predictor.onAfterWrite(e);
}, plugin);
terminal.on("input", function(e) {
DEBUG && console.log(">", e.data.replace("\r", "\\r").replace("\u007F", "\\bs"));
return e.session.$predictor.onInput(e);
@ -86,7 +88,7 @@ define(function(require, exports, module) {
var predictStartX = 0;
var predictStartY = 0;
var nonPredictStartY = 0;
var state = STATE_WAIT_FOR_ECHO_OR_PROMPT;
var state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
var lastInput = null;
// We maintain a copy of the terminal state without predictions
@ -117,7 +119,7 @@ define(function(require, exports, module) {
if (isPossibleConnectionGone()) {
DEBUG && console.log("!", "nopredict: connection gone?");
state = STATE_WAIT_FOR_ECHO;
state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
return;
}
@ -152,17 +154,21 @@ define(function(require, exports, module) {
command.after = { predict: predictLine, predictIndex: predictIndex };
command.sent = Date.now();
DEBUG && console.log("!"
+ nonPredictTerminal.$debugCharsAt(nonPredictTerminal.y)
.slice(0, predictStartX)
.map(function(c) { return c || " "; })
.join("")
+ "%c" + predictLine,
"color: lightblue");
if (DEBUG) {
var alreadyEchoed = predictions[0].before.predict;
console.log("!" + debugPromptSuffix()
+ predictLine.substr(0, alreadyEchoed.length)
+ "%c" + predictLine.substr(alreadyEchoed.length),
"color: lightblue"
);
}
// DEBUG && console.log("!="
// + session.terminal.$debugCharsAt(predictStartY - session.terminal.ybase).join(""));
command.timeout = setTimeout(function panic() {
if (!c9.has(c9.NETWORK) || !c9.connected) {
state = STATE_WAIT_FOR_ECHO;
state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
c9.once("connect", function() {
command.timeout = setTimeout(panic, MIN_PREDICTION_WAIT);
});
@ -184,6 +190,12 @@ define(function(require, exports, module) {
}
}
function debugPromptSuffix() {
return nonPredictTerminal.$debugCharsAt(nonPredictTerminal.y)
.slice(0, predictStartX).slice(-3)
.map(function(c) { return c || " "; }).join("");
}
function isPossibleConnectionGone() {
if (!pendingPings.length)
return;
@ -202,15 +214,19 @@ define(function(require, exports, module) {
}
DEBUG && console.log(
"< "
+ (state == STATE_PREDICT ? nonPredictTerminal.$debugCharsAt(e.$startY).join("") + " < " : "")
+ e.data
"<"
+ (state == STATE_PREDICT
? debugPromptSuffix() +
nonPredictTerminal.$debugCharsAt(e.$startY).slice(predictStartX).join("")
: "")
+ "%c < " + e.data,
"color: lightblue"
);
if (!predictions.length) {
if (state == STATE_PREDICT && nonPredictStartY !== nonPredictTerminal.ybase + nonPredictTerminal.y) {
DEBUG && console.log(" ^ disabled predictions: (row changed)");
state = STATE_WAIT_FOR_ECHO;
state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
}
tryEnablePrediction(e.data);
emit("nopredict", { data: e.data, session: session });
@ -219,13 +235,17 @@ define(function(require, exports, module) {
pong();
var result;
chopPredictions(e, predictions, function(err, _result) {
result = _result;
if (err || !result) {
DEBUG && console.log("[predict_echo] mispredict?", e.data.replace(/\r/g, "\\r"),
"\n@", nonPredictTerminal.$debugCharsAt(e.$startY).join(""));
emit("mispredict", { data: e.data, predictions: predictions, session: session });
chopPredictions(e, predictions, function(err, results, line) {
if (err || !results) {
DEBUG && console.log("[predict_echo] mispredict?", e.data.replace(/\r/g, "\\r")
+ "\n!=" + session.terminal.$debugCharsAt(predictStartY - session.terminal.ybase).join("")
+ "\n<=" + nonPredictTerminal.$debugCharsAt(e.$startY).join(""));
emit("mispredict", {
data: e.data,
line: charsOf(line),
predictions: predictions,
session: session
});
undoPredictions();
}
// I would try to enable predictions here,
@ -244,7 +264,7 @@ define(function(require, exports, module) {
/**
* Temporarily restore the unpredict terminal state to allow
* writing incoming data, including small anomalies that may
* not have been predict but still passed our sanity checks.
* not have been predicted but still passed our sanity checks.
*/
function writePredictData(data, startX) {
var predictTerminal = session.terminal;
@ -288,7 +308,7 @@ define(function(require, exports, module) {
pendingPings = [];
predictions = [];
state = STATE_WAIT_FOR_ECHO;
state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
copyTerminalLineTo(terminal);
session.terminal.x = nonPredictTerminal.x;
lastInput = null; // avoid immediately enabling again
@ -304,7 +324,12 @@ define(function(require, exports, module) {
var fromChars = target === nonPredictTerminal ? predictChars : nonPredictChars;
var toChars = target === nonPredictTerminal ? nonPredictChars : predictChars;
if (!predictChars) { // terminal likely just refreshed, never mind copying to it
state = STATE_WAIT_FOR_PROMPT;
return;
}
if (!fromChars || !toChars) {
state = STATE_WAIT_FOR_PROMPT;
errorHandler.reportError(new Error("Warning: can't copy terminal line: "), {
fromChars: fromChars, toChars: toChars
});
@ -347,14 +372,14 @@ define(function(require, exports, module) {
* @param {Object[]} predictions
* @param {Function} callback
* @param {Error} callback.err
* @param {Boolean} callback.result Whether prediction was succesful.
* @param {Object[]|Boolean} callback.results A list of matching predictions, or `false`
*/
function chopPredictions(e, predictions, callback) {
var line = nonPredictTerminal.lines[nonPredictStartY];
var rowChanged = nonPredictStartY !== nonPredictTerminal.y + nonPredictTerminal.ybase;
if (!checkTextBeforePrediction())
return callback(null, false);
return done(null, false);
// Check if predictions became true
var matchedOneOff = false;
@ -390,26 +415,36 @@ define(function(require, exports, module) {
predict = predictions.splice(0, i + 1);
}
emit("predict", {
data: e.data,
session: session,
predictions: predict
});
return callback(null, predict);
return done(null, predict);
}
}
// No matches. But one got really close.
if (matchedOneOff)
return callback(null, []);
return done(null, []);
// No matches. Return if our predictions were optional.
if (isOptionalOnly(predictions))
return callback(null, []);
return done(null, []);
// No matches. But it seems we got a noop input. Our predictions likely happen later.
if (matchPrediction(NoopCommand.tryCreate()))
return done(null, []);
// No matches for our predictions :( We likely made a mistake.
// Reporting false here ensures we catch mistakes early.
return callback(null, false);
return done(null, false);
function done(err, result) {
if (result) {
emit("predict", {
data: e.data,
session: session,
predictions: predict
});
}
callback(err, result, line);
}
function matchPrediction(prediction) {
var predict = prediction.after.predict;
@ -466,7 +501,7 @@ define(function(require, exports, module) {
return;
// Enable prediction when we see a prompt
if ((state == STATE_WAIT_FOR_PROMPT || state === STATE_WAIT_FOR_ECHO_OR_PROMPT)
if ((state == STATE_WAIT_FOR_PROMPT || state === STATE_WAIT_FOR_PROMPT_OR_ECHO)
&& data.match(/[$#] $/)) {
if (DEBUG) console.log(" ^ re-enabled predictions: (prompt)");
return startPredict();
@ -474,30 +509,43 @@ define(function(require, exports, module) {
// Enable prediction when we see echoing
if (lastInput
&& (state === STATE_WAIT_FOR_ECHO || state === STATE_WAIT_FOR_ECHO_OR_PROMPT)
&& (state === STATE_WAIT_FOR_PROMPT_OR_ECHO)
&& lastInput === data.substr(data.length - lastInput.length)
&& (!BASH_ONLY || isBashActive())) {
if (DEBUG) console.log(" ^ re-enabled predictions:", lastInput);
return startPredict();
}
// Enable predictions when we see echoing *and* a prompt
if (lastInput
&& state == STATE_WAIT_FOR_PROMPT
&& lastInput === data.substr(data.length - lastInput.length)
&& isBashActive()) {
if (DEBUG) console.log(" ^ re-enabled predictions:", lastInput);
return startPredict();
}
}
function startPredict() {
state = STATE_INITING;
predictIndex = 0;
predictLine = "";
predictStartX = nonPredictTerminal.x;
nonPredictStartY = nonPredictTerminal.y + nonPredictTerminal.ybase;
predictStartY = session.terminal.y + session.terminal.ybase;
terminal.once("afterWrite", function() {
predictStartY = session.terminal.y + session.terminal.ybase;
state = STATE_PREDICT;
if (!checkTextBeforePrediction()) {
// Appears to happen when tmux or shell unexpectedly sends a new line
console.warn("Unable to init predictions");
state = STATE_WAIT_FOR_ECHO;
}
});
state = STATE_INITING;
}
function onAfterWrite(e) {
if (state !== STATE_INITING)
return;
predictStartY = session.terminal.y + session.terminal.ybase;
state = STATE_PREDICT;
if (!checkTextBeforePrediction()) {
// Appears to happen when tmux or shell unexpectedly sends a new line
console.log("[predict_echo] Unable to init predictions; will try again later");
state = STATE_WAIT_FOR_PROMPT;
}
}
function isBashActive() {
@ -600,12 +648,10 @@ define(function(require, exports, module) {
};
function BackspaceCommand() {
var after = predictLine.substr(predictIndex);
var deletedChar;
var outputText = OUTPUTS_BACKSPACE_CHAR[0];
return {
$outputText: outputText,
do: function() {
deletedChar = peek(-1);
predictLine = predictLine.substr(0, predictIndex - 1) + after;
predictIndex--;
echo(outputText);
@ -622,11 +668,9 @@ define(function(require, exports, module) {
};
function DeleteCommand() {
var after = predictLine.substr(predictIndex + 1);
var deletedChar;
return {
$outputText: OUTPUTS_DELETE_CHAR[0],
do: function() {
deletedChar = peek();
predictLine = predictLine.substr(0, predictIndex) + after;
echo(OUTPUTS_DELETE_CHAR[0]);
}
@ -641,12 +685,10 @@ define(function(require, exports, module) {
return new CursorLeftCommand();
};
function CursorLeftCommand() {
var noChange = false;
return {
$outputText: OUTPUTS_LEFT[0],
do: function() {
if (predictIndex === 0) {
noChange = true;
clearTimeout(this.timeout);
return;
}
@ -683,12 +725,30 @@ define(function(require, exports, module) {
return new HomeCommand();
};
function HomeCommand() {
var oldIndex;
var outputText = predictIndex ? getCursorLeft(predictIndex) : "";
return {
$outputText: outputText,
do: function() {
oldIndex = predictIndex;
echo(outputText);
predictIndex = 0;
}
};
}
/**
* Noop command. Factory method: tryCreate().
*/
NoopCommand.tryCreate = function() {
var result = new NoopCommand();
result.before = { predict: predictLine, predictIndex: predictIndex };
result.after = { predict: predictLine, predictIndex: predictIndex };
return result;
};
function NoopCommand() {
var outputText = predictIndex ? getCursorLeft(predictIndex) : "";
return {
$outputText: outputText,
do: function() {
echo(outputText);
predictIndex = 0;
}
@ -708,18 +768,13 @@ define(function(require, exports, module) {
get predictions() { return predictions; },
undoPredictions: undoPredictions,
onInput: onInput,
onBeforeWrite: onBeforeWrite
onBeforeWrite: onBeforeWrite,
onAfterWrite: onAfterWrite,
};
}
function charsOf(s) {
var r1 = [];
var r2 = [];
for (var i = 0; i < s.length; i++) {
r1.push(s.charAt(i));
r2.push(s.charCodeAt(i));
}
return [r1, r2];
function charsOf(line) {
return line.map(function(c) { return c[1] }).join("");
}
function getCursorLeft(n) {

View File

@ -5,6 +5,7 @@
require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/assertions"], function (architect, chai, baseProc) {
var expect = chai.expect;
var TMUXNAME = "cloud9test2";
expect.setupArchitectTest([
{
@ -14,7 +15,6 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
debug: true,
hosted: true,
local: false,
davPrefix: "/"
},
"plugins/c9.core/ext",
@ -45,7 +45,7 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
"plugins/c9.ide.ui/forms",
{
packagePath: "plugins/c9.fs/proc",
tmuxName: "cloud9test2"
tmuxName: TMUXNAME
},
"plugins/c9.vfs.client/vfs_client",
"plugins/c9.vfs.client/endpoint",
@ -55,16 +55,6 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
baseProc: baseProc
},
// Mock plugins
{
consumes: ["apf", "ui", "Plugin"],
provides: [
"commands", "menus", "commands", "layout", "watcher",
"save", "anims", "clipboard", "dialog.alert", "auth.bootstrap",
"info", "dialog.error"
],
setup: expect.html.mocked
},
{
consumes: ["tabManager", "proc", "terminal", "terminal.predict_echo", "c9"],
provides: [],
@ -91,9 +81,9 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
var INPUT_DELETE = ESC + "[3~";
var OUTPUT_BACKSPACE = "\b" + ESC + "[K";
var OUTPUT_DELETE_CHAR = ESC + "[P";
var STATE_WAIT_FOR_ECHO_OR_PROMPT = 1;
var STATE_WAIT_FOR_ECHO = 2;
var STATE_WAIT_FOR_PROMPT = 3;
var STATE_WAIT_FOR_PROMPT_OR_ECHO = 1;
var STATE_WAIT_FOR_PROMPT = 2;
var STATE_INITING = 3;
expect.html.setConstructor(function(tab) {
if (typeof tab == "object")
@ -105,8 +95,6 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
before(function(done) {
this.timeout(45000);
apf.config.setProperty("allow-select", false);
apf.config.setProperty("allow-blur", false);
bar.$ext.style.background = "rgba(220, 220, 220, 0.93)";
bar.$ext.style.position = "fixed";
@ -120,7 +108,7 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
predictor.$setTestTimeouts();
predictor.DEBUG = true;
proc.execFile("~/.c9/bin/tmux", { args: ["-L", "cloud9test", "kill-server"]}, function(err) {
proc.execFile("~/.c9/bin/tmux", { args: ["-L", TMUXNAME, "kill-server"]}, function(err) {
tabs.once("ready", function() {
tabs.getPanes()[0].focus();
openTerminal(done);
@ -128,8 +116,10 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
});
});
function openTerminal(done) {
function openTerminal(callback) {
tabs.openEditor("terminal", function(err, tab) {
if (err) return callback(err);
editor = tab.editor;
session = editor.ace.getSession().c9session;
send = session.send;
@ -137,25 +127,23 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
setTimeout(init);
function init() {
afterPrompt(function() { setTimeout(start); });
afterPrompt(function() { setTimeout(callback); });
// Make sure we have a prompt with a dollar for tests
// And terminal won't send rename commands in the middle of the test
// TODO: do we need to handle rename sequence in predict_echo instead?
editor.ace.onTextInput("PS1='. $ ';"
editor.ace.onTextInput("PS1='P$ ';"
+ "tmux setw automatic-rename off;"
+ "printf '\\x1b]0;predict echo\\x07'\n");
// editor.ace.onTextInput("ssh lennart\n");
// editor.ace.onTextInput("ssh ubuntu@ci.c9.io\n");
}
});
function start() {
predictor.on("mispredict", function(e) {
console.error("MISPREDICTED", e);
delete e.session;
throw new Error("MISPREDICTED: " + JSON.stringify(e));
});
setTimeout(done);
}
}
function reportMispredict(e) {
console.error("MISPREDICTED", e);
delete e.session;
throw new Error("MISPREDICTED: " + JSON.stringify(e));
}
function peek(offset) {
@ -206,19 +194,23 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
setTimeout(function() {
send(key);
sendAll(keys, callback);
});
}, 5);
}
describe.skip("predict_echo", function() {
beforeEach(function(done) {
predictor.off("mispredict", reportMispredict);
afterPredict("*", function() {
afterPrompt(function() {
session.$predictor.state = 0;
predictor.on("mispredict", reportMispredict);
done();
});
send("\r");
});
session.$predictor.state = 0;
console.log("! next test");
sendAll(" # next*".split(""));
});
@ -373,8 +365,8 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
afterPredict("[", function() {
afterPredict("[", function() {
assert.equal(peek(-1), " ");
assert.equal(peek(), "e");
assert.equal(peek(1), "c");
assert.equal(peek(), "#");
assert.equal(peek(1), "p");
afterPrompt(done);
send("\r");
@ -384,16 +376,16 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
sendAll([INPUT_RIGHT]);
});
sendAll(["eecho bleep", INPUT_HOME]);
sendAll(["##print some chars; home; right; backspace", INPUT_HOME]);
});
it("supports insert with repeated characters; stress test", function loop(done, attempt) {
it("supports insert with repeated characters (prxaat); stress test", function loop(done, attempt) {
this.timeout && this.timeout(60000);
session.$predictor.state = 0;
if (attempt === 5)
return done();
sendAll("echo blaat".split(""), function() {
sendAll("echo praat".split(""), function() {
var sawX;
afterPredict("t", function() {
@ -402,8 +394,9 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
});
predictor.on("predict", function wait(e) {
sawX = sawX || e.data.match(/x/);
if (!sawX || e.data.match(/xaat/) || !e.data.match(/a/))
return; // console.log(" -", e.data, sawX)*
// Wait until we've seen an 'x' and then an 'a'
if (!sawX || e.data.match(/xaat$/))
return console.log(" -", e.data, !!sawX);
predictor.off("predict", wait);
assert.equal(peek(), "a");
@ -494,11 +487,11 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
});
// sometimes backspace will re-enable state 0; we reset it here
session.$predictor.state = STATE_WAIT_FOR_ECHO_OR_PROMPT;
session.$predictor.state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
send(":");
});
session.$predictor.state = STATE_WAIT_FOR_ECHO_OR_PROMPT;
session.$predictor.state = STATE_WAIT_FOR_PROMPT_OR_ECHO;
send(INPUT_BACKSPACE);
});
@ -542,6 +535,19 @@ require(["lib/architect/architect", "lib/chai/chai", "/vfs-root", "ace/test/asse
});
});
it("recovers after spurious backspaces on a prompt", function(done) {
var afterBackspace = false;
predictor.once("nopredict", function() {
assert.equal(afterBackspace, true);
// assert.equal(session.$predictor.state, STATE_INITING);
afterPrompt(done);
send("\r");
});
send(INPUT_BACKSPACE);
afterBackspace = true;
send("Q");
});
});
if (!onload.remain) {