You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@karaf.apache.org by gn...@apache.org on 2014/05/15 19:22:02 UTC

[1/2] git commit: [KARAF-2983] Support window size change signals in Terminal

Repository: karaf
Updated Branches:
  refs/heads/master e5e50310a -> 57ec802bc


[KARAF-2983] Support window size change signals in Terminal

Project: http://git-wip-us.apache.org/repos/asf/karaf/repo
Commit: http://git-wip-us.apache.org/repos/asf/karaf/commit/a5450aa2
Tree: http://git-wip-us.apache.org/repos/asf/karaf/tree/a5450aa2
Diff: http://git-wip-us.apache.org/repos/asf/karaf/diff/a5450aa2

Branch: refs/heads/master
Commit: a5450aa241cb6e15d430ad4792d924f2636bd3f1
Parents: e5e5031
Author: Guillaume Nodet <gn...@gmail.com>
Authored: Thu May 15 19:09:32 2014 +0200
Committer: Guillaume Nodet <gn...@gmail.com>
Committed: Thu May 15 19:09:32 2014 +0200

----------------------------------------------------------------------
 .../apache/karaf/shell/api/console/Signal.java  | 81 +++++++++++++++++
 .../karaf/shell/api/console/SignalListener.java | 31 +++++++
 .../karaf/shell/api/console/Terminal.java       | 27 ++++++
 .../shell/impl/console/ConsoleSessionImpl.java  |  8 ++
 .../karaf/shell/impl/console/JLineTerminal.java | 57 +++++++++++-
 .../impl/console/osgi/LocalConsoleManager.java  |  5 +-
 .../shell/support/terminal/SignalSupport.java   | 96 ++++++++++++++++++++
 .../org/apache/karaf/shell/ssh/SshTerminal.java | 12 ++-
 .../karaf/webconsole/gogo/WebTerminal.java      |  3 +-
 9 files changed, 311 insertions(+), 9 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/shell/core/src/main/java/org/apache/karaf/shell/api/console/Signal.java
----------------------------------------------------------------------
diff --git a/shell/core/src/main/java/org/apache/karaf/shell/api/console/Signal.java b/shell/core/src/main/java/org/apache/karaf/shell/api/console/Signal.java
new file mode 100644
index 0000000..1e4a563
--- /dev/null
+++ b/shell/core/src/main/java/org/apache/karaf/shell/api/console/Signal.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.karaf.shell.api.console;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum Signal {
+
+    HUP(1),
+    INT(2),
+    QUIT(3),
+    ILL(4),
+    TRAP(5),
+    IOT(6),
+    BUS(7),
+    FPE(8),
+    KILL(9),
+    USR1(10),
+    SEGV(11),
+    USR2(12),
+    PIPE(13),
+    ALRM(14),
+    TERM(15),
+    STKFLT(16),
+    CHLD(17),
+    CONT(18),
+    STOP(19),
+    TSTP(20),
+    TTIN(21),
+    TTOU(22),
+    URG(23),
+    XCPU(24),
+    XFSZ(25),
+    VTALRM(26),
+    PROF(27),
+    WINCH(28),
+    IO(29),
+    PWR(30);
+
+    private static final Map<String, Signal> lookupTable = new HashMap<String, Signal>(40);
+
+    static {
+        // registering the signals in the lookup table to allow faster
+        // string based signal lookups
+        for (Signal s : Signal.values()) {
+            lookupTable.put(s.name(), s);
+        }
+    }
+
+    public static Signal get(String name) {
+        return lookupTable.get(name);
+    }
+
+    private final int numeric;
+
+    private Signal(int numeric) {
+        this.numeric = numeric;
+    }
+
+    public int getNumeric() {
+        return numeric;
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/shell/core/src/main/java/org/apache/karaf/shell/api/console/SignalListener.java
----------------------------------------------------------------------
diff --git a/shell/core/src/main/java/org/apache/karaf/shell/api/console/SignalListener.java b/shell/core/src/main/java/org/apache/karaf/shell/api/console/SignalListener.java
new file mode 100644
index 0000000..3f6927a
--- /dev/null
+++ b/shell/core/src/main/java/org/apache/karaf/shell/api/console/SignalListener.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.karaf.shell.api.console;
+
+/**
+ * Define a listener to receive signals
+ */
+public interface SignalListener {
+
+    /**
+     *
+     * @param signal
+     */
+    void signal(Signal signal);
+}

http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/shell/core/src/main/java/org/apache/karaf/shell/api/console/Terminal.java
----------------------------------------------------------------------
diff --git a/shell/core/src/main/java/org/apache/karaf/shell/api/console/Terminal.java b/shell/core/src/main/java/org/apache/karaf/shell/api/console/Terminal.java
index 3549ec7..c0ee911 100644
--- a/shell/core/src/main/java/org/apache/karaf/shell/api/console/Terminal.java
+++ b/shell/core/src/main/java/org/apache/karaf/shell/api/console/Terminal.java
@@ -18,6 +18,8 @@
  */
 package org.apache.karaf.shell.api.console;
 
+import java.util.EnumSet;
+
 /**
  * Session terminal.
  */
@@ -48,4 +50,29 @@ public interface Terminal {
      */
     void setEchoEnabled(boolean enabled);
 
+    /**
+     * Add a qualified listener for the specific signal
+     * @param listener the listener to register
+     * @param signal the signal the listener is interested in
+     */
+    void addSignalListener(SignalListener listener, Signal... signal);
+
+    /**
+     * Add a qualified listener for the specific set of signal
+     * @param listener the listener to register
+     * @param signals the signals the listener is interested in
+     */
+    void addSignalListener(SignalListener listener, EnumSet<Signal> signals);
+
+    /**
+     * Add a global listener for all signals
+     * @param listener the listener to register
+     */
+    void addSignalListener(SignalListener listener);
+
+    /**
+     * Remove a previously registered listener for all the signals it was registered
+     * @param listener the listener to remove
+     */
+    void removeSignalListener(SignalListener listener);
 }

http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/shell/core/src/main/java/org/apache/karaf/shell/impl/console/ConsoleSessionImpl.java
----------------------------------------------------------------------
diff --git a/shell/core/src/main/java/org/apache/karaf/shell/impl/console/ConsoleSessionImpl.java b/shell/core/src/main/java/org/apache/karaf/shell/impl/console/ConsoleSessionImpl.java
index 1673f37..dbfc59a 100644
--- a/shell/core/src/main/java/org/apache/karaf/shell/impl/console/ConsoleSessionImpl.java
+++ b/shell/core/src/main/java/org/apache/karaf/shell/impl/console/ConsoleSessionImpl.java
@@ -19,6 +19,7 @@
 package org.apache.karaf.shell.impl.console;
 
 import java.io.CharArrayWriter;
+import java.io.Closeable;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
@@ -233,6 +234,13 @@ public class ConsoleSessionImpl implements Session {
         if (closeCallback != null) {
             closeCallback.run();
         }
+        if (terminal instanceof Closeable) {
+            try {
+                ((Closeable) terminal).close();
+            } catch (IOException e) {
+                // Ignore
+            }
+        }
     }
 
     public void run() {

http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/shell/core/src/main/java/org/apache/karaf/shell/impl/console/JLineTerminal.java
----------------------------------------------------------------------
diff --git a/shell/core/src/main/java/org/apache/karaf/shell/impl/console/JLineTerminal.java b/shell/core/src/main/java/org/apache/karaf/shell/impl/console/JLineTerminal.java
index d000885..aaf4eb6 100644
--- a/shell/core/src/main/java/org/apache/karaf/shell/impl/console/JLineTerminal.java
+++ b/shell/core/src/main/java/org/apache/karaf/shell/impl/console/JLineTerminal.java
@@ -18,17 +18,26 @@
  */
 package org.apache.karaf.shell.impl.console;
 
+import java.io.Closeable;
+import java.io.IOException;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+
+import org.apache.karaf.shell.api.console.Signal;
 import org.apache.karaf.shell.api.console.Terminal;
+import org.apache.karaf.shell.support.terminal.SignalSupport;
 
 /**
-* Created by gnodet on 27/02/14.
-*/
-public class JLineTerminal implements Terminal {
+ * Created by gnodet on 27/02/14.
+ */
+public class JLineTerminal extends SignalSupport implements Terminal, Closeable {
 
     private final jline.Terminal terminal;
 
     public JLineTerminal(jline.Terminal terminal) {
         this.terminal = terminal;
+        registerSignalHandler();
     }
 
     public jline.Terminal getTerminal() {
@@ -59,4 +68,46 @@ public class JLineTerminal implements Terminal {
     public void setEchoEnabled(boolean enabled) {
         terminal.setEchoEnabled(enabled);
     }
+
+    @Override
+    public void close() throws IOException {
+        unregisterSignalHandler();
+    }
+
+    private void registerSignalHandler() {
+        try {
+            Class<?> signalClass = Class.forName("sun.misc.Signal");
+            Class<?> signalHandlerClass = Class.forName("sun.misc.SignalHandler");
+            // Implement signal handler
+            Object signalHandler = Proxy.newProxyInstance(getClass().getClassLoader(),
+                    new Class<?>[]{signalHandlerClass}, new InvocationHandler() {
+                        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
+                            JLineTerminal.this.signal(Signal.WINCH);
+                            return null;
+                        }
+                    }
+            );
+            // Register the signal handler, this code is equivalent to:
+            // Signal.handle(new Signal("CONT"), signalHandler);
+            signalClass.getMethod("handle", signalClass, signalHandlerClass).invoke(null, signalClass.getConstructor(String.class).newInstance("WINCH"), signalHandler);
+        } catch (Exception e) {
+            // Ignore this exception, if the above failed, the signal API is incompatible with what we're expecting
+
+        }
+    }
+
+    private void unregisterSignalHandler() {
+        try {
+            Class<?> signalClass = Class.forName("sun.misc.Signal");
+            Class<?> signalHandlerClass = Class.forName("sun.misc.SignalHandler");
+
+            Object signalHandler = signalHandlerClass.getField("SIG_DFL").get(null);
+            // Register the signal handler, this code is equivalent to:
+            // Signal.handle(new Signal("CONT"), signalHandler);
+            signalClass.getMethod("handle", signalClass, signalHandlerClass).invoke(null, signalClass.getConstructor(String.class).newInstance("WINCH"), signalHandler);
+        } catch (Exception e) {
+            // Ignore this exception, if the above failed, the signal API is incompatible with what we're expecting
+
+        }
+    }
 }

http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java
----------------------------------------------------------------------
diff --git a/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java b/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java
index 1c30dd0..e93d93e 100644
--- a/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java
+++ b/shell/core/src/main/java/org/apache/karaf/shell/impl/console/osgi/LocalConsoleManager.java
@@ -67,7 +67,7 @@ public class LocalConsoleManager {
 
         
         final Subject subject = createLocalKarafSubject();    
-        this.session = JaasHelper.<Session>doAs(subject, new PrivilegedAction<Session>() {
+        this.session = JaasHelper.doAs(subject, new PrivilegedAction<Session>() {
             public Session run() {
                 String encoding = getEncoding();
                 session = sessionFactory.create(
@@ -95,8 +95,7 @@ public class LocalConsoleManager {
     }
 
     private String getEncoding() {
-        String ctype = System.getenv("LC_CTYPE");
-        String encoding = ctype;
+        String encoding = System.getenv("LC_CTYPE");
         if (encoding != null && encoding.indexOf('.') > 0) {
             encoding = encoding.substring(encoding.indexOf('.') + 1);
         } else {

http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/shell/core/src/main/java/org/apache/karaf/shell/support/terminal/SignalSupport.java
----------------------------------------------------------------------
diff --git a/shell/core/src/main/java/org/apache/karaf/shell/support/terminal/SignalSupport.java b/shell/core/src/main/java/org/apache/karaf/shell/support/terminal/SignalSupport.java
new file mode 100644
index 0000000..39ff0f7
--- /dev/null
+++ b/shell/core/src/main/java/org/apache/karaf/shell/support/terminal/SignalSupport.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.karaf.shell.support.terminal;
+
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+import org.apache.karaf.shell.api.console.Signal;
+import org.apache.karaf.shell.api.console.SignalListener;
+
+public class SignalSupport {
+
+    private final Map<Signal, Set<SignalListener>> listeners;
+
+    public SignalSupport() {
+        listeners = new ConcurrentHashMap<>(3);
+    }
+
+    public void addSignalListener(SignalListener listener, Signal... signals) {
+        if (signals == null) {
+            throw new IllegalArgumentException("signals may not be null");
+        }
+        if (listener == null) {
+            throw new IllegalArgumentException("listener may not be null");
+        }
+        for (Signal s : signals) {
+            getSignalListeners(s, true).add(listener);
+        }
+    }
+
+    public void addSignalListener(SignalListener listener) {
+        addSignalListener(listener, EnumSet.allOf(Signal.class));
+    }
+
+    public void addSignalListener(SignalListener listener, EnumSet<Signal> signals) {
+        if (signals == null) {
+            throw new IllegalArgumentException("signals may not be null");
+        }
+        addSignalListener(listener, signals.toArray(new Signal[signals.size()]));
+    }
+
+    public void removeSignalListener(SignalListener listener) {
+        if (listener == null) {
+            throw new IllegalArgumentException("listener may not be null");
+        }
+        for (Signal s : EnumSet.allOf(Signal.class)) {
+            final Set<SignalListener> ls = getSignalListeners(s, false);
+            if (ls != null) {
+                ls.remove(listener);
+            }
+        }
+    }
+
+    public void signal(Signal signal) {
+        final Set<SignalListener> ls = getSignalListeners(signal, false);
+        if (ls != null) {
+            for (SignalListener l : ls) {
+                l.signal(signal);
+            }
+        }
+    }
+
+    protected Set<SignalListener> getSignalListeners(Signal signal, boolean create) {
+        Set<SignalListener> ls = listeners.get(signal);
+        if (ls == null && create) {
+            synchronized (listeners) {
+                ls = listeners.get(signal);
+                if (ls == null) {
+                    ls = new CopyOnWriteArraySet<>();
+                    listeners.put(signal, ls);
+                }
+            }
+        }
+        // may be null in case create=false
+        return ls;
+    }
+}

http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/SshTerminal.java
----------------------------------------------------------------------
diff --git a/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/SshTerminal.java b/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/SshTerminal.java
index fa24169..17291d0 100644
--- a/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/SshTerminal.java
+++ b/shell/ssh/src/main/java/org/apache/karaf/shell/ssh/SshTerminal.java
@@ -18,16 +18,23 @@
  */
 package org.apache.karaf.shell.ssh;
 
+import org.apache.karaf.shell.api.console.Signal;
 import org.apache.karaf.shell.api.console.Terminal;
+import org.apache.karaf.shell.support.terminal.SignalSupport;
 import org.apache.sshd.server.Environment;
 
-public class SshTerminal implements Terminal {
+public class SshTerminal extends SignalSupport implements Terminal {
 
     private Environment environment;
 
-
     public SshTerminal(Environment environment) {
         this.environment = environment;
+        this.environment.addSignalListener(new org.apache.sshd.server.SignalListener() {
+            @Override
+            public void signal(org.apache.sshd.server.Signal signal) {
+                SshTerminal.this.signal(Signal.WINCH);
+            }
+        }, org.apache.sshd.server.Signal.WINCH);
     }
 
     @Override
@@ -66,4 +73,5 @@ public class SshTerminal implements Terminal {
     public void setEchoEnabled(boolean enabled) {
         // TODO: how to disable echo over ssh ?
     }
+
 }

http://git-wip-us.apache.org/repos/asf/karaf/blob/a5450aa2/webconsole/gogo/src/main/java/org/apache/karaf/webconsole/gogo/WebTerminal.java
----------------------------------------------------------------------
diff --git a/webconsole/gogo/src/main/java/org/apache/karaf/webconsole/gogo/WebTerminal.java b/webconsole/gogo/src/main/java/org/apache/karaf/webconsole/gogo/WebTerminal.java
index 865a528..f0dd828 100644
--- a/webconsole/gogo/src/main/java/org/apache/karaf/webconsole/gogo/WebTerminal.java
+++ b/webconsole/gogo/src/main/java/org/apache/karaf/webconsole/gogo/WebTerminal.java
@@ -17,8 +17,9 @@
 package org.apache.karaf.webconsole.gogo;
 
  import org.apache.karaf.shell.api.console.Terminal;
+ import org.apache.karaf.shell.support.terminal.SignalSupport;
 
-public class WebTerminal implements Terminal {
+public class WebTerminal extends SignalSupport implements Terminal {
 
     private int width;
     private int height;


[2/2] git commit: [KARAF-2069] Provide a less pager command

Posted by gn...@apache.org.
[KARAF-2069] Provide a less pager command

Project: http://git-wip-us.apache.org/repos/asf/karaf/repo
Commit: http://git-wip-us.apache.org/repos/asf/karaf/commit/57ec802b
Tree: http://git-wip-us.apache.org/repos/asf/karaf/tree/57ec802b
Diff: http://git-wip-us.apache.org/repos/asf/karaf/diff/57ec802b

Branch: refs/heads/master
Commit: 57ec802bcd36caec74d87165e2d700e118ee68d4
Parents: a5450aa
Author: Guillaume Nodet <gn...@gmail.com>
Authored: Thu May 15 19:10:30 2014 +0200
Committer: Guillaume Nodet <gn...@gmail.com>
Committed: Thu May 15 19:10:30 2014 +0200

----------------------------------------------------------------------
 .../karaf/shell/commands/impl/LessAction.java   | 918 +++++++++++++++++++
 1 file changed, 918 insertions(+)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/karaf/blob/57ec802b/shell/commands/src/main/java/org/apache/karaf/shell/commands/impl/LessAction.java
----------------------------------------------------------------------
diff --git a/shell/commands/src/main/java/org/apache/karaf/shell/commands/impl/LessAction.java b/shell/commands/src/main/java/org/apache/karaf/shell/commands/impl/LessAction.java
new file mode 100644
index 0000000..4185c06
--- /dev/null
+++ b/shell/commands/src/main/java/org/apache/karaf/shell/commands/impl/LessAction.java
@@ -0,0 +1,918 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.karaf.shell.commands.impl;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.io.Reader;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Stack;
+import java.util.TreeMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.regex.Pattern;
+
+import jline.console.KeyMap;
+import org.apache.karaf.shell.api.action.Action;
+import org.apache.karaf.shell.api.action.Argument;
+import org.apache.karaf.shell.api.action.Command;
+import org.apache.karaf.shell.api.action.Option;
+import org.apache.karaf.shell.api.action.lifecycle.Reference;
+import org.apache.karaf.shell.api.action.lifecycle.Service;
+import org.apache.karaf.shell.api.console.Session;
+import org.apache.karaf.shell.api.console.Signal;
+import org.apache.karaf.shell.api.console.SignalListener;
+import org.apache.karaf.shell.api.console.Terminal;
+import org.jledit.jline.NonBlockingInputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Command(scope = "shell", name = "less", description = "File pager.")
+@Service
+public class LessAction implements Action, SignalListener {
+
+    private static final int ESCAPE = 27;
+    public static final int ESCAPE_TIMEOUT = 100;
+    public static final int READ_EXPIRED = -2;
+
+    private final Logger log = LoggerFactory.getLogger(getClass());
+
+    @Option(name = "-e", aliases = "--quit-at-eof")
+    boolean quitAtSecondEof;
+
+    @Option(name = "-E", aliases = "--QUIT-AT-EOF")
+    boolean quitAtFirstEof;
+
+    @Option(name = "-N", aliases = "--LINE-NUMBERS")
+    boolean printLineNumbers;
+
+    @Option(name = "-q", aliases = {"--quiet", "--silent"})
+    boolean quiet;
+
+    @Option(name = "-Q", aliases = {"--QUIET", "--SILENT"})
+    boolean veryQuiet;
+
+    @Option(name = "-S", aliases = "--chop-long-lines")
+    boolean chopLongLines;
+
+    @Option(name = "-i", aliases = "--ignore-case")
+    boolean ignoreCaseCond;
+
+    @Option(name = "-I", aliases = "--IGNORE-CASE")
+    boolean ignoreCaseAlways;
+
+    @Argument(multiValued = true)
+    List<File> files;
+
+    @Reference
+    Terminal terminal;
+
+    @Reference
+    Session session;
+
+    BufferedReader reader;
+
+    NonBlockingInputStream consoleInput;
+    Reader consoleReader;
+
+    KeyMap keys;
+
+    int firstLineInMemory = 0;
+    List<String> lines = new ArrayList<>();
+
+    int firstLineToDisplay = 0;
+    int firstColumnToDisplay = 0;
+    int offsetInLine = 0;
+
+    String message;
+    final StringBuilder buffer = new StringBuilder();
+    final StringBuilder opBuffer = new StringBuilder();
+    final Stack<Character> pushBackChar = new Stack<>();
+    Thread displayThread;
+    final AtomicBoolean redraw = new AtomicBoolean();
+
+    final Map<String, Operation> options = new TreeMap<>();
+
+    int window;
+    int halfWindow;
+
+    int nbEof;
+
+    String pattern;
+
+    @Override
+    public Object execute() throws Exception {
+        InputStream in;
+        if (files != null && !files.isEmpty()) {
+            message = files.get(0).toString();
+            in = new FileInputStream(files.get(0));
+        } else {
+            in = System.in;
+        }
+        reader = new BufferedReader(new InputStreamReader(new InterruptibleInputStream(in)));
+        try {
+            if (terminal == null || !isTty(System.out)) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    System.out.println(line);
+                    checkInterrupted();
+                }
+                return null;
+            } else {
+                boolean echo = terminal.isEchoEnabled();
+                terminal.setEchoEnabled(false);
+                terminal.addSignalListener(this, Signal.WINCH);
+                try {
+                    window = terminal.getHeight() - 1;
+                    halfWindow = window / 2;
+                    keys = new KeyMap("less", false);
+                    bindKeys(keys);
+                    consoleInput = new NonBlockingInputStream(session.getKeyboard(), true);
+                    consoleReader = new InputStreamReader(consoleInput);
+
+                    // Use alternate buffer
+                    System.out.print("\u001B[?1049h");
+                    System.out.flush();
+
+                    displayThread = new Thread() {
+                        @Override
+                        public void run() {
+                            redrawLoop();
+                        }
+                    };
+                    displayThread.start();
+                    redraw();
+                    checkInterrupted();
+
+                    options.put("-e", Operation.OPT_QUIT_AT_SECOND_EOF);
+                    options.put("--quit-at-eof", Operation.OPT_QUIT_AT_SECOND_EOF);
+                    options.put("-E", Operation.OPT_QUIT_AT_FIRST_EOF);
+                    options.put("-QUIT-AT-EOF", Operation.OPT_QUIT_AT_FIRST_EOF);
+                    options.put("-N", Operation.OPT_PRINT_LINES);
+                    options.put("--LINE-NUMBERS", Operation.OPT_PRINT_LINES);
+                    options.put("-q", Operation.OPT_QUIET);
+                    options.put("--quiet", Operation.OPT_QUIET);
+                    options.put("--silent", Operation.OPT_QUIET);
+                    options.put("-Q", Operation.OPT_VERY_QUIET);
+                    options.put("--QUIET", Operation.OPT_VERY_QUIET);
+                    options.put("--SILENT", Operation.OPT_VERY_QUIET);
+                    options.put("-S", Operation.OPT_CHOP_LONG_LINES);
+                    options.put("--chop-long-lines", Operation.OPT_CHOP_LONG_LINES);
+                    options.put("-i", Operation.OPT_IGNORE_CASE_COND);
+                    options.put("--ignore-case", Operation.OPT_IGNORE_CASE_COND);
+                    options.put("-I", Operation.OPT_IGNORE_CASE_ALWAYS);
+                    options.put("--IGNORE-CASE", Operation.OPT_IGNORE_CASE_ALWAYS);
+
+                    Operation op;
+                    do {
+                        checkInterrupted();
+
+                        op = null;
+                        //
+                        // Option edition
+                        //
+                        if (buffer.length() > 0 && buffer.charAt(0) == '-') {
+                            int c = consoleReader.read();
+                            message = null;
+                            if (buffer.length() == 1) {
+                                buffer.append((char) c);
+                                if (c != '-') {
+                                    op = options.get(buffer.toString());
+                                    if (op == null) {
+                                        message = "There is no " + printable(buffer.toString()) + " option";
+                                        buffer.setLength(0);
+                                    }
+                                }
+                            } else if (c == '\r') {
+                                op = options.get(buffer.toString());
+                                if (op == null) {
+                                    message = "There is no " + printable(buffer.toString()) + " option";
+                                    buffer.setLength(0);
+                                }
+                            } else {
+                                buffer.append((char) c);
+                                Map<String, Operation> matching = new HashMap<>();
+                                for (Map.Entry<String, Operation> entry : options.entrySet()) {
+                                    if (entry.getKey().startsWith(buffer.toString())) {
+                                        matching.put(entry.getKey(), entry.getValue());
+                                    }
+                                }
+                                switch (matching.size()) {
+                                case 0:
+                                    buffer.setLength(0);
+                                    break;
+                                case 1:
+                                    buffer.setLength(0);
+                                    buffer.append(matching.keySet().iterator().next());
+                                    break;
+                                }
+                            }
+
+                        }
+                        //
+                        // Pattern edition
+                        //
+                        else if (buffer.length() > 0 && (buffer.charAt(0) == '/' || buffer.charAt(0) == '?')) {
+                            int c = consoleReader.read();
+                            message = null;
+                            if (c == '\r') {
+                                pattern = buffer.toString().substring(1);
+                                if (buffer.charAt(0) == '/') {
+                                    moveToNextMatch();
+                                } else {
+                                    moveToPreviousMatch();
+                                }
+                                buffer.setLength(0);
+                            } else {
+                                buffer.append((char) c);
+                            }
+                        }
+                        //
+                        // Command reading
+                        //
+                        else {
+                            Object obj = readOperation();
+                            message = null;
+                            if (obj instanceof Character) {
+                                char c = (char) obj;
+                                // Enter option mode or pattern edit mode
+                                if (c == '-' || c == '/' || c == '?') {
+                                    buffer.setLength(0);
+                                }
+                                buffer.append((char) obj);
+                            } else if (obj instanceof Operation) {
+                                op = (Operation) obj;
+                            }
+                        }
+                        if (op != null) {
+                            switch (op) {
+                            case FORWARD_ONE_LINE:
+                                moveForward(getStrictPositiveNumberInBuffer(1));
+                                break;
+                            case BACKWARD_ONE_LINE:
+                                moveBackward(getStrictPositiveNumberInBuffer(1));
+                                break;
+                            case FORWARD_ONE_WINDOW_OR_LINES:
+                                moveForward(getStrictPositiveNumberInBuffer(window));
+                                break;
+                            case FORWARD_ONE_WINDOW_AND_SET:
+                                window = getStrictPositiveNumberInBuffer(window);
+                                moveForward(window);
+                                break;
+                            case FORWARD_ONE_WINDOW_NO_STOP:
+                                moveForward(window);
+                                // TODO: handle no stop
+                                break;
+                            case FORWARD_HALF_WINDOW_AND_SET:
+                                halfWindow = getStrictPositiveNumberInBuffer(halfWindow);
+                                moveForward(halfWindow);
+                                break;
+                            case BACKWARD_ONE_WINDOW_AND_SET:
+                                window = getStrictPositiveNumberInBuffer(window);
+                                moveBackward(window);
+                                break;
+                            case BACKWARD_ONE_WINDOW_OR_LINES:
+                                moveBackward(getStrictPositiveNumberInBuffer(window));
+                                break;
+                            case BACKWARD_HALF_WINDOW_AND_SET:
+                                halfWindow = getStrictPositiveNumberInBuffer(halfWindow);
+                                moveBackward(halfWindow);
+                                break;
+                            case GO_TO_FIRST_LINE_OR_N:
+                                // TODO: handle number
+                                firstLineToDisplay = firstLineInMemory;
+                                offsetInLine = 0;
+                                break;
+                            case GO_TO_LAST_LINE_OR_N:
+                                // TODO: handle number
+                                moveForward(Integer.MAX_VALUE);
+                                break;
+                            case LEFT_ONE_HALF_SCREEN:
+                                firstColumnToDisplay = Math.max(0, firstColumnToDisplay - terminal.getWidth() / 2);
+                                break;
+                            case RIGHT_ONE_HALF_SCREEN:
+                                firstColumnToDisplay += terminal.getWidth() / 2;
+                                break;
+                            case REPEAT_SEARCH_BACKWARD:
+                            case REPEAT_SEARCH_BACKWARD_SPAN_FILES:
+                                moveToPreviousMatch();
+                                break;
+                            case REPEAT_SEARCH_FORWARD:
+                            case REPEAT_SEARCH_FORWARD_SPAN_FILES:
+                                moveToNextMatch();
+                                break;
+                            case UNDO_SEARCH:
+                                pattern = null;
+                                break;
+                            case OPT_PRINT_LINES:
+                                buffer.setLength(0);
+                                printLineNumbers = !printLineNumbers;
+                                message = printLineNumbers ? "Constantly display line numbers" : "Don't use line numbers";
+                                break;
+                            case OPT_QUIET:
+                                buffer.setLength(0);
+                                quiet = !quiet;
+                                veryQuiet = false;
+                                message = quiet ? "Ring the bell for errors but not at eof/bof" : "Ring the bell for errors AND at eof/bof";
+                                break;
+                            case OPT_VERY_QUIET:
+                                buffer.setLength(0);
+                                veryQuiet = !veryQuiet;
+                                quiet = false;
+                                message = veryQuiet ? "Never ring the bell" : "Ring the bell for errors AND at eof/bof";
+                                break;
+                            case OPT_CHOP_LONG_LINES:
+                                buffer.setLength(0);
+                                offsetInLine = 0;
+                                chopLongLines = !chopLongLines;
+                                message = chopLongLines ? "Chop long lines" : "Fold long lines";
+                                break;
+                            case OPT_IGNORE_CASE_COND:
+                                ignoreCaseCond = !ignoreCaseCond;
+                                ignoreCaseAlways = false;
+                                message = ignoreCaseCond ? "Ignore case in searches" : "Case is significant in searches";
+                                break;
+                            case OPT_IGNORE_CASE_ALWAYS:
+                                ignoreCaseAlways = !ignoreCaseAlways;
+                                ignoreCaseCond = false;
+                                message = ignoreCaseAlways ? "Ignore case in searches and in patterns" : "Case is significant in searches";
+                                break;
+                            }
+                            buffer.setLength(0);
+                        }
+                        redraw();
+                        if (quitAtFirstEof && nbEof > 0 || quitAtSecondEof && nbEof > 1) {
+                            op = Operation.EXIT;
+                        }
+                    } while (op != Operation.EXIT);
+                } catch (InterruptedException ie) {
+                    log.debug("Interrupted by user");
+                } finally {
+                    terminal.setEchoEnabled(echo);
+                    terminal.removeSignalListener(this);
+                    consoleInput.shutdown();
+                    displayThread.interrupt();
+                    displayThread.join();
+                    // Use main buffer
+                    System.out.print("\u001B[?1049l");
+                    // Clear line
+                    System.out.println();
+                    System.out.flush();
+                }
+            }
+        } finally {
+            reader.close();
+        }
+        return null;
+    }
+
+    private void moveToNextMatch() throws IOException {
+        Pattern compiled = getPattern();
+        if (compiled != null) {
+            for (int lineNumber = firstLineToDisplay + 1; ; lineNumber++) {
+                String line = getLine(lineNumber);
+                if (line == null) {
+                    break;
+                } else if (compiled.matcher(line).find()) {
+                    firstLineToDisplay = lineNumber;
+                    offsetInLine = 0;
+                    return;
+                }
+            }
+        }
+        message = "Pattern not found";
+    }
+
+    private void moveToPreviousMatch() throws IOException {
+        Pattern compiled = getPattern();
+        if (compiled != null) {
+            for (int lineNumber = firstLineToDisplay - 1; lineNumber >= firstLineInMemory; lineNumber--) {
+                String line = getLine(lineNumber);
+                if (line == null) {
+                    break;
+                } else if (compiled.matcher(line).find()) {
+                    firstLineToDisplay = lineNumber;
+                    offsetInLine = 0;
+                    return;
+                }
+            }
+        }
+        message = "Pattern not found";
+    }
+
+    private String printable(String s) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < s.length(); i++) {
+            char c = s.charAt(i);
+            if (c == ESCAPE) {
+                sb.append("ESC");
+            } else if (c < 32) {
+                sb.append('^').append((char) (c + '@'));
+            } else if (c < 128) {
+                sb.append(c);
+            } else {
+                sb.append('\\').append(String.format("03o"));
+            }
+        }
+        return sb.toString();
+    }
+
+    void moveForward(int lines) throws IOException {
+        int width = terminal.getWidth() - (printLineNumbers ? 8 : 0);
+        int height = terminal.getHeight();
+        while (--lines >= 0) {
+
+            int lastLineToDisplay = firstLineToDisplay;
+            if (firstColumnToDisplay > 0 || chopLongLines) {
+                lastLineToDisplay += height - 1;
+            } else {
+                int off = offsetInLine;
+                for (int l = 0; l < height - 1; l++) {
+                    String line = getLine(lastLineToDisplay);
+                    if (line.length() > off + width) {
+                        off += width;
+                    } else {
+                        off = 0;
+                        lastLineToDisplay++;
+                    }
+                }
+            }
+            if (getLine(lastLineToDisplay) == null) {
+                eof();
+                return;
+            }
+
+            String line = getLine(firstLineToDisplay);
+            if (line.length() > width + offsetInLine) {
+                offsetInLine += width;
+            } else {
+                offsetInLine = 0;
+                firstLineToDisplay++;
+            }
+        }
+    }
+
+    void moveBackward(int lines) throws IOException {
+        int width = terminal.getWidth() - (printLineNumbers ? 8 : 0);
+        while (--lines >= 0) {
+            if (offsetInLine > 0) {
+                offsetInLine = Math.max(0, offsetInLine - width);
+            } else if (firstLineInMemory < firstLineToDisplay) {
+                firstLineToDisplay--;
+                String line = getLine(firstLineToDisplay);
+                offsetInLine = line.length() - line.length() % width;
+            } else {
+                bof();
+                return;
+            }
+        }
+    }
+
+    private void eof() {
+        nbEof++;
+        message = "(END)";
+        if (!quiet && !veryQuiet && !quitAtFirstEof && !quitAtSecondEof) {
+            System.out.print((char) 0x07);
+            System.out.flush();
+        }
+    }
+
+    private void bof() {
+        if (!quiet && !veryQuiet) {
+            System.out.print((char) 0x07);
+            System.out.flush();
+        }
+    }
+
+    int getStrictPositiveNumberInBuffer(int def) {
+        try {
+            int n = Integer.parseInt(buffer.toString());
+            return (n > 0) ? n : def;
+        } catch (NumberFormatException e) {
+            return def;
+        } finally {
+            buffer.setLength(0);
+        }
+    }
+
+    void redraw() {
+        synchronized (redraw) {
+            redraw.set(true);
+            redraw.notifyAll();
+        }
+    }
+
+    void redrawLoop() {
+        synchronized (redraw) {
+            for (; ; ) {
+                try {
+                    if (redraw.compareAndSet(true, false)) {
+                        display();
+                    } else {
+                        redraw.wait();
+                    }
+                } catch (Exception e) {
+                    return;
+                }
+            }
+        }
+    }
+
+    void display() throws IOException {
+        System.out.println();
+        int width = terminal.getWidth() - (printLineNumbers ? 8 : 0);
+        int height = terminal.getHeight();
+        int inputLine = firstLineToDisplay;
+        String curLine = null;
+        Pattern compiled = getPattern();
+        for (int terminalLine = 0; terminalLine < height - 1; terminalLine++) {
+            if (curLine == null) {
+                curLine = getLine(inputLine++);
+                if (curLine == null) {
+                    curLine = "";
+                }
+                if (compiled != null) {
+                    curLine = compiled.matcher(curLine).replaceAll("\033[7m$1\033[0m");
+                }
+            }
+            String toDisplay;
+            if (firstColumnToDisplay > 0 || chopLongLines) {
+                int off = firstColumnToDisplay;
+                if (terminalLine == 0 && offsetInLine > 0) {
+                    off = Math.max(offsetInLine, off);
+                }
+                toDisplay = ansiSubstring(curLine, off, off + width);
+                curLine = null;
+            } else {
+                if (terminalLine == 0 && offsetInLine > 0) {
+                    curLine = ansiSubstring(curLine, offsetInLine, Integer.MAX_VALUE);
+                }
+                toDisplay = ansiSubstring(curLine, 0, width);
+                curLine = ansiSubstring(curLine, width, Integer.MAX_VALUE);
+                if (curLine.isEmpty()) {
+                    curLine = null;
+                }
+            }
+            if (printLineNumbers) {
+                System.out.print(String.format("%7d ", inputLine));
+            }
+            System.out.println(toDisplay);
+        }
+        System.out.flush();
+        if (message != null) {
+            System.out.print("\033[7m" + message + " \033[0m");
+        } else if (buffer.length() > 0) {
+            System.out.print(" " + buffer);
+        } else if (opBuffer.length() > 0) {
+            System.out.print(" " + printable(opBuffer.toString()));
+        } else {
+            System.out.print(":");
+        }
+        System.out.flush();
+    }
+
+    private Pattern getPattern() {
+        Pattern compiled = null;
+        if (pattern != null) {
+            boolean insensitive = ignoreCaseAlways || ignoreCaseCond && pattern.toLowerCase().equals(pattern);
+            compiled = Pattern.compile("(" + pattern + ")", insensitive ? Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE : 0);
+        }
+        return compiled;
+    }
+
+    private String ansiSubstring(String curLine, int begin, int end) {
+        int printPos = 0;
+        int csi = 0;
+        char sgr = '0';
+
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < curLine.length() && printPos < end; i++) {
+            char c = curLine.charAt(i);
+            if (csi == 0 && c == '\033') {
+                csi++;
+            } else if (csi == 1 && c == '[') {
+                csi++;
+            } else if (csi == 2) {
+                sgr = c;
+                csi++;
+            } else if (csi == 3 && c == 'm') {
+                csi = 0;
+                if (printPos >= begin) {
+                    sb.append("\033[").append(sgr).append("m");
+                }
+            } else {
+                if (printPos == begin && sgr != '0') {
+                    sb.append("\033[7m");
+                }
+                if (printPos >= begin && printPos < end) {
+                    sb.append(c);
+                }
+                ++printPos;
+            }
+        }
+        if (sgr != '0') {
+            sb.append("\033[0m");
+        }
+        return sb.toString();
+    }
+
+    String getLine(int line) throws IOException {
+        while (line <= lines.size()) {
+            String str = reader.readLine();
+            if (str != null) {
+                lines.add(str);
+            } else {
+                break;
+            }
+        }
+        if (line < lines.size()) {
+            return lines.get(line);
+        }
+        return null;
+    }
+
+    @Override
+    public void signal(Signal signal) {
+        // Ugly hack to force the jline unix terminal to retrieve the width/height of the terminal
+        // because results are cached for 1 second.
+        try {
+            Field field = terminal.getClass().getDeclaredField("terminal");
+            field.setAccessible(true);
+            Object jlineTerminal = field.get(terminal);
+            field = jlineTerminal.getClass().getSuperclass().getDeclaredField("settings");
+            field.setAccessible(true);
+            Object settings = field.get(jlineTerminal);
+            field = settings.getClass().getDeclaredField("configLastFetched");
+            field.setAccessible(true);
+            field.setLong(settings, 0L);
+        } catch (Throwable t) {
+            // Ignore
+        }
+        redraw();
+    }
+
+    protected boolean isTty(OutputStream out) {
+        try {
+            Method mth = out.getClass().getDeclaredMethod("getCurrent");
+            mth.setAccessible(true);
+            Object current = mth.invoke(out);
+            return current == session.getConsole();
+        } catch (Throwable t) {
+            return false;
+        }
+    }
+
+    /**
+     * This is for long running commands to be interrupted by ctrl-c
+     *
+     * @throws InterruptedException
+     */
+    public static void checkInterrupted() throws InterruptedException {
+        Thread.yield();
+        if (Thread.currentThread().isInterrupted()) {
+            throw new InterruptedException();
+        }
+    }
+
+
+    protected Object readOperation() throws IOException {
+        int c = pushBackChar.isEmpty() ? consoleReader.read() : pushBackChar.pop();
+        if (c == -1) {
+            return null;
+        }
+        opBuffer.append((char) c);
+
+        Object o = keys.getBound(opBuffer);
+        if (o == jline.console.Operation.DO_LOWERCASE_VERSION) {
+            opBuffer.setLength(opBuffer.length() - 1);
+            opBuffer.append(Character.toLowerCase((char) c));
+            o = keys.getBound(opBuffer);
+        }
+
+        if (o instanceof KeyMap) {
+            if (c == ESCAPE
+                    && pushBackChar.isEmpty()
+                    && consoleInput.isNonBlockingEnabled()
+                    && consoleInput.peek(ESCAPE_TIMEOUT) == READ_EXPIRED) {
+                o = ((KeyMap) o).getAnotherKey();
+                if (o == null || o instanceof KeyMap) {
+                    return null;
+                }
+                opBuffer.setLength(0);
+            } else {
+                return null;
+            }
+        }
+
+        while (o == null && opBuffer.length() > 0) {
+            c = opBuffer.charAt(opBuffer.length() - 1);
+            opBuffer.setLength(opBuffer.length() - 1);
+            Object o2 = keys.getBound(opBuffer);
+            if (o2 instanceof KeyMap) {
+                o = ((KeyMap) o2).getAnotherKey();
+                if (o != null) {
+                    pushBackChar.push((char) c);
+                }
+            }
+        }
+
+        if (o != null) {
+            opBuffer.setLength(0);
+            pushBackChar.clear();
+        }
+        return o;
+    }
+
+
+    private void bindKeys(KeyMap map) {
+        // Arrow keys bindings
+        map.bind("\033[0A", Operation.BACKWARD_ONE_LINE);
+        map.bind("\033[0B", Operation.LEFT_ONE_HALF_SCREEN);
+        map.bind("\033[0C", Operation.RIGHT_ONE_HALF_SCREEN);
+        map.bind("\033[0D", Operation.FORWARD_ONE_LINE);
+
+        map.bind("\340\110", Operation.BACKWARD_ONE_LINE);
+        map.bind("\340\113", Operation.LEFT_ONE_HALF_SCREEN);
+        map.bind("\340\115", Operation.RIGHT_ONE_HALF_SCREEN);
+        map.bind("\340\120", Operation.FORWARD_ONE_LINE);
+        map.bind("\000\110", Operation.BACKWARD_ONE_LINE);
+        map.bind("\000\113", Operation.LEFT_ONE_HALF_SCREEN);
+        map.bind("\000\115", Operation.RIGHT_ONE_HALF_SCREEN);
+        map.bind("\000\120", Operation.FORWARD_ONE_LINE);
+
+        map.bind("\033[A", Operation.BACKWARD_ONE_LINE);
+        map.bind("\033[B", Operation.FORWARD_ONE_LINE);
+        map.bind("\033[C", Operation.RIGHT_ONE_HALF_SCREEN);
+        map.bind("\033[D", Operation.LEFT_ONE_HALF_SCREEN);
+
+        map.bind("\033[OA", Operation.BACKWARD_ONE_LINE);
+        map.bind("\033[OB", Operation.FORWARD_ONE_LINE);
+        map.bind("\033[OC", Operation.RIGHT_ONE_HALF_SCREEN);
+        map.bind("\033[OD", Operation.LEFT_ONE_HALF_SCREEN);
+
+        map.bind("\0340H", Operation.BACKWARD_ONE_LINE);
+        map.bind("\0340P", Operation.FORWARD_ONE_LINE);
+        map.bind("\0340M", Operation.RIGHT_ONE_HALF_SCREEN);
+        map.bind("\0340K", Operation.LEFT_ONE_HALF_SCREEN);
+
+        map.bind("h", Operation.HELP);
+        map.bind("H", Operation.HELP);
+
+        map.bind("q", Operation.EXIT);
+        map.bind(":q", Operation.EXIT);
+        map.bind("Q", Operation.EXIT);
+        map.bind(":Q", Operation.EXIT);
+        map.bind("ZZ", Operation.EXIT);
+
+        map.bind("e", Operation.FORWARD_ONE_LINE);
+        map.bind(ctrl('E'), Operation.FORWARD_ONE_LINE);
+        map.bind("j", Operation.FORWARD_ONE_LINE);
+        map.bind(ctrl('N'), Operation.FORWARD_ONE_LINE);
+        map.bind("\r", Operation.FORWARD_ONE_LINE);
+
+        map.bind("y", Operation.BACKWARD_ONE_LINE);
+        map.bind(ctrl('Y'), Operation.BACKWARD_ONE_LINE);
+        map.bind("k", Operation.BACKWARD_ONE_LINE);
+        map.bind(ctrl('K'), Operation.BACKWARD_ONE_LINE);
+        map.bind(ctrl('P'), Operation.BACKWARD_ONE_LINE);
+
+        map.bind("f", Operation.FORWARD_ONE_WINDOW_OR_LINES);
+        map.bind(ctrl('F'), Operation.FORWARD_ONE_WINDOW_OR_LINES);
+        map.bind(ctrl('V'), Operation.FORWARD_ONE_WINDOW_OR_LINES);
+        map.bind(" ", Operation.FORWARD_ONE_WINDOW_OR_LINES);
+
+        map.bind("b", Operation.BACKWARD_ONE_WINDOW_OR_LINES);
+        map.bind(ctrl('B'), Operation.BACKWARD_ONE_WINDOW_OR_LINES);
+        map.bind("\033v", Operation.BACKWARD_ONE_WINDOW_OR_LINES);
+
+        map.bind("z", Operation.FORWARD_ONE_WINDOW_AND_SET);
+
+        map.bind("w", Operation.BACKWARD_ONE_WINDOW_AND_SET);
+
+        map.bind("\033 ", Operation.FORWARD_ONE_WINDOW_NO_STOP);
+
+        map.bind("d", Operation.FORWARD_HALF_WINDOW_AND_SET);
+        map.bind(ctrl('D'), Operation.FORWARD_HALF_WINDOW_AND_SET);
+
+        map.bind("u", Operation.BACKWARD_HALF_WINDOW_AND_SET);
+        map.bind(ctrl('U'), Operation.BACKWARD_HALF_WINDOW_AND_SET);
+
+        map.bind("\033)", Operation.RIGHT_ONE_HALF_SCREEN);
+
+        map.bind("\033(", Operation.LEFT_ONE_HALF_SCREEN);
+
+        map.bind("F", Operation.FORWARD_FOREVER);
+
+        map.bind("n", Operation.REPEAT_SEARCH_FORWARD);
+        map.bind("N", Operation.REPEAT_SEARCH_BACKWARD);
+        map.bind("\033n", Operation.REPEAT_SEARCH_FORWARD_SPAN_FILES);
+        map.bind("\033N", Operation.REPEAT_SEARCH_BACKWARD_SPAN_FILES);
+        map.bind("\033u", Operation.UNDO_SEARCH);
+
+        map.bind("g", Operation.GO_TO_FIRST_LINE_OR_N);
+        map.bind("<", Operation.GO_TO_FIRST_LINE_OR_N);
+        map.bind("\033<", Operation.GO_TO_FIRST_LINE_OR_N);
+
+        map.bind("G", Operation.GO_TO_LAST_LINE_OR_N);
+        map.bind(">", Operation.GO_TO_LAST_LINE_OR_N);
+        map.bind("\033>", Operation.GO_TO_LAST_LINE_OR_N);
+
+        for (char c : "-/0123456789?".toCharArray()) {
+            map.bind("" + c, c);
+        }
+    }
+
+    String ctrl(char c) {
+        return "" + ((char) (c & 0x1f));
+    }
+
+    static enum Operation {
+
+        // General
+        HELP,
+        EXIT,
+
+        // Moving
+        FORWARD_ONE_LINE,
+        BACKWARD_ONE_LINE,
+        FORWARD_ONE_WINDOW_OR_LINES,
+        BACKWARD_ONE_WINDOW_OR_LINES,
+        FORWARD_ONE_WINDOW_AND_SET,
+        BACKWARD_ONE_WINDOW_AND_SET,
+        FORWARD_ONE_WINDOW_NO_STOP,
+        FORWARD_HALF_WINDOW_AND_SET,
+        BACKWARD_HALF_WINDOW_AND_SET,
+        LEFT_ONE_HALF_SCREEN,
+        RIGHT_ONE_HALF_SCREEN,
+        FORWARD_FOREVER,
+        REPAINT,
+        REPAINT_AND_DISCARD,
+
+        // Searching
+        REPEAT_SEARCH_FORWARD,
+        REPEAT_SEARCH_BACKWARD,
+        REPEAT_SEARCH_FORWARD_SPAN_FILES,
+        REPEAT_SEARCH_BACKWARD_SPAN_FILES,
+        UNDO_SEARCH,
+
+        // Jumping
+        GO_TO_FIRST_LINE_OR_N,
+        GO_TO_LAST_LINE_OR_N,
+        GO_TO_PERCENT_OR_N,
+        GO_TO_NEXT_TAG,
+        GO_TO_PREVIOUS_TAG,
+        FIND_CLOSE_BRACKET,
+        FIND_OPEN_BRACKET,
+
+        // Options
+        OPT_PRINT_LINES,
+        OPT_CHOP_LONG_LINES,
+        OPT_QUIT_AT_FIRST_EOF,
+        OPT_QUIT_AT_SECOND_EOF,
+        OPT_QUIET,
+        OPT_VERY_QUIET,
+        OPT_IGNORE_CASE_COND,
+        OPT_IGNORE_CASE_ALWAYS,
+
+    }
+
+    static class InterruptibleInputStream extends FilterInputStream {
+        InterruptibleInputStream(InputStream in) {
+            super(in);
+        }
+
+        @Override
+        public int read(byte[] b, int off, int len) throws IOException {
+            if (Thread.currentThread().isInterrupted()) {
+                throw new InterruptedIOException();
+            }
+            return super.read(b, off, len);
+        }
+    }
+
+}