about summary refs log tree commit diff stats
path: root/src/local
diff options
context:
space:
mode:
Diffstat (limited to 'src/local')
-rw-r--r--src/local/client.nim643
-rw-r--r--src/local/container.nim1024
-rw-r--r--src/local/pager.nim1189
-rw-r--r--src/local/select.nim306
4 files changed, 3162 insertions, 0 deletions
diff --git a/src/local/client.nim b/src/local/client.nim
new file mode 100644
index 00000000..c8e6996e
--- /dev/null
+++ b/src/local/client.nim
@@ -0,0 +1,643 @@
+import cstrutils
+import nativesockets
+import net
+import options
+import os
+import selectors
+import streams
+import strutils
+import tables
+import terminal
+
+when defined(posix):
+  import posix
+
+import std/exitprocs
+
+import bindings/quickjs
+import config/config
+import css/sheet
+import display/term
+import html/chadombuilder
+import html/dom
+import html/event
+import io/headers
+import io/lineedit
+import io/loader
+import io/posixstream
+import io/promise
+import io/request
+import io/window
+import ips/forkserver
+import ips/socketstream
+import js/base64
+import js/domexception
+import js/error
+import js/fromjs
+import js/intl
+import js/javascript
+import js/module
+import js/timeout
+import js/tojs
+import local/container
+import local/pager
+import types/blob
+import types/cookie
+import types/url
+import utils/opt
+import utils/twtstr
+import xhr/formdata
+import xhr/xmlhttprequest
+
+import chakasu/charset
+
+type
+  Client* = ref object
+    alive: bool
+    attrs: WindowAttributes
+    config {.jsget.}: Config
+    console {.jsget.}: Console
+    errormessage: string
+    fd: int
+    fdmap: Table[int, Container]
+    feednext: bool
+    forkserver: ForkServer
+    notnum: bool # has a non-numeric character been input already?
+    jsctx: JSContext
+    jsrt: JSRuntime
+    line {.jsget.}: LineEdit
+    loader: FileLoader
+    mainproc: Pid
+    pager {.jsget.}: Pager
+    precnum: int32 # current number prefix (when vi-numeric-prefix is true)
+    s: string # current input buffer
+    selector: Selector[Container]
+    store {.jsget, jsset.}: Document
+    timeouts: TimeoutState[Container]
+    userstyle: CSSStylesheet
+
+  Console = ref object
+    err: Stream
+    pager: Pager
+    container: Container
+    prev: Container
+    ibuf: string
+    tty: File
+
+jsDestructor(Client)
+jsDestructor(Console)
+
+proc readChar(console: Console): char =
+  if console.ibuf == "":
+    try:
+      return console.tty.readChar()
+    except EOFError:
+      quit(1)
+  result = console.ibuf[0]
+  console.ibuf = console.ibuf.substr(1)
+
+proc finalize(client: Client) {.jsfin.} =
+  if client.jsctx != nil:
+    free(client.jsctx)
+  if client.jsrt != nil:
+    free(client.jsrt)
+
+proc doRequest(client: Client, req: Request): Response {.jsfunc.} =
+  return client.loader.doRequest(req)
+
+proc fetch[T: Request|string](client: Client, req: T,
+    init = none(RequestInit)): JSResult[FetchPromise] {.jsfunc.} =
+  let req = ?newRequest(client.jsctx, req, init)
+  return ok(client.loader.fetch(req))
+
+proc interruptHandler(rt: JSRuntime, opaque: pointer): int {.cdecl.} =
+  let client = cast[Client](opaque)
+  if client.console == nil or client.console.tty == nil: return
+  try:
+    let c = client.console.tty.readChar()
+    if c == char(3): #C-c
+      client.console.ibuf = ""
+      return 1
+    else:
+      client.console.ibuf &= c
+  except IOError:
+    discard
+  return 0
+
+proc runJSJobs(client: Client) =
+  client.jsrt.runJSJobs(client.console.err)
+
+proc evalJS(client: Client, src, filename: string, module = false): JSValue =
+  client.pager.term.unblockStdin()
+  let flags = if module:
+    JS_EVAL_TYPE_MODULE
+  else:
+    JS_EVAL_TYPE_GLOBAL
+  result = client.jsctx.eval(src, filename, flags)
+  client.runJSJobs()
+  client.pager.term.restoreStdin()
+
+proc evalJSFree(client: Client, src, filename: string) =
+  JS_FreeValue(client.jsctx, client.evalJS(src, filename))
+
+proc command0(client: Client, src: string, filename = "<command>",
+    silence = false, module = false) =
+  let ret = client.evalJS(src, filename, module = module)
+  if JS_IsException(ret):
+    client.jsctx.writeException(client.console.err)
+  else:
+    if not silence:
+      let str = fromJS[string](client.jsctx, ret)
+      if str.isSome:
+        client.console.err.write(str.get & '\n')
+        client.console.err.flush()
+  JS_FreeValue(client.jsctx, ret)
+
+proc command(client: Client, src: string) =
+  client.command0(src)
+  client.console.container.requestLines().then(proc() =
+    client.console.container.cursorLastLine())
+
+proc suspend(client: Client) {.jsfunc.} =
+  client.pager.term.quit()
+  discard kill(client.mainproc, cint(SIGSTOP))
+  client.pager.term.restart()
+
+proc quit(client: Client, code = 0) {.jsfunc.} =
+  if client.alive:
+    client.alive = false
+    client.pager.quit()
+    let ctx = client.jsctx
+    var global = JS_GetGlobalObject(ctx)
+    JS_FreeValue(ctx, global)
+    if client.jsctx != nil:
+      free(client.jsctx)
+    #TODO
+    #if client.jsrt != nil:
+    #  free(client.jsrt)
+  quit(code)
+
+proc feedNext(client: Client) {.jsfunc.} =
+  client.feednext = true
+
+proc alert(client: Client, msg: string) {.jsfunc.} =
+  client.pager.alert(msg)
+
+proc handlePagerEvents(client: Client) =
+  let container = client.pager.container
+  if container != nil:
+    client.pager.handleEvents(container)
+
+proc evalAction(client: Client, action: string, arg0: int32) =
+  let ret = client.evalJS(action, "<command>")
+  let ctx = client.jsctx
+  if JS_IsFunction(ctx, ret):
+    if arg0 != 0:
+      var arg0 = toJS(ctx, arg0)
+      JS_FreeValue(ctx, JS_Call(ctx, ret, JS_UNDEFINED, 1, addr arg0))
+      JS_FreeValue(ctx, arg0)
+    else: # no precnum
+      JS_FreeValue(ctx, JS_Call(ctx, ret, JS_UNDEFINED, 0, nil))
+  JS_FreeValue(ctx, ret)
+
+# The maximum number we are willing to accept.
+# This should be fine for 32-bit signed ints (which precnum currently is).
+# We can always increase it further (e.g. by switching to uint32, uint64...) if
+# it proves to be too low.
+const MaxPrecNum = 100000000
+
+proc handleCommandInput(client: Client, c: char) =
+  if client.config.input.vi_numeric_prefix and not client.notnum:
+    if client.precnum != 0 and c == '0' or c in '1' .. '9':
+      if client.precnum < MaxPrecNum: # better ignore than eval...
+        client.precnum *= 10
+        client.precnum += cast[int32](decValue(c))
+      return
+    else:
+      client.notnum = true
+  client.s &= c
+  let action = getNormalAction(client.config, client.s)
+  client.evalAction(action, client.precnum)
+  if not client.feedNext:
+    client.precnum = 0
+    client.notnum = false
+    client.handlePagerEvents()
+    client.pager.refreshStatusMsg()
+
+proc input(client: Client) =
+  client.pager.term.restoreStdin()
+  while true:
+    let c = client.console.readChar()
+    if client.pager.askpromise != nil:
+      if c == 'y':
+        client.pager.fulfillAsk(true)
+        client.runJSJobs()
+      elif c == 'n':
+        client.pager.fulfillAsk(false)
+        client.runJSJobs()
+    elif client.pager.lineedit.isSome:
+      client.s &= c
+      let edit = client.pager.lineedit.get
+      client.line = edit
+      if edit.escNext:
+        edit.escNext = false
+        if edit.write(client.s, client.pager.term.cs):
+          client.s = ""
+      else:
+        let action = getLinedAction(client.config, client.s)
+        if action == "":
+          if edit.write(client.s, client.pager.term.cs):
+            client.s = ""
+          else:
+            client.feedNext = true
+        elif not client.feednext:
+          client.evalAction(action, 0)
+        if client.pager.lineedit.isNone:
+          client.line = nil
+        if not client.feedNext:
+          client.pager.updateReadLine()
+    else:
+      client.handleCommandInput(c)
+    if not client.feednext:
+      client.s = ""
+      break
+    else:
+      client.feednext = false
+  client.s = ""
+
+proc setTimeout[T: JSValue|string](client: Client, handler: T,
+    timeout = 0i32): int32 {.jsfunc.} =
+  return client.timeouts.setTimeout(handler, timeout)
+
+proc setInterval[T: JSValue|string](client: Client, handler: T,
+    interval = 0i32): int32 {.jsfunc.} =
+  return client.timeouts.setInterval(handler, interval)
+
+proc clearTimeout(client: Client, id: int32) {.jsfunc.} =
+  client.timeouts.clearTimeout(id)
+
+proc clearInterval(client: Client, id: int32) {.jsfunc.} =
+  client.timeouts.clearInterval(id)
+
+let SIGWINCH {.importc, header: "<signal.h>", nodecl.}: cint
+
+proc log(console: Console, ss: varargs[string]) {.jsfunc.} =
+  for i in 0..<ss.len:
+    console.err.write(ss[i])
+    if i != ss.high:
+      console.err.write(' ')
+  console.err.write('\n')
+  console.err.flush()
+
+proc show(console: Console) {.jsfunc.} =
+  if console.pager.container != console.container:
+    console.prev = console.pager.container
+    console.pager.setContainer(console.container)
+    console.container.requestLines()
+
+proc hide(console: Console) {.jsfunc.} =
+  if console.pager.container == console.container:
+    console.pager.setContainer(console.prev)
+
+proc buffer(console: Console): Container {.jsfget.} =
+  return console.container
+
+proc acceptBuffers(client: Client) =
+  while client.pager.unreg.len > 0:
+    let (pid, stream) = client.pager.unreg.pop()
+    let fd = stream.source.getFd()
+    if int(fd) in client.fdmap:
+      client.selector.unregister(fd)
+      client.fdmap.del(int(fd))
+    else:
+      client.pager.procmap.del(pid)
+    stream.close()
+  for pid, container in client.pager.procmap:
+    let stream = connectSocketStream(pid, buffered = false, blocking = true)
+    container.setStream(stream)
+    let fd = stream.source.getFd()
+    client.fdmap[int(fd)] = container
+    client.selector.registerHandle(fd, {Read}, nil)
+    client.pager.handleEvents(container)
+  client.pager.procmap.clear()
+
+proc c_setvbuf(f: File, buf: pointer, mode: cint, size: csize_t): cint {.
+  importc: "setvbuf", header: "<stdio.h>", tags: [].}
+
+proc handleRead(client: Client, fd: int) =
+  if client.console.tty != nil and fd == client.console.tty.getFileHandle():
+    client.input()
+    client.handlePagerEvents()
+  elif fd == client.forkserver.estream.fd:
+    var nl = false
+    const prefix = "STDERR: "
+    var s = prefix
+    while true:
+      try:
+        let c = client.forkserver.estream.readChar()
+        if nl and s.len > prefix.len:
+          client.console.err.write(s)
+          s = prefix
+          nl = false
+        s &= c
+        nl = c == '\n'
+      except IOError:
+        break
+    if s.len > prefix.len:
+      client.console.err.write(s)
+    client.console.err.flush()
+  elif fd in client.loader.connecting:
+    client.loader.onConnected(fd)
+    client.runJSJobs()
+  elif fd in client.loader.ongoing:
+    client.loader.onRead(fd)
+  elif fd in client.loader.unregistered:
+    discard # ignore
+  else:
+    let container = client.fdmap[fd]
+    client.pager.handleEvent(container)
+
+proc flushConsole*(client: Client) {.jsfunc.} =
+  client.handleRead(client.forkserver.estream.fd)
+
+proc handleError(client: Client, fd: int) =
+  if client.console.tty != nil and fd == client.console.tty.getFileHandle():
+    #TODO do something here...
+    stderr.write("Error in tty\n")
+    quit(1)
+  elif fd == client.forkserver.estream.fd:
+    #TODO do something here...
+    stderr.write("Fork server crashed :(\n")
+    quit(1)
+  elif fd in client.loader.connecting:
+    #TODO handle error?
+    discard
+  elif fd in client.loader.ongoing:
+    client.loader.onError(fd)
+  elif fd in client.loader.unregistered:
+    discard # already unregistered...
+  else:
+    if fd in client.fdmap:
+      let container = client.fdmap[fd]
+      if container != client.console.container:
+        client.console.log("Error in buffer", $container.location)
+      else:
+        client.console.container = nil
+      client.selector.unregister(fd)
+      client.fdmap.del(fd)
+    if client.console.container != nil:
+      client.console.show()
+    else:
+      doAssert false
+
+proc inputLoop(client: Client) =
+  let selector = client.selector
+  discard c_setvbuf(client.console.tty, nil, IONBF, 0)
+  selector.registerHandle(int(client.console.tty.getFileHandle()), {Read}, nil)
+  let sigwinch = selector.registerSignal(int(SIGWINCH), nil)
+  while true:
+    let events = client.selector.select(-1)
+    for event in events:
+      if Read in event.events:
+        client.handleRead(event.fd)
+      if Error in event.events:
+        client.handleError(event.fd)
+      if Signal in event.events: 
+        assert event.fd == sigwinch
+        client.attrs = getWindowAttributes(client.console.tty)
+        client.pager.windowChange(client.attrs)
+      if selectors.Event.Timer in event.events:
+        assert client.timeouts.runTimeoutFd(event.fd)
+        client.runJSJobs()
+        client.console.container.requestLines().then(proc() =
+          client.console.container.cursorLastLine())
+    client.loader.unregistered.setLen(0)
+    client.acceptBuffers()
+    if client.pager.scommand != "":
+      client.command(client.pager.scommand)
+      client.pager.scommand = ""
+      client.handlePagerEvents()
+    if client.pager.container == nil:
+      # No buffer to display.
+      quit(1)
+    client.pager.showAlerts()
+    client.pager.draw()
+
+func hasSelectFds(client: Client): bool =
+  return not client.timeouts.empty or
+    client.pager.numload > 0 or
+    client.loader.connecting.len > 0 or
+    client.loader.ongoing.len > 0 or
+    client.pager.procmap.len > 0
+
+proc headlessLoop(client: Client) =
+  while client.hasSelectFds():
+    let events = client.selector.select(-1)
+    for event in events:
+      if Read in event.events:
+        client.handleRead(event.fd)
+      if Error in event.events:
+        client.handleError(event.fd)
+      if selectors.Event.Timer in event.events:
+        assert client.timeouts.runTimeoutFd(event.fd)
+    client.runJSJobs()
+    client.loader.unregistered.setLen(0)
+    client.acceptBuffers()
+
+proc clientLoadJSModule(ctx: JSContext, module_name: cstring,
+    opaque: pointer): JSModuleDef {.cdecl.} =
+  let global = JS_GetGlobalObject(ctx)
+  JS_FreeValue(ctx, global)
+  var x: Option[URL]
+  if module_name.startsWith("/") or module_name.startsWith("./") or
+      module_name.startsWith("../"):
+    let cur = getCurrentDir()
+    x = parseURL($module_name, parseURL("file://" & cur & "/"))
+  else:
+    x = parseURL($module_name)
+  if x.isNone or x.get.scheme != "file":
+    JS_ThrowTypeError(ctx, "Invalid URL: %s", module_name)
+    return nil
+  try:
+    let f = readFile($x.get.path)
+    return finishLoadModule(ctx, f, module_name)
+  except IOError:
+    JS_ThrowTypeError(ctx, "Failed to open file %s", module_name)
+    return nil
+
+proc readBlob(client: Client, path: string): Option[WebFile] {.jsfunc.} =
+  try:
+    return some(newWebFile(path))
+  except IOError:
+    discard
+
+#TODO this is dumb
+proc readFile(client: Client, path: string): string {.jsfunc.} =
+  try:
+    return readFile(path)
+  except IOError:
+    discard
+
+#TODO ditto
+proc writeFile(client: Client, path: string, content: string) {.jsfunc.} =
+  writeFile(path, content)
+
+proc newConsole(pager: Pager, tty: File): Console =
+  new(result)
+  if tty != nil:
+    var pipefd: array[0..1, cint]
+    if pipe(pipefd) == -1:
+      raise newException(Defect, "Failed to open console pipe.")
+    let url = newURL("javascript:console.show()")
+    result.container = pager.readPipe0(some("text/plain"), CHARSET_UNKNOWN,
+      pipefd[0], option(url.get(nil)), "Browser console")
+    var f: File
+    if not open(f, pipefd[1], fmWrite):
+      raise newException(Defect, "Failed to open file for console pipe.")
+    result.err = newFileStream(f)
+    result.err.writeLine("Type (M-c) console.hide() to return to buffer mode.")
+    result.err.flush()
+    result.pager = pager
+    result.tty = tty
+    pager.registerContainer(result.container)
+  else:
+    result.err = newFileStream(stderr)
+
+proc dumpBuffers(client: Client) =
+  client.headlessLoop()
+  let ostream = newFileStream(stdout)
+  for container in client.pager.containers:
+    try:
+      client.pager.drawBuffer(container, ostream)
+      client.pager.handleEvents(container)
+    except IOError:
+      client.console.log("Error in buffer", $container.location)
+      # check for errors
+      client.handleRead(client.forkserver.estream.fd)
+      quit(1)
+  stdout.close()
+
+proc launchClient*(client: Client, pages: seq[string],
+    contentType: Option[string], cs: Charset, dump: bool) =
+  var tty: File
+  var dump = dump
+  if not dump:
+    if stdin.isatty():
+      tty = stdin
+    if stdout.isatty():
+      if tty == nil:
+        dump = not open(tty, "/dev/tty", fmRead)
+    else:
+      dump = true
+  let selector = newSelector[Container]()
+  let efd = int(client.forkserver.estream.fd)
+  selector.registerHandle(efd, {Read}, nil)
+  client.loader.registerFun = proc(fd: int) =
+    selector.registerHandle(fd, {Read}, nil)
+  client.loader.unregisterFun = proc(fd: int) =
+    selector.unregister(fd)
+  client.selector = selector
+  client.pager.launchPager(tty)
+  client.console = newConsole(client.pager, tty)
+  #TODO passing console.err here makes it impossible to change it later. maybe
+  # better associate it with jsctx
+  client.timeouts = newTimeoutState(client.selector, client.jsctx,
+    client.console.err, proc(src, file: string) = client.evalJSFree(src, file))
+  client.alive = true
+  addExitProc((proc() = client.quit()))
+  if client.config.start.startup_script != "":
+    let s = if fileExists(client.config.start.startup_script):
+      readFile(client.config.start.startup_script)
+    else:
+      client.config.start.startup_script
+    let ismodule = client.config.start.startup_script.endsWith(".mjs")
+    client.command0(s, client.config.start.startup_script, silence = true,
+      module = ismodule)
+  client.userstyle = client.config.css.stylesheet.parseStylesheet()
+
+  if not stdin.isatty():
+    client.pager.readPipe(contentType, cs, stdin.getFileHandle())
+
+  for page in pages:
+    client.pager.loadURL(page, ctype = contentType, cs = cs)
+  client.pager.showAlerts()
+  client.acceptBuffers()
+  if not dump:
+    client.inputLoop()
+  else:
+    client.dumpBuffers()
+  if client.config.start.headless:
+    client.headlessLoop()
+  client.quit()
+
+proc nimGCStats(client: Client): string {.jsfunc.} =
+  return GC_getStatistics()
+
+proc jsGCStats(client: Client): string {.jsfunc.} =
+  return client.jsrt.getMemoryUsage()
+
+proc nimCollect(client: Client) {.jsfunc.} =
+  GC_fullCollect()
+
+proc jsCollect(client: Client) {.jsfunc.} =
+  JS_RunGC(client.jsrt)
+
+proc sleep(client: Client, millis: int) {.jsfunc.} =
+  sleep millis
+
+proc atob(client: Client, data: string): DOMResult[string] {.jsfunc.} =
+  return atob(data)
+
+proc btoa(client: Client, data: string): DOMResult[string] {.jsfunc.} =
+  return btoa(data)
+
+proc addJSModules(client: Client, ctx: JSContext) =
+  ctx.addDOMExceptionModule()
+  ctx.addCookieModule()
+  ctx.addURLModule()
+  ctx.addEventModule()
+  ctx.addDOMModule()
+  ctx.addHTMLModule()
+  ctx.addIntlModule()
+  ctx.addBlobModule()
+  ctx.addFormDataModule()
+  ctx.addXMLHttpRequestModule()
+  ctx.addHeadersModule()
+  ctx.addRequestModule()
+  ctx.addResponseModule()
+  ctx.addLineEditModule()
+  ctx.addConfigModule()
+  ctx.addPagerModule()
+  ctx.addContainerModule()
+
+func getClient(client: Client): Client {.jsfget: "client".} =
+  return client
+
+proc newClient*(config: Config, forkserver: ForkServer, mainproc: Pid): Client =
+  setControlCHook(proc() {.noconv.} = quit(1))
+  let jsrt = newJSRuntime()
+  JS_SetModuleLoaderFunc(jsrt, normalizeModuleName, clientLoadJSModule, nil)
+  let jsctx = jsrt.newJSContext()
+  let attrs = getWindowAttributes(stdout)
+  let client = Client(
+    config: config,
+    forkserver: forkserver,
+    mainproc: mainproc,
+    attrs: attrs,
+    loader: forkserver.newFileLoader(
+      defaultHeaders = config.getDefaultHeaders(),
+      proxy = config.getProxy(),
+      acceptProxy = true
+    ),
+    jsrt: jsrt,
+    jsctx: jsctx,
+    pager: newPager(config, attrs, forkserver, mainproc, jsctx)
+  )
+  jsrt.setInterruptHandler(interruptHandler, cast[pointer](client))
+  var global = JS_GetGlobalObject(jsctx)
+  jsctx.registerType(Client, asglobal = true)
+  setGlobal(jsctx, global, client)
+  JS_FreeValue(jsctx, global)
+  jsctx.registerType(Console)
+  client.addJSModules(jsctx)
+  return client
diff --git a/src/local/container.nim b/src/local/container.nim
new file mode 100644
index 00000000..5941bf6c
--- /dev/null
+++ b/src/local/container.nim
@@ -0,0 +1,1024 @@
+import deques
+import options
+import streams
+import unicode
+
+when defined(posix):
+  import posix
+
+import buffer/buffer
+import buffer/cell
+import config/config
+import io/promise
+import io/request
+import io/window
+import ips/forkserver
+import ips/serialize
+import js/javascript
+import js/regex
+import local/select
+import types/buffersource
+import types/color
+import types/cookie
+import types/url
+import utils/mimeguess
+import utils/twtstr
+
+import chakasu/charset
+
+type
+  CursorPosition* = object
+    cursorx*: int
+    cursory*: int
+    xend*: int
+    fromx*: int
+    fromy*: int
+    setx: int
+    setxrefresh: bool
+
+  ContainerEventType* = enum
+    NO_EVENT, FAIL, SUCCESS, NEEDS_AUTH, REDIRECT, ANCHOR, NO_ANCHOR, UPDATE,
+    READ_LINE, READ_AREA, OPEN, INVALID_COMMAND, STATUS, ALERT, LOADED, TITLE,
+    CHECK_MAILCAP, QUIT
+
+  ContainerEvent* = object
+    case t*: ContainerEventType
+    of READ_LINE:
+      prompt*: string
+      value*: string
+      password*: bool
+    of READ_AREA:
+      tvalue*: string
+    of OPEN, REDIRECT:
+      request*: Request
+    of ANCHOR, NO_ANCHOR:
+      anchor*: string
+    of ALERT:
+      msg*: string
+    of UPDATE:
+      force*: bool
+    else: discard
+
+  Highlight* = ref object
+    x*, y*: int
+    endy*, endx*: int
+    rect*: bool
+    clear*: bool
+
+  Container* = ref object
+    parent* {.jsget.}: Container
+    children* {.jsget.}: seq[Container]
+    config*: BufferConfig
+    iface*: BufferInterface
+    width* {.jsget.}: int
+    height* {.jsget.}: int
+    title*: string # used in status msg
+    hovertext: array[HoverType, string]
+    lastpeek: HoverType
+    source*: BufferSource
+    pos: CursorPosition
+    bpos: seq[CursorPosition]
+    highlights: seq[Highlight]
+    process* {.jsget.}: Pid
+    loadinfo*: string
+    lines: SimpleFlexibleGrid
+    lineshift: int
+    numLines*: int
+    replace*: Container
+    code*: int
+    retry*: seq[URL]
+    hlon*: bool # highlight on?
+    sourcepair*: Container # pointer to buffer with a source view (may be nil)
+    redraw*: bool
+    needslines*: bool
+    canceled: bool
+    events*: Deque[ContainerEvent]
+    startpos: Option[CursorPosition]
+    hasstart: bool
+    redirectdepth*: int
+    select*: Select
+
+jsDestructor(Container)
+
+proc newBuffer*(forkserver: ForkServer, mainproc: Pid, config: BufferConfig,
+    source: BufferSource, title = "", redirectdepth = 0): Container =
+  let attrs = getWindowAttributes(stdout)
+  let ostream = forkserver.ostream
+  let istream = forkserver.istream
+  ostream.swrite(FORK_BUFFER)
+  ostream.swrite(source)
+  ostream.swrite(config)
+  ostream.swrite(attrs)
+  ostream.swrite(mainproc)
+  ostream.flush()
+  var process: Pid
+  istream.sread(process)
+  return Container(
+    source: source,
+    width: attrs.width,
+    height: attrs.height - 1,
+    title: title,
+    config: config,
+    redirectdepth: redirectdepth,
+    process: process,
+    pos: CursorPosition(
+      setx: -1
+    )
+  )
+
+func charset*(container: Container): Charset =
+  return container.source.charset
+
+func contentType*(container: Container): Option[string] {.jsfget.} =
+  return container.source.contenttype
+
+func location*(container: Container): URL {.jsfget.} =
+  return container.source.location
+
+func lineLoaded(container: Container, y: int): bool =
+  return y - container.lineshift in 0..container.lines.high
+
+func getLine(container: Container, y: int): SimpleFlexibleLine =
+  if container.lineLoaded(y):
+    return container.lines[y - container.lineshift]
+
+iterator ilines*(container: Container, slice: Slice[int]): SimpleFlexibleLine {.inline.} =
+  for y in slice:
+    yield container.getLine(y)
+
+func cursorx*(container: Container): int {.inline.} = container.pos.cursorx
+func cursory*(container: Container): int {.inline.} = container.pos.cursory
+func fromx*(container: Container): int {.inline.} = container.pos.fromx
+func fromy*(container: Container): int {.inline.} = container.pos.fromy
+func xend(container: Container): int {.inline.} = container.pos.xend
+func lastVisibleLine(container: Container): int = min(container.fromy + container.height, container.numLines) - 1
+
+func currentLine(container: Container): string =
+  return container.getLine(container.cursory).str
+
+func cursorBytes(container: Container, y: int, cc = container.cursorx): int =
+  let line = container.getLine(y).str
+  var w = 0
+  var i = 0
+  while i < line.len and w < cc:
+    var r: Rune
+    fastRuneAt(line, i, r)
+    w += r.twidth(w)
+  return i
+
+func currentCursorBytes(container: Container, cc = container.cursorx): int =
+  return container.cursorBytes(container.cursory, cc)
+
+# Returns the X position of the first cell occupied by the character the cursor
+# currently points to.
+func cursorFirstX(container: Container): int =
+  if container.numLines == 0: return 0
+  let line = container.currentLine
+  var w = 0
+  var i = 0
+  var r: Rune
+  let cc = container.cursorx
+  while i < line.len:
+    fastRuneAt(line, i, r)
+    let tw = r.twidth(w)
+    if w + tw > cc:
+      return w
+    w += tw
+
+# Returns the X position of the last cell occupied by the character the cursor
+# currently points to.
+func cursorLastX(container: Container): int =
+  if container.numLines == 0: return 0
+  let line = container.currentLine
+  var w = 0
+  var i = 0
+  var r: Rune
+  let cc = container.cursorx
+  while i < line.len and w <= cc:
+    fastRuneAt(line, i, r)
+    w += r.twidth(w)
+  return max(w - 1, 0)
+
+# Last cell for tab, first cell for everything else (e.g. double width.)
+# This is needed because moving the cursor to the 2nd cell of a double
+# width character clears it on some terminals.
+func cursorDispX(container: Container): int =
+  if container.numLines == 0: return 0
+  let line = container.currentLine
+  if line.len == 0: return 0
+  var w = 0
+  var pw = 0
+  var i = 0
+  var r: Rune
+  let cc = container.cursorx
+  while i < line.len and w <= cc:
+    fastRuneAt(line, i, r)
+    pw = w
+    w += r.twidth(w)
+  if r == Rune('\t'):
+    return max(w - 1, 0)
+  else:
+    return pw
+
+func acursorx*(container: Container): int =
+  max(0, container.cursorDispX() - container.fromx)
+
+func acursory*(container: Container): int =
+  container.cursory - container.fromy
+
+func maxScreenWidth(container: Container): int =
+  for line in container.ilines(container.fromy..container.lastVisibleLine):
+    result = max(line.str.width(), result)
+
+func getTitle*(container: Container): string {.jsfunc.} =
+  if container.title != "":
+    return container.title
+  return container.source.location.serialize(excludepassword = true)
+
+func currentLineWidth(container: Container): int =
+  if container.numLines == 0: return 0
+  return container.currentLine.width()
+
+func maxfromy(container: Container): int = max(container.numLines - container.height, 0)
+
+func maxfromx(container: Container): int = max(container.maxScreenWidth() - container.width, 0)
+
+func atPercentOf*(container: Container): int =
+  if container.numLines == 0: return 100
+  return (100 * (container.cursory + 1)) div container.numLines
+
+func lineWindow(container: Container): Slice[int] =
+  if container.numLines == 0: # not loaded
+    return 0..container.height * 5
+  let n = (container.height * 5) div 2
+  var x = container.fromy - n + container.height div 2
+  var y = container.fromy + n + container.height div 2
+  if y >= container.numLines:
+    x -= y - container.numLines
+    y = container.numLines
+  if x < 0:
+    y += -x
+    x = 0
+  return x .. y
+
+func contains*(hl: Highlight, x, y: int): bool =
+  if hl.rect:
+    let rx = hl.x .. hl.endx
+    let ry = hl.y .. hl.endy
+    return x in rx and y in ry
+  else:
+    return (y > hl.y or y == hl.y and x >= hl.x) and
+      (y < hl.endy or y == hl.endy and x <= hl.endx)
+
+func contains*(hl: Highlight, y: int): bool =
+  return y in hl.y .. hl.endy
+
+func colorArea*(hl: Highlight, y: int, limitx: Slice[int]): Slice[int] =
+  if hl.rect:
+    if y in hl.y .. hl.endy:
+      return max(hl.x, limitx.a) .. min(hl.endx, limitx.b)
+  else:
+    if y in hl.y + 1 .. hl.endy - 1:
+      return limitx
+    if y == hl.y and y == hl.endy:
+      return max(hl.x, limitx.a) .. min(hl.endx, limitx.b)
+    if y == hl.y:
+      return max(hl.x, limitx.a) .. limitx.b
+    if y == hl.endy:
+      return limitx.a .. min(hl.endx, limitx.b)
+
+func findHighlights*(container: Container, y: int): seq[Highlight] =
+  for hl in container.highlights:
+    if y in hl:
+      result.add(hl)
+
+func getHoverText*(container: Container): string =
+  for t in HoverType:
+    if container.hovertext[t] != "":
+      return container.hovertext[t]
+
+func isHoverURL*(container: Container, url: URL): bool =
+  let hoverurl = parseURL(container.hovertext[HOVER_LINK])
+  return hoverurl.isSome and url.host == hoverurl.get.host
+
+proc triggerEvent(container: Container, event: ContainerEvent) =
+  container.events.addLast(event)
+
+proc triggerEvent(container: Container, t: ContainerEventType) =
+  container.triggerEvent(ContainerEvent(t: t))
+
+proc updateCursor(container: Container)
+
+proc setNumLines(container: Container, lines: int, finish = false) =
+  if container.numLines != lines:
+    container.numLines = lines
+    if container.startpos.isSome and finish:
+      container.pos = container.startpos.get
+      container.startpos = none(CursorPosition)
+    container.updateCursor()
+    container.triggerEvent(STATUS)
+
+proc requestLines*(container: Container, w = container.lineWindow): auto {.discardable.} =
+  return container.iface.getLines(w).then(proc(res: tuple[numLines: int, lines: seq[SimpleFlexibleLine]]) =
+    container.lines.setLen(w.len)
+    container.lineshift = w.a
+    for y in 0 ..< min(res.lines.len, w.len):
+      container.lines[y] = res.lines[y]
+      container.lines[y].str.mnormalize()
+    container.updateCursor()
+    if res.numLines != container.numLines:
+      container.setNumLines(res.numLines, true)
+    let cw = container.fromy ..< container.fromy + container.height
+    if w.a in cw or w.b in cw or cw.a in w or cw.b in w:
+      container.triggerEvent(UPDATE))
+
+proc redraw(container: Container) {.jsfunc.} =
+  container.triggerEvent(ContainerEvent(t: UPDATE, force: true))
+
+proc sendCursorPosition*(container: Container) =
+  container.iface.updateHover(container.cursorx, container.cursory)
+      .then(proc(res: UpdateHoverResult) =
+    if res.link.isSome:
+      container.hovertext[HOVER_LINK] = res.link.get
+    if res.title.isSome:
+      container.hovertext[HOVER_TITLE] = res.title.get
+    if res.link.isSome or res.title.isSome:
+      container.triggerEvent(STATUS)
+    if res.repaint:
+      container.needslines = true)
+
+proc setFromY(container: Container, y: int) {.jsfunc.} =
+  if container.pos.fromy != y:
+    container.pos.fromy = max(min(y, container.maxfromy), 0)
+    container.needslines = true
+    container.triggerEvent(UPDATE)
+
+proc setFromX(container: Container, x: int, refresh = true) {.jsfunc.} =
+  if container.pos.fromx != x:
+    container.pos.fromx = max(min(x, container.maxfromx), 0)
+    if container.pos.fromx > container.cursorx:
+      container.pos.cursorx = min(container.pos.fromx, container.currentLineWidth())
+      if refresh:
+        container.sendCursorPosition()
+    container.triggerEvent(UPDATE)
+
+proc setFromXY(container: Container, x, y: int) {.jsfunc.} =
+  container.setFromY(y)
+  container.setFromX(x)
+
+proc setCursorX(container: Container, x: int, refresh = true, save = true) {.jsfunc.} =
+  if not container.lineLoaded(container.cursory):
+    container.pos.setx = x
+    container.pos.setxrefresh = refresh
+    return
+  container.pos.setx = -1
+  let cw = container.currentLineWidth()
+  let x2 = x
+  let x = max(min(x, cw - 1), 0)
+  if not refresh or container.fromx <= x and x < container.fromx + container.width:
+    container.pos.cursorx = x
+  elif refresh and container.fromx > x:
+    if x2 < container.cursorx:
+      container.setFromX(x, false)
+    container.pos.cursorx = container.fromx
+  elif x > container.cursorx:
+    container.setFromX(max(x - container.width + 1, container.fromx), false)
+    container.pos.cursorx = x
+  elif x < container.cursorx:
+    container.setFromX(x, false)
+    container.pos.cursorx = x
+  if refresh:
+    container.sendCursorPosition()
+  if save:
+    container.pos.xend = container.cursorx
+
+proc restoreCursorX(container: Container) {.jsfunc.} =
+  let x = clamp(container.currentLineWidth() - 1, 0, container.xend)
+  container.setCursorX(x, false, false)
+
+proc setCursorY(container: Container, y: int, refresh = true) {.jsfunc.} =
+  let y = max(min(y, container.numLines - 1), 0)
+  if container.cursory == y: return
+  if y - container.fromy >= 0 and y - container.height < container.fromy:
+    container.pos.cursory = y
+  else:
+    if y > container.cursory:
+      container.setFromY(y - container.height + 1)
+    else:
+      container.setFromY(y)
+    container.pos.cursory = y
+  container.restoreCursorX()
+  if refresh:
+    container.sendCursorPosition()
+
+proc centerLine(container: Container) {.jsfunc.} =
+  container.setFromY(container.cursory - container.height div 2)
+
+proc centerColumn(container: Container) {.jsfunc.} =
+  container.setFromX(container.cursorx - container.width div 2)
+
+proc setCursorXY(container: Container, x, y: int, refresh = true) {.jsfunc.} =
+  let fy = container.fromy
+  container.setCursorY(y, refresh)
+  container.setCursorX(x, refresh)
+  if fy != container.fromy:
+    container.centerLine()
+
+proc cursorDown(container: Container, n = 1) {.jsfunc.} =
+  if container.select.open:
+    container.select.cursorDown()
+  else:
+    container.setCursorY(container.cursory + n)
+
+proc cursorUp(container: Container, n = 1) {.jsfunc.} =
+  if container.select.open:
+    container.select.cursorUp()
+  else:
+    container.setCursorY(container.cursory - n)
+
+proc cursorLeft(container: Container, n = 1) {.jsfunc.} =
+  if container.select.open:
+    container.select.cursorLeft()
+  else:
+    container.setCursorX(container.cursorFirstX() - n)
+
+proc cursorRight(container: Container, n = 1) {.jsfunc.} =
+  if container.select.open:
+    container.select.cursorRight()
+  else:
+    container.setCursorX(container.cursorLastX() + n)
+
+proc cursorLineBegin(container: Container) {.jsfunc.} =
+  container.setCursorX(0)
+
+proc cursorLineTextStart(container: Container) {.jsfunc.} =
+  if container.numLines == 0: return
+  var x = 0
+  for r in container.currentLine.runes:
+    if not r.isWhitespace():
+      break
+    x += r.twidth(x)
+  container.setCursorX(x)
+
+proc cursorLineEnd(container: Container) {.jsfunc.} =
+  container.setCursorX(container.currentLineWidth() - 1)
+
+proc cursorNextWord(container: Container) {.jsfunc.} =
+  if container.numLines == 0: return
+  var r: Rune
+  var b = container.currentCursorBytes()
+  var x = container.cursorx
+  while b < container.currentLine.len:
+    let pb = b
+    fastRuneAt(container.currentLine, b, r)
+    if r.breaksWord():
+      b = pb
+      break
+    x += r.twidth(x)
+
+  while b < container.currentLine.len:
+    let pb = b
+    fastRuneAt(container.currentLine, b, r)
+    if not r.breaksWord():
+      b = pb
+      break
+    x += r.twidth(x)
+
+  if b < container.currentLine.len:
+    container.setCursorX(x)
+  else:
+    if container.cursory < container.numLines - 1:
+      container.cursorDown()
+      container.cursorLineBegin()
+    else:
+      container.cursorLineEnd()
+
+proc cursorPrevWord(container: Container) {.jsfunc.} =
+  if container.numLines == 0: return
+  var b = container.currentCursorBytes()
+  var x = container.cursorx
+  if container.currentLine.len > 0:
+    b = min(b, container.currentLine.len - 1)
+    while b >= 0:
+      let (r, o) = lastRune(container.currentLine, b)
+      if r.breaksWord():
+        break
+      b -= o
+      x -= r.twidth(x)
+
+    while b >= 0:
+      let (r, o) = lastRune(container.currentLine, b)
+      if not r.breaksWord():
+        break
+      b -= o
+      x -= r.twidth(x)
+  else:
+    b = -1
+
+  if b >= 0:
+    container.setCursorX(x)
+  else:
+    if container.cursory > 0:
+      container.cursorUp()
+      container.cursorLineEnd()
+    else:
+      container.cursorLineBegin()
+
+proc pageDown(container: Container) {.jsfunc.} =
+  container.setFromY(container.fromy + container.height)
+  container.setCursorY(container.cursory + container.height)
+  container.restoreCursorX()
+
+proc pageUp(container: Container) {.jsfunc.} =
+  container.setFromY(container.fromy - container.height)
+  container.setCursorY(container.cursory - container.height)
+  container.restoreCursorX()
+
+proc pageLeft(container: Container) {.jsfunc.} =
+  container.setFromX(container.fromx - container.width)
+
+proc pageRight(container: Container) {.jsfunc.} =
+  container.setFromX(container.fromx + container.width)
+
+proc halfPageUp(container: Container) {.jsfunc.} =
+  container.setFromY(container.fromy - container.height div 2 + 1)
+  container.setCursorY(container.cursory - container.height div 2 + 1)
+  container.restoreCursorX()
+
+proc halfPageDown(container: Container) {.jsfunc.} =
+  container.setFromY(container.fromy + container.height div 2 - 1)
+  container.setCursorY(container.cursory + container.height div 2 - 1)
+  container.restoreCursorX()
+
+proc cursorFirstLine(container: Container) {.jsfunc.} =
+  if container.select.open:
+    container.select.cursorFirstLine()
+  else:
+    container.setCursorY(0)
+
+proc cursorLastLine*(container: Container) {.jsfunc.} =
+  if container.select.open:
+    container.select.cursorLastLine()
+  else:
+    container.setCursorY(container.numLines - 1)
+
+proc cursorTop(container: Container) {.jsfunc.} =
+  container.setCursorY(container.fromy)
+
+proc cursorMiddle(container: Container) {.jsfunc.} =
+  container.setCursorY(container.fromy + (container.height - 2) div 2)
+
+proc cursorBottom(container: Container) {.jsfunc.} =
+  container.setCursorY(container.fromy + container.height - 1)
+
+proc cursorLeftEdge(container: Container) {.jsfunc.} =
+  container.setCursorX(container.fromx)
+
+proc cursorMiddleColumn(container: Container) {.jsfunc.} =
+  container.setCursorX(container.fromx + (container.width - 2) div 2)
+
+proc cursorRightEdge(container: Container) {.jsfunc.} =
+  container.setCursorX(container.fromx + container.width - 1)
+
+proc scrollDown(container: Container) {.jsfunc.} =
+  if container.fromy + container.height < container.numLines:
+    container.setFromY(container.fromy + 1)
+    if container.fromy > container.cursory:
+      container.cursorDown()
+  else:
+    container.cursorDown()
+
+proc scrollUp(container: Container) {.jsfunc.} =
+  if container.fromy > 0:
+    container.setFromY(container.fromy - 1)
+    if container.fromy + container.height <= container.cursory:
+      container.cursorUp()
+  else:
+    container.cursorUp()
+
+proc scrollRight(container: Container) {.jsfunc.} =
+  if container.fromx + container.width < container.maxScreenWidth():
+    container.setFromX(container.fromx + 1)
+
+proc scrollLeft(container: Container) {.jsfunc.} =
+  if container.fromx > 0:
+    container.setFromX(container.fromx - 1)
+
+proc alert(container: Container, msg: string) =
+  container.triggerEvent(ContainerEvent(t: ALERT, msg: msg))
+
+proc lineInfo(container: Container) {.jsfunc.} =
+  container.alert("line " & $(container.cursory + 1) & "/" &
+    $container.numLines & " (" & $container.atPercentOf() & "%) col " &
+    $(container.cursorx + 1) & "/" & $container.currentLineWidth &
+    " (byte " & $container.currentCursorBytes & ")")
+
+proc updateCursor(container: Container) =
+  if container.pos.setx > -1:
+    container.setCursorX(container.pos.setx, container.pos.setxrefresh)
+  if container.fromy > container.maxfromy:
+    container.setFromY(container.maxfromy)
+  if container.cursory >= container.numLines:
+    container.setCursorY(container.lastVisibleLine)
+    container.alert("Last line is #" & $container.numLines)
+
+proc gotoLine*[T: string|int](container: Container, s: T) =
+  when s is string:
+    if s == "":
+      redraw(container)
+    elif s[0] == '^':
+      container.cursorFirstLine()
+    elif s[0] == '$':
+      container.cursorLastLine()
+    else:
+      let i = parseUInt32(s)
+      if i.isSome and i.get > 0:
+        container.setCursorY(int(i.get - 1))
+      else:
+        container.alert("First line is #1") # :)
+  else:
+    container.setCursorY(s - 1)
+
+proc pushCursorPos*(container: Container) =
+  if container.select.open:
+    container.select.pushCursorPos()
+  else:
+    container.bpos.add(container.pos)
+
+proc popCursorPos*(container: Container, nojump = false) =
+  if container.select.open:
+    container.select.popCursorPos(nojump)
+  else:
+    container.pos = container.bpos.pop()
+    if not nojump:
+      container.updateCursor()
+      container.sendCursorPosition()
+      container.needslines = true
+
+proc copyCursorPos*(container, c2: Container) =
+  container.startpos = some(c2.pos)
+  container.hasstart = true
+
+proc cursorNextLink*(container: Container) {.jsfunc.} =
+  container.iface
+    .findNextLink(container.cursorx, container.cursory)
+    .then(proc(res: tuple[x, y: int]) =
+      if res.x > -1 and res.y != -1:
+        container.setCursorXY(res.x, res.y))
+
+proc cursorPrevLink*(container: Container) {.jsfunc.} =
+  container.iface
+    .findPrevLink(container.cursorx, container.cursory)
+    .then(proc(res: tuple[x, y: int]) =
+      if res.x > -1 and res.y != -1:
+        container.setCursorXY(res.x, res.y))
+
+proc clearSearchHighlights*(container: Container) =
+  for i in countdown(container.highlights.high, 0):
+    if container.highlights[i].clear:
+      container.highlights.del(i)
+
+proc onMatch(container: Container, res: BufferMatch, refresh: bool) =
+  if res.success:
+    container.setCursorXY(res.x, res.y, refresh)
+    if container.hlon:
+      container.clearSearchHighlights()
+      let ex = res.x + res.str.twidth(res.x) - 1
+      let hl = Highlight(x: res.x, y: res.y, endx: ex, endy: res.y, clear: true)
+      container.highlights.add(hl)
+      container.triggerEvent(UPDATE)
+      container.hlon = false
+  elif container.hlon:
+    container.clearSearchHighlights()
+    container.triggerEvent(UPDATE)
+    container.needslines = true
+    container.hlon = false
+
+proc cursorNextMatch*(container: Container, regex: Regex, wrap, refresh: bool):
+    EmptyPromise {.discardable.} =
+  if container.select.open:
+    container.select.cursorNextMatch(regex, wrap)
+    return newResolvedPromise()
+  else:
+    return container.iface
+      .findNextMatch(regex, container.cursorx, container.cursory, wrap)
+      .then(proc(res: BufferMatch) =
+        container.onMatch(res, refresh))
+
+proc cursorPrevMatch*(container: Container, regex: Regex, wrap, refresh: bool):
+    EmptyPromise {.discardable.} =
+  if container.select.open:
+    container.select.cursorPrevMatch(regex, wrap)
+    return newResolvedPromise()
+  else:
+    return container.iface
+      .findPrevMatch(regex, container.cursorx, container.cursory, wrap)
+      .then(proc(res: BufferMatch) =
+        container.onMatch(res, refresh))
+
+proc setLoadInfo(container: Container, msg: string) =
+  container.loadinfo = msg
+  container.triggerEvent(STATUS)
+
+#TODO TODO TODO this should be called with a timeout.
+proc onload*(container: Container, res: LoadResult) =
+  if container.canceled:
+    container.setLoadInfo("")
+    #TODO we wouldn't need the then part if we had incremental rendering of
+    # HTML.
+    container.iface.cancel().then(proc(lines: int) =
+      container.setNumLines(lines)
+      container.needslines = true)
+  else:
+    if res.bytes == -1 or res.atend:
+      container.setLoadInfo("")
+    elif not res.atend:
+      container.setLoadInfo(convert_size(res.bytes) & " loaded")
+    if res.lines > container.numLines:
+      container.setNumLines(res.lines)
+      container.triggerEvent(STATUS)
+      container.needslines = true
+    if not res.atend:
+      discard container.iface.load().then(proc(res: LoadResult) =
+        container.onload(res))
+    else:
+      container.iface.getTitle().then(proc(title: string): auto =
+        if title != "":
+          container.title = title
+          container.triggerEvent(TITLE)
+        return container.iface.render()
+      ).then(proc(lines: int): auto =
+        container.setNumLines(lines, true)
+        container.needslines = true
+        container.triggerEvent(LOADED)
+        if not container.hasstart and container.source.location.anchor != "":
+          return container.iface.gotoAnchor()
+      ).then(proc(res: tuple[x, y: int]) =
+        if res.x != -1 and res.y != -1:
+          container.setCursorXY(res.x, res.y))
+
+proc load(container: Container) =
+  container.setLoadInfo("Connecting to " & container.location.host & "...")
+  container.iface.connect().then(proc(res: ConnectResult) =
+    let info = container.loadinfo
+    if not res.invalid:
+      container.code = res.code
+      if res.code == 0:
+        container.triggerEvent(SUCCESS)
+        # accept cookies
+        if res.cookies.len > 0 and container.config.cookiejar != nil:
+          container.config.cookiejar.add(res.cookies)
+        if res.referrerpolicy.isSome and container.config.referer_from:
+          container.config.referrerpolicy = res.referrerpolicy.get
+        container.setLoadInfo("Connected to " & $container.source.location & ". Downloading...")
+        if res.needsAuth:
+          container.triggerEvent(NEEDS_AUTH)
+        if res.redirect != nil:
+          container.triggerEvent(ContainerEvent(t: REDIRECT, request: res.redirect))
+        container.source.charset = res.charset
+        if res.contentType == "application/octet-stream":
+          let contentType = guessContentType(container.location.pathname,
+            "application/octet-stream", container.config.mimeTypes)
+          if contentType != "application/octet-stream":
+            container.iface.setContentType(contentType)
+          container.source.contenttype = some(contentType)
+        elif res.contentType != "":
+          container.source.contenttype = some(res.contentType)
+        container.triggerEvent(CHECK_MAILCAP)
+      else:
+        container.setLoadInfo("")
+        container.triggerEvent(FAIL)
+    else:
+      container.setLoadInfo(info)
+  )
+
+proc startload*(container: Container) =
+  container.iface.load()
+    .then(proc(res: tuple[atend: bool, lines, bytes: int]) =
+      container.onload(res))
+
+proc connect2*(container: Container): EmptyPromise =
+  return container.iface.connect2()
+
+proc redirectToFd*(container: Container, fdin: FileHandle, wait: bool):
+    EmptyPromise =
+  return container.iface.redirectToFd(fdin, wait)
+
+proc readFromFd*(container: Container, fdout: FileHandle, ishtml: bool):
+    EmptyPromise =
+  return container.iface.readFromFd(fdout, ishtml)
+
+proc quit*(container: Container) =
+  container.triggerEvent(QUIT)
+
+proc cancel*(container: Container) {.jsfunc.} =
+  if container.select.open:
+    container.select.cancel()
+  else:
+    container.canceled = true
+    container.alert("Canceled loading")
+
+proc findAnchor*(container: Container, anchor: string) =
+  container.iface.findAnchor(anchor).then(proc(found: bool) =
+    if found:
+      container.triggerEvent(ContainerEvent(t: ANCHOR, anchor: anchor))
+    else:
+      container.triggerEvent(NO_ANCHOR))
+
+proc readCanceled*(container: Container) =
+  container.iface.readCanceled().then(proc(repaint: bool) =
+    if repaint:
+      container.needslines = true)
+
+proc readSuccess*(container: Container, s: string) =
+  container.iface.readSuccess(s).then(proc(res: ReadSuccessResult) =
+    if res.repaint:
+      container.needslines = true
+    if res.open.isSome:
+      container.triggerEvent(ContainerEvent(t: OPEN, request: res.open.get)))
+
+proc reshape(container: Container): EmptyPromise {.discardable, jsfunc.} =
+  return container.iface.render().then(proc(lines: int): auto =
+    container.setNumLines(lines)
+    return container.requestLines())
+
+proc pipeBuffer*(container, pipeTo: Container) =
+  container.iface.getSource().then(proc() =
+    pipeTo.load() #TODO do not load if pipeTo is killed first?
+  )
+
+proc onclick(container: Container, res: ClickResult)
+
+proc displaySelect(container: Container, selectResult: SelectResult) =
+  let submitSelect = proc(selected: seq[int]) =
+    container.iface.select(selected).then(proc(res: ClickResult) =
+      container.onclick(res))
+  container.select.initSelect(selectResult, container.acursorx,
+    container.acursory, container.height, submitSelect)
+  container.triggerEvent(UPDATE)
+
+proc onclick(container: Container, res: ClickResult) =
+  if res.repaint:
+    container.needslines = true
+  if res.open.isSome:
+    container.triggerEvent(ContainerEvent(t: OPEN, request: res.open.get))
+  if res.select.isSome:
+    container.displaySelect(res.select.get)
+  if res.readline.isSome:
+    let rl = res.readline.get
+    let event = if rl.area:
+      ContainerEvent(
+        t: READ_AREA,
+        tvalue: rl.value
+      )
+    else:
+      ContainerEvent(
+        t: READ_LINE,
+        prompt: rl.prompt,
+        value: rl.value,
+        password: rl.hide
+      )
+    container.triggerEvent(event)
+
+proc click(container: Container) {.jsfunc.} =
+  if container.select.open:
+    container.select.click()
+  else:
+    container.iface.click(container.cursorx, container.cursory)
+      .then(proc(res: ClickResult) = container.onclick(res))
+
+proc windowChange*(container: Container, attrs: WindowAttributes) =
+  if attrs.width != container.width or attrs.height - 1 != container.height:
+    container.width = attrs.width
+    container.height = attrs.height - 1
+    container.iface.windowChange(attrs).then(proc(): auto =
+      container.needslines = true
+      return container.iface.render()
+    ).then(proc(lines: int) =
+      if lines != container.numLines:
+        container.setNumLines(lines, true)
+      container.needslines = true)
+
+proc peek(container: Container) {.jsfunc.} =
+  container.alert($container.source.location)
+
+proc clearHover*(container: Container) =
+  container.lastpeek = low(HoverType)
+
+proc peekCursor(container: Container) {.jsfunc.} =
+  var p = container.lastpeek
+  while true:
+    if container.hovertext[p] != "":
+      container.alert($p & ": " & container.hovertext[p])
+      break
+    if p < high(HoverType):
+      inc p
+    else:
+      p = low(HoverType)
+    if p == container.lastpeek: break
+  if container.lastpeek < high(HoverType):
+    inc container.lastpeek
+  else:
+    container.lastpeek = low(HoverType)
+
+proc handleCommand(container: Container) =
+  var packetid, len: int
+  container.iface.stream.sread(len)
+  container.iface.stream.sread(packetid)
+  container.iface.resolve(packetid, len - slen(packetid))
+
+proc setStream*(container: Container, stream: Stream) =
+  container.iface = newBufferInterface(stream)
+  if container.source.t == LOAD_PIPE:
+    container.iface.passFd(container.source.fd).then(proc() =
+      discard close(container.source.fd))
+    stream.flush()
+  container.load()
+
+proc onreadline(container: Container, w: Slice[int], handle: (proc(line: SimpleFlexibleLine)), res: GetLinesResult) =
+  for line in res.lines:
+    handle(line)
+  if res.numLines > w.b + 1:
+    var w = w
+    w.a += 24
+    w.b += 24
+    container.iface.getLines(w).then(proc(res: GetLinesResult) =
+      container.onreadline(w, handle, res))
+  else:
+    container.setNumLines(res.numLines, true)
+
+# Synchronously read all lines in the buffer.
+proc readLines*(container: Container, handle: (proc(line: SimpleFlexibleLine))) =
+  if container.code == 0:
+    # load succeded
+    let w = 0 .. 23
+    container.iface.getLines(w).then(proc(res: GetLinesResult) =
+      container.onreadline(w, handle, res))
+    while container.iface.hasPromises:
+      # fulfill all promises
+      container.handleCommand()
+
+proc drawLines*(container: Container, display: var FixedGrid,
+    hlcolor: CellColor) =
+  var r: Rune
+  var by = 0
+  let endy = min(container.fromy + display.height, container.numLines)
+  for line in container.ilines(container.fromy ..< endy):
+    var w = 0 # width of the row so far
+    var i = 0 # byte in line.str
+    # Skip cells till fromx.
+    while w < container.fromx and i < line.str.len:
+      fastRuneAt(line.str, i, r)
+      w += r.twidth(w)
+    let dls = by * display.width # starting position of row in display
+    # Fill in the gap in case we skipped more cells than fromx mandates (i.e.
+    # we encountered a double-width character.)
+    var k = 0
+    if w > container.fromx:
+      while k < w - container.fromx:
+        display[dls + k].str &= ' '
+        inc k
+    var cf = line.findFormat(w)
+    var nf = line.findNextFormat(w)
+    let startw = w # save this for later
+    # Now fill in the visible part of the row.
+    while i < line.str.len:
+      let pw = w
+      fastRuneAt(line.str, i, r)
+      let rw = r.twidth(w)
+      w += rw
+      if w > container.fromx + display.width:
+        break # die on exceeding the width limit
+      if nf.pos != -1 and nf.pos <= pw:
+        cf = nf
+        nf = line.findNextFormat(pw)
+      if cf.pos != -1:
+        display[dls + k].format = cf.format
+      if r == Rune('\t'):
+        # Needs to be replaced with spaces, otherwise bgcolor isn't displayed.
+        let tk = k + rw
+        while k < tk:
+          display[dls + k].str &= ' '
+          inc k
+      else:
+        display[dls + k].str &= r
+        k += rw
+    # Finally, override cell formatting for highlighted cells.
+    let hls = container.findHighlights(container.fromy + by)
+    let aw = container.width - (startw - container.fromx) # actual width
+    for hl in hls:
+      let area = hl.colorArea(container.fromy + by, startw .. startw + aw)
+      for i in area:
+        var hlformat = display[dls + i - startw].format
+        hlformat.bgcolor = hlcolor
+        display[dls + i - startw].format = hlformat
+    inc by
+
+proc handleEvent*(container: Container) =
+  container.handleCommand()
+  if container.needslines:
+    container.requestLines()
+    container.needslines = false
+
+proc addContainerModule*(ctx: JSContext) =
+  ctx.registerType(Container, name = "Buffer")
diff --git a/src/local/pager.nim b/src/local/pager.nim
new file mode 100644
index 00000000..8bb1172f
--- /dev/null
+++ b/src/local/pager.nim
@@ -0,0 +1,1189 @@
+import deques
+import net
+import options
+import os
+import osproc
+import streams
+import tables
+import unicode
+
+when defined(posix):
+  import posix
+
+import buffer/cell
+import config/config
+import config/mailcap
+import config/mimetypes
+import display/term
+import extern/editor
+import extern/runproc
+import io/connecterror
+import io/lineedit
+import io/loader
+import io/promise
+import io/request
+import io/tempfile
+import io/window
+import ips/forkserver
+import ips/socketstream
+import js/dict
+import js/javascript
+import js/regex
+import js/tojs
+import local/container
+import local/select
+import types/buffersource
+import types/color
+import types/cookie
+import types/url
+import utils/opt
+import utils/twtstr
+
+import chakasu/charset
+
+type
+  LineMode* = enum
+    NO_LINEMODE, LOCATION, USERNAME, PASSWORD, COMMAND, BUFFER, SEARCH_F,
+    SEARCH_B, ISEARCH_F, ISEARCH_B, GOTO_LINE
+
+  Pager* = ref object
+    alerton: bool
+    alerts: seq[string]
+    askcursor: int
+    askpromise*: Promise[bool]
+    askprompt: string
+    commandMode* {.jsget.}: bool
+    config: Config
+    container*: Container
+    cookiejars: Table[string, CookieJar]
+    display: FixedGrid
+    forkserver: ForkServer
+    iregex: Result[Regex, string]
+    isearchpromise: EmptyPromise
+    lineedit*: Option[LineEdit]
+    linehist: array[LineMode, LineHistory]
+    linemode*: LineMode
+    mailcap: Mailcap
+    mainproc: Pid
+    mimeTypes: MimeTypes
+    numload*: int
+    omnirules: seq[OmniRule]
+    procmap*: Table[Pid, Container]
+    proxy: URL
+    redraw*: bool
+    regex: Opt[Regex]
+    reverseSearch: bool
+    scommand*: string
+    siteconf: seq[SiteConfig]
+    statusgrid*: FixedGrid
+    term*: Terminal
+    tty: File
+    unreg*: seq[(Pid, SocketStream)]
+    username: string
+
+jsDestructor(Pager)
+
+func attrs(pager: Pager): WindowAttributes = pager.term.attrs
+
+func getRoot(container: Container): Container =
+  var c = container
+  while c.parent != nil: c = c.parent
+  return c
+
+iterator all_children(parent: Container): Container {.inline.} =
+  var stack = newSeqOfCap[Container](parent.children.len)
+  for i in countdown(parent.children.high, 0):
+    stack.add(parent.children[i])
+  while stack.len > 0:
+    let c = stack.pop()
+    yield c
+    for i in countdown(c.children.high, 0):
+      stack.add(c.children[i])
+
+iterator containers*(pager: Pager): Container {.inline.} =
+  if pager.container != nil:
+    let root = getRoot(pager.container)
+    yield root
+    for c in root.all_children:
+      yield c
+
+proc setContainer*(pager: Pager, c: Container) {.jsfunc.} =
+  pager.container = c
+  pager.redraw = true
+  if c != nil:
+    pager.term.setTitle(c.getTitle())
+
+proc hasprop(ctx: JSContext, pager: Pager, s: string): bool {.jshasprop.} =
+  if pager.container != nil:
+    let cval = toJS(ctx, pager.container)
+    let val = JS_GetPropertyStr(ctx, cval, s)
+    if val != JS_UNDEFINED:
+      result = true
+    JS_FreeValue(ctx, val)
+
+proc reflect(ctx: JSContext, this_val: JSValue, argc: cint, argv: ptr JSValue,
+             magic: cint, func_data: ptr JSValue): JSValue {.cdecl.} =
+  let fun = cast[ptr JSValue](cast[int](func_data) + sizeof(JSValue))[]
+  return JS_Call(ctx, fun, func_data[], argc, argv)
+
+proc getter(ctx: JSContext, pager: Pager, s: string): Option[JSValue]
+    {.jsgetprop.} =
+  if pager.container != nil:
+    let cval = toJS(ctx, pager.container)
+    let val = JS_GetPropertyStr(ctx, cval, s)
+    if val != JS_UNDEFINED:
+      if JS_IsFunction(ctx, val):
+        var func_data = @[cval, val]
+        let fun = JS_NewCFunctionData(ctx, reflect, 1, 0, 2, addr func_data[0])
+        return some(fun)
+      return some(val)
+
+proc searchNext(pager: Pager) {.jsfunc.} =
+  if pager.regex.isSome:
+    let wrap = pager.config.search.wrap
+    if not pager.reverseSearch:
+      pager.container.cursorNextMatch(pager.regex.get, wrap, true)
+    else:
+      pager.container.cursorPrevMatch(pager.regex.get, wrap, true)
+
+proc searchPrev(pager: Pager) {.jsfunc.} =
+  if pager.regex.isSome:
+    let wrap = pager.config.search.wrap
+    if not pager.reverseSearch:
+      pager.container.cursorPrevMatch(pager.regex.get, wrap, true)
+    else:
+      pager.container.cursorNextMatch(pager.regex.get, wrap, true)
+
+proc getLineHist(pager: Pager, mode: LineMode): LineHistory =
+  if pager.linehist[mode] == nil:
+    pager.linehist[mode] = newLineHistory()
+  return pager.linehist[mode]
+
+proc setLineEdit(pager: Pager, prompt: string, mode: LineMode, current = "", hide = false) =
+  pager.lineedit = some(readLine(prompt, pager.attrs.width, current = current, term = pager.term, hide = hide, hist = pager.getLineHist(mode)))
+  pager.linemode = mode
+
+proc clearLineEdit(pager: Pager) =
+  pager.lineedit = none(LineEdit)
+
+proc searchForward(pager: Pager) {.jsfunc.} =
+  pager.setLineEdit("/", SEARCH_F)
+
+proc searchBackward(pager: Pager) {.jsfunc.} =
+  pager.setLineEdit("?", SEARCH_B)
+
+proc isearchForward(pager: Pager) {.jsfunc.} =
+  pager.container.pushCursorPos()
+  pager.isearchpromise = newResolvedPromise()
+  pager.setLineEdit("/", ISEARCH_F)
+
+proc isearchBackward(pager: Pager) {.jsfunc.} =
+  pager.container.pushCursorPos()
+  pager.isearchpromise = newResolvedPromise()
+  pager.setLineEdit("?", ISEARCH_B)
+
+proc gotoLine[T: string|int](pager: Pager, s: T = "") {.jsfunc.} =
+  when s is string:
+    if s == "":
+      pager.setLineEdit("Goto line: ", GOTO_LINE)
+      return
+  pager.container.gotoLine(s)
+
+proc alert*(pager: Pager, msg: string)
+
+proc newPager*(config: Config, attrs: WindowAttributes,
+    forkserver: ForkServer, mainproc: Pid, ctx: JSContext): Pager =
+  let pager = Pager(
+    config: config,
+    display: newFixedGrid(attrs.width, attrs.height - 1),
+    forkserver: forkserver,
+    mainproc: mainproc,
+    omnirules: config.getOmniRules(ctx),
+    proxy: config.getProxy(),
+    siteconf: config.getSiteConfig(ctx),
+    statusgrid: newFixedGrid(attrs.width),
+    term: newTerminal(stdout, config, attrs),
+    mimeTypes: config.getMimeTypes()
+  )
+  let (mcap, errs) = config.getMailcap()
+  pager.mailcap = mcap
+  for err in errs:
+    pager.alert("Error reading mailcap: " & err)
+  return pager
+
+proc launchPager*(pager: Pager, tty: File) =
+  pager.tty = tty
+  pager.term.start(tty)
+
+proc dumpAlerts*(pager: Pager) =
+  for msg in pager.alerts:
+    stderr.write("cha: " & msg & '\n')
+
+proc quit*(pager: Pager, code = 0) =
+  pager.term.quit()
+  pager.dumpAlerts()
+
+proc clearDisplay(pager: Pager) =
+  pager.display = newFixedGrid(pager.display.width, pager.display.height)
+
+proc buffer(pager: Pager): Container {.jsfget, inline.} = pager.container
+
+proc refreshDisplay(pager: Pager, container = pager.container) =
+  pager.clearDisplay()
+  container.drawLines(pager.display,
+    cellColor(pager.config.display.highlight_color))
+
+# Note: this function doesn't work if start < i of last written char
+proc writeStatusMessage(pager: Pager, str: string,
+                        format: Format = newFormat(), start = 0,
+                        maxwidth = -1, clip = '$'): int {.discardable.} =
+  var maxwidth = maxwidth
+  if maxwidth == -1:
+    maxwidth = pager.statusgrid.len
+  var i = start
+  let e = min(start + maxwidth, pager.statusgrid.width)
+  if i >= e:
+    return i
+  for r in str.runes:
+    let pi = i
+    i += r.twidth(i)
+    if i >= e:
+      if i >= pager.statusgrid.width:
+        i = pi
+      pager.statusgrid[i].format = format
+      pager.statusgrid[i].str = $clip
+      inc i
+      break
+    if r.isControlChar():
+      pager.statusgrid[pi].str = "^" & getControlLetter(char(r))
+    else:
+      pager.statusgrid[pi].str = $r
+    pager.statusgrid[pi].format = format
+  result = i
+  var def = newFormat()
+  while i < e:
+    pager.statusgrid[i].str = ""
+    pager.statusgrid[i].format = def
+    inc i
+
+# Note: should only be called directly after user interaction.
+proc refreshStatusMsg*(pager: Pager) =
+  let container = pager.container
+  if container == nil: return
+  if pager.tty == nil: return
+  if pager.askpromise != nil: return
+  if container.loadinfo != "":
+    pager.alerton = false
+    pager.writeStatusMessage(container.loadinfo)
+  elif pager.alerts.len > 0:
+    pager.alerton = true
+    pager.writeStatusMessage(pager.alerts[0])
+    pager.alerts.delete(0)
+  else:
+    var format = newFormat()
+    format.reverse = true
+    pager.alerton = false
+    container.clearHover()
+    var msg = $(container.cursory + 1) & "/" & $container.numLines & " (" &
+              $container.atPercentOf() & "%)"
+    let mw = pager.writeStatusMessage(msg, format)
+    let title = " <" & container.getTitle() & ">"
+    let hover = container.getHoverText()
+    if hover.len == 0:
+      pager.writeStatusMessage(title, format, mw)
+    else:
+      let hover2 = " " & hover
+      let maxwidth = pager.statusgrid.width - hover2.width() - mw
+      let tw = pager.writeStatusMessage(title, format, mw, maxwidth, '>')
+      pager.writeStatusMessage(hover2, format, tw)
+
+# Call refreshStatusMsg if no alert is being displayed on the screen.
+proc showAlerts*(pager: Pager) =
+  if not pager.alerton:
+    pager.refreshStatusMsg()
+
+proc drawBuffer*(pager: Pager, container: Container, ostream: Stream) =
+  var format = newFormat()
+  container.readLines(proc(line: SimpleFlexibleLine) =
+    if line.formats.len == 0:
+      ostream.write(line.str & "\n")
+    else:
+      var x = 0
+      var w = 0
+      var i = 0
+      var s = ""
+      for f in line.formats:
+        var outstr = ""
+        while x < f.pos:
+          var r: Rune
+          fastRuneAt(line.str, i, r)
+          outstr &= r
+          x += r.width()
+        s &= pager.term.processOutputString(outstr, w)
+        s &= pager.term.processFormat(format, f.format)
+      if i < line.str.len:
+        s &= pager.term.processOutputString(line.str.substr(i), w)
+      s &= pager.term.processFormat(format, newFormat()) & "\n"
+      ostream.write(s))
+  ostream.flush()
+
+proc redraw(pager: Pager) {.jsfunc.} =
+  pager.redraw = true
+  pager.term.clearCanvas()
+
+proc draw*(pager: Pager) =
+  let container = pager.container
+  if container == nil: return
+  pager.term.hideCursor()
+  if pager.redraw:
+    pager.refreshDisplay()
+    pager.term.writeGrid(pager.display)
+  if container.select.open and container.select.redraw:
+    container.select.drawSelect(pager.display)
+    pager.term.writeGrid(pager.display)
+  if pager.askpromise != nil:
+    discard
+  elif pager.lineedit.isSome:
+    if pager.lineedit.get.isnew:
+      #TODO hack
+      # make term notice that it must redraw when status is restored
+      let x = newFixedGrid(pager.attrs.width)
+      pager.term.writeGrid(x, 0, pager.attrs.height - 1)
+  else:
+    pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1)
+  pager.term.outputGrid()
+  if pager.askpromise != nil:
+    pager.term.setCursor(pager.askcursor, pager.attrs.height - 1)
+  elif pager.lineedit.isSome:
+    if pager.lineedit.get.isnew:
+      #TODO hack
+      pager.term.setCursor(0, pager.attrs.height - 1)
+      pager.lineedit.get.drawPrompt()
+      pager.term.setCursor(pager.lineedit.get.getCursorX(), pager.attrs.height - 1)
+      pager.lineedit.get.fullRedraw()
+      pager.lineedit.get.isnew = false
+    pager.term.setCursor(pager.lineedit.get.getCursorX(), pager.attrs.height - 1)
+  elif container.select.open:
+    pager.term.setCursor(container.select.getCursorX(),
+      container.select.getCursorY())
+  else:
+    pager.term.setCursor(pager.container.acursorx, pager.container.acursory)
+  pager.term.showCursor()
+  pager.term.flush()
+  pager.redraw = false
+
+proc writeAskPrompt(pager: Pager) =
+  let yn = " (y/n)"
+  let maxwidth = pager.statusgrid.width - yn.len
+  let i = pager.writeStatusMessage(pager.askprompt, maxwidth = maxwidth)
+  pager.askcursor = pager.writeStatusMessage(yn, start = i)
+  pager.term.writeGrid(pager.statusgrid, 0, pager.attrs.height - 1)
+
+proc ask(pager: Pager, prompt: string): Promise[bool] {.jsfunc.} =
+  pager.askprompt = prompt
+  pager.writeAskPrompt()
+  pager.askpromise = Promise[bool]()
+  return pager.askpromise
+
+proc fulfillAsk*(pager: Pager, y: bool) =
+  pager.askpromise.resolve(y)
+  pager.askpromise = nil
+  pager.askprompt = ""
+
+proc registerContainer*(pager: Pager, container: Container) =
+  pager.procmap[container.process] = container
+
+proc addContainer*(pager: Pager, container: Container) =
+  container.parent = pager.container
+  if pager.container != nil:
+    pager.container.children.insert(container, 0)
+  pager.registerContainer(container)
+  pager.setContainer(container)
+
+proc newBuffer(pager: Pager, bufferConfig: BufferConfig, source: BufferSource,
+    title = "", redirectdepth = 0): Container =
+  return newBuffer(
+    pager.forkserver,
+    pager.mainproc,
+    bufferConfig,
+    source,
+    title,
+    redirectdepth
+  )
+
+proc dupeBuffer(pager: Pager, container: Container, location: URL,
+    contentType = ""): Container =
+  let contentType = if contentType != "":
+    some(contentType)
+  else:
+    container.contenttype
+  let location = if location != nil:
+    location
+  else:
+    container.source.location
+  let source = BufferSource(
+    t: CLONE,
+    location: location,
+    contenttype: contentType,
+    clonepid: container.process,
+  )
+  let pipeTo = pager.newBuffer(container.config, source, container.title)
+  container.pipeBuffer(pipeTo)
+  return pipeTo
+
+proc dupeBuffer(pager: Pager, location: URL = nil) {.jsfunc.} =
+  pager.addContainer(pager.dupeBuffer(pager.container, location))
+
+# The prevBuffer and nextBuffer procedures emulate w3m's PREV and NEXT
+# commands by traversing the container tree in a depth-first order.
+proc prevBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.parent == nil:
+    return false
+  let n = pager.container.parent.children.find(pager.container)
+  assert n != -1, "Container not a child of its parent"
+  if n > 0:
+    var container = pager.container.parent.children[n - 1]
+    while container.children.len > 0:
+      container = container.children[^1]
+    pager.setContainer(container)
+  else:
+    pager.setContainer(pager.container.parent)
+  return true
+
+proc nextBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.children.len > 0:
+    pager.setContainer(pager.container.children[0])
+    return true
+  var container = pager.container
+  while container.parent != nil:
+    let n = container.parent.children.find(container)
+    assert n != -1, "Container not a child of its parent"
+    if n < container.parent.children.high:
+      pager.setContainer(container.parent.children[n + 1])
+      return true
+    container = container.parent
+  return false
+
+proc parentBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.parent == nil:
+    return false
+  pager.setContainer(pager.container.parent)
+  return true
+
+proc prevSiblingBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.parent == nil:
+    return false
+  var n = pager.container.parent.children.find(pager.container)
+  assert n != -1, "Container not a child of its parent"
+  if n == 0:
+    n = pager.container.parent.children.len
+  pager.setContainer(pager.container.parent.children[n - 1])
+  return true
+
+proc nextSiblingBuffer(pager: Pager): bool {.jsfunc.} =
+  if pager.container == nil:
+    return false
+  if pager.container.parent == nil:
+    return false
+  var n = pager.container.parent.children.find(pager.container)
+  assert n != -1, "Container not a child of its parent"
+  if n == pager.container.parent.children.high:
+    n = -1
+  pager.setContainer(pager.container.parent.children[n + 1])
+  return true
+
+proc alert*(pager: Pager, msg: string) {.jsfunc.} =
+  pager.alerts.add(msg)
+
+proc deleteContainer(pager: Pager, container: Container) =
+  container.cancel()
+  if container.sourcepair != nil:
+    container.sourcepair.sourcepair = nil
+    container.sourcepair = nil
+  if container.parent != nil:
+    let parent = container.parent
+    let n = parent.children.find(container)
+    assert n != -1, "Container not a child of its parent"
+    for i in countdown(container.children.high, 0):
+      let child = container.children[i]
+      child.parent = container.parent
+      parent.children.insert(child, n + 1)
+    parent.children.delete(n)
+    if container == pager.container:
+      if n == 0:
+        pager.setContainer(parent)
+      else:
+        pager.setContainer(parent.children[n - 1])
+  elif container.children.len > 0:
+    let parent = container.children[0]
+    parent.parent = nil
+    for i in 1..container.children.high:
+      container.children[i].parent = parent
+      parent.children.add(container.children[i])
+    if container == pager.container:
+      pager.setContainer(parent)
+  else:
+    for child in container.children:
+      child.parent = nil
+    if container == pager.container:
+      pager.setContainer(nil)
+  container.parent = nil
+  container.children.setLen(0)
+  pager.unreg.add((container.process, SocketStream(container.iface.stream)))
+  pager.forkserver.removeChild(container.process)
+
+proc discardBuffer(pager: Pager, container = none(Container)) {.jsfunc.} =
+  let c = container.get(pager.container)
+  if c == nil or c.parent == nil and c.children.len == 0:
+    pager.alert("Cannot discard last buffer!")
+  else:
+    pager.deleteContainer(c)
+
+proc discardTree(pager: Pager, container = none(Container)) {.jsfunc.} =
+  let container = container.get(pager.container)
+  if container != nil:
+    for c in container.all_children:
+      pager.deleteContainer(c)
+  else:
+    pager.alert("Buffer has no children!")
+
+proc toggleSource(pager: Pager) {.jsfunc.} =
+  if pager.container.sourcepair != nil:
+    pager.setContainer(pager.container.sourcepair)
+  else:
+    let contenttype = if pager.container.contentType.get("") == "text/html":
+      "text/plain"
+    else:
+      "text/html"
+    let container = pager.dupeBuffer(pager.container, nil, contenttype)
+    container.sourcepair = pager.container
+    pager.container.sourcepair = container
+    pager.addContainer(container)
+
+proc windowChange*(pager: Pager, attrs: WindowAttributes) =
+  pager.term.windowChange(attrs)
+  pager.display = newFixedGrid(attrs.width, attrs.height - 1)
+  pager.statusgrid = newFixedGrid(attrs.width)
+  for container in pager.containers:
+    container.windowChange(attrs)
+  if pager.askprompt != "":
+    pager.writeAskPrompt()
+  pager.showAlerts()
+
+# Apply siteconf settings to a request.
+# Note that this may modify the URL passed.
+proc applySiteconf(pager: Pager, url: var URL): BufferConfig =
+  let host = url.host
+  var referer_from: bool
+  var cookiejar: CookieJar
+  var headers = pager.config.getDefaultHeaders()
+  var scripting: bool
+  var images: bool
+  var charsets = pager.config.encoding.document_charset
+  var userstyle = pager.config.css.stylesheet
+  var proxy = pager.proxy
+  let mimeTypes = pager.mimeTypes
+  for sc in pager.siteconf:
+    if sc.url.isSome and not sc.url.get.match($url):
+      continue
+    elif sc.host.isSome and not sc.host.get.match(host):
+      continue
+    if sc.rewrite_url != nil:
+      let s = sc.rewrite_url(url)
+      if s.isSome and s.get != nil:
+        url = s.get
+    if sc.cookie.isSome:
+      if sc.cookie.get:
+        # host/url might have changed by now
+        let jarid = sc.share_cookiejar.get(url.host)
+        if jarid notin pager.cookiejars:
+          pager.cookiejars[jarid] = newCookieJar(url,
+            sc.third_party_cookie)
+        cookiejar = pager.cookiejars[jarid]
+      else:
+        cookiejar = nil # override
+    if sc.scripting.isSome:
+      scripting = sc.scripting.get
+    if sc.referer_from.isSome:
+      referer_from = sc.referer_from.get
+    if sc.document_charset.len > 0:
+      charsets = sc.document_charset
+    if sc.images.isSome:
+      images = sc.images.get
+    if sc.stylesheet.isSome:
+      userstyle &= "\n"
+      userstyle &= sc.stylesheet.get
+    if sc.proxy.isSome:
+      proxy = sc.proxy.get
+  return pager.config.getBufferConfig(url, cookiejar, headers, referer_from,
+    scripting, charsets, images, userstyle, proxy, mimeTypes)
+
+# Load request in a new buffer.
+proc gotoURL(pager: Pager, request: Request, prevurl = none(URL),
+    ctype = none(string), cs = CHARSET_UNKNOWN, replace: Container = nil,
+    redirectdepth = 0, referrer: Container = nil) =
+  if referrer != nil and referrer.config.referer_from:
+    request.referer = referrer.source.location
+  var bufferconfig = pager.applySiteconf(request.url)
+  if prevurl.isnone or not prevurl.get.equals(request.url, true) or
+      request.url.hash == "" or request.httpmethod != HTTP_GET:
+    # Basically, we want to reload the page *only* when
+    # a) we force a reload (by setting prevurl to none)
+    # b) or the new URL isn't just the old URL + an anchor
+    # I think this makes navigation pretty natural, or at least very close to
+    # what other browsers do. Still, it would be nice if we got some visual
+    # feedback on what is actually going to happen when typing a URL; TODO.
+    let source = BufferSource(
+      t: LOAD_REQUEST,
+      request: request,
+      contenttype: ctype,
+      charset: cs,
+      location: request.url
+    )
+    if referrer != nil:
+      bufferconfig.referrerpolicy = referrer.config.referrerpolicy
+    let container = pager.newBuffer(
+      bufferconfig,
+      source,
+      redirectdepth = redirectdepth
+    )
+    if replace != nil:
+      container.replace = replace
+      container.copyCursorPos(container.replace)
+    pager.addContainer(container)
+    inc pager.numload
+  else:
+    pager.container.findAnchor(request.url.anchor)
+
+proc omniRewrite(pager: Pager, s: string): string =
+  for rule in pager.omnirules:
+    if rule.match.match(s):
+      let sub = rule.substitute_url(s)
+      if sub.isSome:
+        return sub.get
+      else:
+        pager.alert("Error in substitution of rule " & rule.match.buf & " for " & s)
+  return s
+
+# When the user has passed a partial URL as an argument, they might've meant
+# either:
+# * file://$PWD/<file>
+# * https://<url>
+# So we attempt to load both, and see what works.
+proc loadURL*(pager: Pager, url: string, ctype = none(string),
+    cs = CHARSET_UNKNOWN) =
+  let url0 = pager.omniRewrite(url)
+  let url = if url[0] == '~': expandPath(url0) else: url0
+  let firstparse = parseURL(url)
+  if firstparse.issome:
+    let prev = if pager.container != nil:
+      some(pager.container.source.location)
+    else:
+      none(URL)
+    pager.gotoURL(newRequest(firstparse.get), prev, ctype, cs)
+    return
+  var urls: seq[URL]
+  if pager.config.network.prepend_https and url[0] != '/':
+    let pageurl = parseURL("https://" & url)
+    if pageurl.isSome: # attempt to load remote page
+      urls.add(pageurl.get)
+  let cdir = parseURL("file://" & percentEncode(getCurrentDir(), LocalPathPercentEncodeSet) & DirSep)
+  let localurl = percentEncode(url, LocalPathPercentEncodeSet)
+  let newurl = parseURL(localurl, cdir)
+  if newurl.isSome:
+    urls.add(newurl.get) # attempt to load local file
+  if urls.len == 0:
+    pager.alert("Invalid URL " & url)
+  else:
+    let prevc = pager.container
+    pager.gotoURL(newRequest(urls.pop()), ctype = ctype, cs = cs)
+    if pager.container != prevc:
+      pager.container.retry = urls
+
+proc readPipe0*(pager: Pager, ctype: Option[string], cs: Charset,
+    fd: FileHandle, location: Option[URL], title: string): Container =
+  var location = location.get(newURL("file://-").get)
+  let bufferconfig = pager.applySiteconf(location)
+  let source = BufferSource(
+    t: LOAD_PIPE,
+    fd: fd,
+    contenttype: some(ctype.get("text/plain")),
+    charset: cs,
+    location: location
+  )
+  return pager.newBuffer(bufferconfig, source, title = title)
+
+proc readPipe*(pager: Pager, ctype: Option[string], cs: Charset,
+    fd: FileHandle) =
+  let container = pager.readPipe0(ctype, cs, fd, none(URL), "*pipe*")
+  pager.addContainer(container)
+
+proc command(pager: Pager) {.jsfunc.} =
+  pager.setLineEdit("COMMAND: ", COMMAND)
+
+proc commandMode(pager: Pager, val: bool) {.jsfset.} =
+  pager.commandMode = val
+  if val:
+    pager.command()
+
+proc checkRegex(pager: Pager, regex: Result[Regex, string]): Opt[Regex] =
+  if regex.isErr:
+    pager.alert("Invalid regex: " & regex.error)
+    return err()
+  return ok(regex.get)
+
+proc updateReadLineISearch(pager: Pager, linemode: LineMode) =
+  let lineedit = pager.lineedit.get
+  pager.isearchpromise = pager.isearchpromise.then(proc(): EmptyPromise =
+    case lineedit.state
+    of CANCEL:
+      pager.iregex.err()
+      pager.container.popCursorPos()
+      pager.container.clearSearchHighlights()
+      pager.redraw = true
+      pager.isearchpromise = nil
+    of EDIT:
+      let x = $lineedit.news
+      if x != "": pager.iregex = compileSearchRegex(x)
+      pager.container.popCursorPos(true)
+      pager.container.pushCursorPos()
+      if pager.iregex.isSome:
+        pager.container.hlon = true
+        let wrap = pager.config.search.wrap
+        return if linemode == ISEARCH_F:
+          pager.container.cursorNextMatch(pager.iregex.get, wrap, false)
+        else:
+          pager.container.cursorPrevMatch(pager.iregex.get, wrap, false)
+    of FINISH:
+      pager.regex = pager.checkRegex(pager.iregex)
+      pager.reverseSearch = linemode == ISEARCH_B
+      pager.container.clearSearchHighlights()
+      pager.container.sendCursorPosition()
+      pager.redraw = true
+      pager.isearchpromise = nil
+  )
+
+proc updateReadLine*(pager: Pager) =
+  let lineedit = pager.lineedit.get
+  template s: string = $lineedit.news
+  if pager.linemode in {ISEARCH_F, ISEARCH_B}:
+    pager.updateReadLineISearch(pager.linemode)
+  else:
+    case lineedit.state
+    of EDIT: return
+    of FINISH:
+      case pager.linemode
+      of LOCATION: pager.loadURL(s)
+      of USERNAME:
+        pager.username = s
+        pager.setLineEdit("Password: ", PASSWORD, hide = true)
+      of PASSWORD:
+        let url = newURL(pager.container.source.location)
+        url.username = pager.username
+        url.password = s
+        pager.username = ""
+        pager.gotoURL(newRequest(url), some(pager.container.source.location), replace = pager.container, referrer = pager.container)
+      of COMMAND:
+        pager.scommand = s
+        if pager.commandMode:
+          pager.command()
+      of BUFFER: pager.container.readSuccess(s)
+      of SEARCH_F, SEARCH_B:
+        let x = s
+        if x != "":
+          pager.regex = pager.checkRegex(compileSearchRegex(x))
+        pager.reverseSearch = pager.linemode == SEARCH_B
+        pager.searchNext()
+      of GOTO_LINE:
+        pager.container.gotoLine(s)
+      else: discard
+    of CANCEL:
+      case pager.linemode
+      of USERNAME: pager.discardBuffer()
+      of PASSWORD:
+        pager.username = ""
+        pager.discardBuffer()
+      of BUFFER: pager.container.readCanceled()
+      of COMMAND: pager.commandMode = false
+      else: discard
+  if lineedit.state in {CANCEL, FINISH}:
+    if pager.lineedit.get == lineedit:
+      pager.clearLineEdit()
+
+# Open a URL prompt and visit the specified URL.
+proc load(pager: Pager, s = "") {.jsfunc.} =
+  if s.len > 0 and s[^1] == '\n':
+    pager.loadURL(s[0..^2])
+  else:
+    var url = s
+    if url == "":
+      url = pager.container.source.location.serialize()
+    pager.setLineEdit("URL: ", LOCATION, url)
+
+# Reload the page in a new buffer, then kill the previous buffer.
+proc reload(pager: Pager) {.jsfunc.} =
+  pager.gotoURL(newRequest(pager.container.source.location), none(URL),
+    pager.container.contenttype, replace = pager.container)
+
+proc setEnvVars(pager: Pager) {.jsfunc.} =
+  try:
+    putEnv("CHA_URL", $pager.container.location)
+    putEnv("CHA_CHARSET", $pager.container.charset)
+  except OSError:
+    pager.alert("Warning: failed to set some environment variables")
+
+#TODO use default values instead...
+type ExternDict = object of JSDict
+  setenv: Opt[bool]
+  suspend: Opt[bool]
+  wait: bool
+
+#TODO this could be handled much better.
+# * suspend, setenv, wait as dict flags
+# * retval as int?
+proc extern(pager: Pager, cmd: string, t = ExternDict()): bool {.jsfunc.} =
+  if t.setenv.get(true):
+    pager.setEnvVars()
+  if t.suspend.get(true):
+    return runProcess(pager.term, cmd, t.wait)
+  else:
+    return runProcess(cmd)
+
+proc authorize(pager: Pager) =
+  pager.setLineEdit("Username: ", USERNAME)
+
+# Pipe input into the mailcap command, then read its output into a buffer.
+# needsterminal is ignored.
+proc runMailcapReadPipe(pager: Pager, container: Container,
+    entry: MailcapEntry, cmd: string): (EmptyPromise, bool) =
+  var pipefd_in: array[2, cint]
+  if pipe(pipefd_in) == -1:
+    raise newException(Defect, "Failed to open pipe.")
+  var pipefd_out: array[2, cint]
+  if pipe(pipefd_out) == -1:
+    raise newException(Defect, "Failed to open pipe.")
+  let pid = fork()
+  if pid == -1:
+    return (nil, false)
+  elif pid == 0:
+    # child process
+    discard close(pipefd_in[1])
+    discard close(pipefd_out[0])
+    stdout.flushFile()
+    discard dup2(pipefd_in[0], stdin.getFileHandle())
+    discard dup2(pipefd_out[1], stdout.getFileHandle())
+    let devnull = open("/dev/null", O_WRONLY)
+    discard dup2(devnull, stderr.getFileHandle())
+    discard close(devnull)
+    discard close(pipefd_in[0])
+    discard close(pipefd_out[1])
+    discard execCmd(cmd)
+    discard close(stdin.getFileHandle())
+    discard close(stdout.getFileHandle())
+    quit(0)
+  # parent
+  discard close(pipefd_in[0])
+  discard close(pipefd_out[1])
+  let fdin = pipefd_in[1]
+  let fdout = pipefd_out[0]
+  let p = container.redirectToFd(fdin, wait = false)
+  let p2 = p.then(proc(): auto =
+    discard close(fdin)
+    let ishtml = HTMLOUTPUT in entry.flags
+    if ishtml:
+      #TODO this is a hack for dupe buffer and should be reconsidered.
+      container.source.contenttype = some("text/html")
+    return container.readFromFd(fdout, ishtml)
+  ).then(proc() =
+    discard close(fdout)
+  )
+  return (p2, true)
+
+# Pipe input into the mailcap command, and discard its output.
+# If needsterminal, leave stderr and stdout open and wait for the process.
+proc runMailcapWritePipe(pager: Pager, container: Container,
+    entry: MailcapEntry, cmd: string): (EmptyPromise, bool) =
+  let needsterminal = NEEDSTERMINAL in entry.flags
+  var pipefd: array[2, cint]
+  if pipe(pipefd) == -1:
+    raise newException(Defect, "Failed to open pipe.")
+  if needsterminal:
+    pager.term.quit()
+  let pid = fork()
+  if pid == -1:
+    return (nil, false)
+  elif pid == 0:
+    # child process
+    discard close(pipefd[1])
+    discard dup2(pipefd[0], stdin.getFileHandle())
+    if not needsterminal:
+      let devnull = open("/dev/null", O_WRONLY)
+      discard dup2(devnull, stdout.getFileHandle())
+      discard dup2(devnull, stderr.getFileHandle())
+      discard close(devnull)
+    discard close(pipefd[0])
+    discard execCmd(cmd)
+    discard close(stdin.getFileHandle())
+    quit(0)
+  else:
+    # parent
+    discard close(pipefd[0])
+    let fd = pipefd[1]
+    let p = container.redirectToFd(fd, wait = false)
+    discard close(fd)
+    if needsterminal:
+      var x: cint
+      discard waitpid(pid, x, 0)
+      pager.term.restart()
+    return (p, false)
+
+# Save input in a file, run the command, and redirect its output to a
+# new buffer.
+# needsterminal is ignored.
+proc runMailcapReadFile(pager: Pager, container: Container,
+    entry: MailcapEntry, cmd, outpath: string): (EmptyPromise, bool) =
+  let fd = open(outpath, O_WRONLY or O_CREAT, 0o666)
+  if fd == -1:
+    return (nil, false)
+  let p = container.redirectToFd(fd, wait = true).then(proc(): auto =
+    var pipefd: array[2, cint] # redirect stdout here
+    if pipe(pipefd) == -1:
+      raise newException(Defect, "Failed to open pipe.")
+    let pid = fork()
+    if pid == 0:
+      # child process
+      discard close(pipefd[0])
+      discard dup2(pipefd[1], stdout.getFileHandle())
+      discard close(pipefd[1])
+      let devnull = open("/dev/null", O_WRONLY)
+      discard dup2(devnull, stderr.getFileHandle())
+      discard close(devnull)
+      discard execCmd(cmd)
+      discard tryRemoveFile(outpath)
+      quit(0)
+    # parent
+    discard close(pipefd[1])
+    let fdout = pipefd[0]
+    let ishtml = HTMLOUTPUT in entry.flags
+    if ishtml:
+      #TODO this is a hack for dupe buffer and should be reconsidered.
+      container.source.contenttype = some("text/html")
+    return container.readFromFd(fdout, ishtml).then(proc() =
+      discard close(fdout)
+    )
+  )
+  return (p, true)
+
+# Save input in a file, run the command, and discard its output.
+# If needsterminal, leave stderr and stdout open and wait for the process.
+proc runMailcapWriteFile(pager: Pager, container: Container,
+    entry: MailcapEntry, cmd, outpath: string): (EmptyPromise, bool) =
+  let needsterminal = NEEDSTERMINAL in entry.flags
+  let fd = open(outpath, O_WRONLY or O_CREAT, 0o666)
+  if fd == -1:
+    return (nil, false)
+  let p = container.redirectToFd(fd, wait = true).then(proc() =
+    if needsterminal:
+      pager.term.quit()
+      discard execCmd(cmd)
+      discard tryRemoveFile(outpath)
+      pager.term.restart()
+    else:
+      # don't block
+      let pid = fork()
+      if pid == 0:
+        # child process
+        let devnull = open("/dev/null", O_WRONLY)
+        discard dup2(devnull, stdin.getFileHandle())
+        discard dup2(devnull, stdout.getFileHandle())
+        discard dup2(devnull, stderr.getFileHandle())
+        discard close(devnull)
+        discard execCmd(cmd)
+        discard tryRemoveFile(outpath)
+        quit(0)
+  )
+  return (p, false)
+
+# Search for a mailcap entry, and if found, execute the specified command
+# and pipeline the input and output appropriately.
+# There is four possible outcomes:
+# * pipe stdin, discard stdout
+# * pipe stdin, read stdout
+# * write to file, run, discard stdout
+# * write to file, run, read stdout
+# If needsterminal is specified, and stdout is not being read, then the
+# pager is suspended until the command exits.
+#TODO add support for edit/compose, better error handling (use Promise[bool]
+# instead of tuple[EmptyPromise, bool])
+proc checkMailcap(pager: Pager, container: Container): (EmptyPromise, bool) =
+  if container.contenttype.isNone:
+    return (nil, true)
+  if container.source.t == CLONE:
+    return (nil, true) # clone cannot use mailcap
+  let contentType = container.contenttype.get
+  if contentType == "text/html":
+    # We support HTML natively, so it would make little sense to execute
+    # mailcap filters for it.
+    return (nil, true)
+  elif contentType == "text/plain":
+    # This could potentially be useful. Unfortunately, many mailcaps include
+    # a text/plain entry with less by default, so it's probably better to
+    # ignore this.
+    return (nil, true)
+  #TODO callback for outpath or something
+  let url = container.location
+  let cs = container.source.charset
+  let entry = pager.mailcap.getMailcapEntry(contentType, "", url, cs)
+  if entry != nil:
+    let tmpdir = pager.config.external.tmpdir
+    let ext = container.location.pathname.afterLast('.')
+    let tempfile = getTempfile(tmpdir, ext)
+    let outpath = if entry.nametemplate != "":
+      unquoteCommand(entry.nametemplate, contentType, tempfile, url, cs)
+    else:
+      tempfile
+    var canpipe = true
+    let cmd = unquoteCommand(entry.cmd, contentType, outpath, url, cs, canpipe)
+    if {COPIOUSOUTPUT, HTMLOUTPUT} * entry.flags == {}:
+      # no output.
+      if canpipe:
+        return pager.runMailcapWritePipe(container, entry[], cmd)
+      else:
+        return pager.runMailcapWriteFile(container, entry[], cmd, outpath)
+    else:
+      if canpipe:
+        return pager.runMailcapReadPipe(container, entry[], cmd)
+      else:
+        return pager.runMailcapReadFile(container, entry[], cmd, outpath)
+  return (nil, true)
+
+proc handleEvent0(pager: Pager, container: Container, event: ContainerEvent): bool =
+  case event.t
+  of FAIL:
+    dec pager.numload
+    pager.deleteContainer(container)
+    if container.retry.len > 0:
+      pager.gotoURL(newRequest(container.retry.pop()), ctype = container.contenttype)
+    else:
+      let errorMessage = getLoaderErrorMessage(container.code)
+      pager.alert("Can't load " & $container.source.location & " (" &
+        errorMessage & ")")
+    return false
+  of SUCCESS:
+    if container.replace != nil:
+      let n = container.replace.children.find(container)
+      if n != -1:
+        container.replace.children.delete(n)
+        container.parent = nil
+      let n2 = container.children.find(container.replace)
+      if n2 != -1:
+        container.children.delete(n2)
+        container.replace.parent = nil
+      container.children.add(container.replace.children)
+      for child in container.children:
+        child.parent = container
+      container.replace.children.setLen(0)
+      if container.replace.parent != nil:
+        container.parent = container.replace.parent
+        let n = container.replace.parent.children.find(container.replace)
+        assert n != -1, "Container not a child of its parent"
+        container.parent.children[n] = container
+        container.replace.parent = nil
+      if pager.container == container.replace:
+        pager.setContainer(container)
+      pager.deleteContainer(container.replace)
+      container.replace = nil
+  of LOADED:
+    dec pager.numload
+  of NEEDS_AUTH:
+    if pager.container == container:
+      pager.authorize()
+  of REDIRECT:
+    if container.redirectdepth < pager.config.network.max_redirect:
+      pager.alert("Redirecting to " & $event.request.url)
+      pager.gotoURL(event.request, some(container.source.location),
+        replace = container, redirectdepth = container.redirectdepth + 1,
+        referrer = pager.container)
+    else:
+      pager.alert("Error: maximum redirection depth reached")
+      pager.deleteContainer(container)
+      return false
+  of ANCHOR:
+    var url2 = newURL(container.source.location)
+    url2.setHash(event.anchor)
+    pager.addContainer(pager.dupeBuffer(container, url2))
+  of NO_ANCHOR:
+    pager.alert("Couldn't find anchor " & event.anchor)
+  of UPDATE:
+    if container == pager.container:
+      pager.redraw = true
+      if event.force: pager.term.clearCanvas()
+  of READ_LINE:
+    if container == pager.container:
+      pager.setLineEdit("(BUFFER) " & event.prompt, BUFFER, event.value, hide = event.password)
+  of READ_AREA:
+    if container == pager.container:
+      var s = event.tvalue
+      if openInEditor(pager.term, pager.config, s):
+        pager.container.readSuccess(s)
+      else:
+        pager.container.readCanceled()
+      pager.redraw = true
+  of OPEN:
+    if pager.container == nil or not pager.container.isHoverURL(event.request.url):
+      pager.ask("Open pop-up? " & $event.request.url).then(proc(x: bool) =
+        if x:
+          pager.gotoURL(event.request, some(container.source.location), referrer = pager.container))
+    else:
+      pager.gotoURL(event.request, some(container.source.location), referrer = pager.container)
+  of INVALID_COMMAND: discard
+  of STATUS:
+    if pager.container == container:
+      pager.showAlerts()
+  of TITLE:
+    if pager.container == container:
+      pager.showAlerts()
+      pager.term.setTitle(container.getTitle())
+  of ALERT:
+    if pager.container == container:
+      pager.alert(event.msg)
+  of CHECK_MAILCAP:
+    var (cm, connect) = pager.checkMailcap(container)
+    if cm == nil:
+      cm = container.connect2()
+    if connect:
+      cm.then(proc() =
+        container.startload())
+    else:
+      cm.then(proc(): auto =
+        container.quit())
+  of QUIT:
+    dec pager.numload
+    pager.deleteContainer(container)
+    return false
+  of NO_EVENT: discard
+  return true
+
+proc handleEvents*(pager: Pager, container: Container) =
+  while container.events.len > 0:
+    let event = container.events.popFirst()
+    if not pager.handleEvent0(container, event):
+      break
+
+proc handleEvent*(pager: Pager, container: Container) =
+  try:
+    container.handleEvent()
+    pager.handleEvents(container)
+  except IOError:
+    discard
+
+proc addPagerModule*(ctx: JSContext) =
+  ctx.registerType(Pager)
diff --git a/src/local/select.nim b/src/local/select.nim
new file mode 100644
index 00000000..f7afa4d9
--- /dev/null
+++ b/src/local/select.nim
@@ -0,0 +1,306 @@
+import unicode
+
+import buffer/buffer
+import buffer/cell
+import js/regex
+import utils/twtstr
+
+type
+  SubmitSelect* = proc(selected: seq[int])
+  CloseSelect* = proc()
+
+  Select* = object
+    open*: bool
+    options: seq[string]
+    multiple: bool
+    # old selection
+    oselected*: seq[int]
+    # new selection
+    selected*: seq[int]
+    # cursor distance from y
+    cursor: int
+    # widest option
+    maxw: int
+    # maximum height on screen (yes the naming is dumb)
+    maxh: int
+    # first index to display
+    si: int
+    # location on screen
+    x: int
+    y: int
+    redraw*: bool
+    submitFun: SubmitSelect
+    bpos: seq[int]
+
+proc windowChange*(select: var Select, height: int) =
+  select.maxh = height - 2
+  if select.y + select.options.len >= select.maxh:
+    select.y = height - select.options.len
+    if select.y < 0:
+      select.si = -select.y
+      select.y = 0
+  if select.selected.len > 0:
+    let i = select.selected[0]
+    if select.si > i:
+      select.si = i
+    elif select.si + select.maxh < i:
+      select.si = max(i - select.maxh, 0)
+  select.redraw = true
+
+proc initSelect*(select: var Select, selectResult: SelectResult,
+    x, y, height: int, submitFun: SubmitSelect) =
+  select.open = true
+  select.multiple = selectResult.multiple
+  select.options = selectResult.options
+  select.oselected = selectResult.selected
+  select.selected = selectResult.selected
+  select.submitFun = submitFun
+  for opt in select.options.mitems:
+    opt.mnormalize()
+    select.maxw = max(select.maxw, opt.width())
+  select.x = x
+  select.y = y
+  select.windowChange(height)
+
+# index of option currently under cursor
+func hover(select: Select): int =
+  return select.cursor + select.si
+
+func dispheight(select: Select): int =
+  return select.maxh - select.y
+
+proc `hover=`(select: var Select, i: int) =
+  let i = clamp(i, 0, select.options.high)
+  if i >= select.si + select.dispheight:
+    select.si = i - select.dispheight + 1
+    select.cursor = select.dispheight - 1
+  elif i < select.si:
+    select.si = i
+    select.cursor = 0
+  else:
+    select.cursor = i - select.si
+
+proc cursorDown*(select: var Select) =
+  if select.hover < select.options.high and
+      select.cursor + select.y < select.maxh - 1:
+    inc select.cursor
+    select.redraw = true
+  elif select.si < select.options.len - select.maxh:
+    inc select.si
+    select.redraw = true
+
+proc cursorUp*(select: var Select) =
+  if select.cursor > 0:
+    dec select.cursor
+    select.redraw = true
+  elif select.si > 0:
+    dec select.si
+    select.redraw = true
+  elif select.multiple and select.cursor > -1:
+    select.cursor = -1
+
+proc close(select: var Select) =
+  select = Select()
+
+proc cancel*(select: var Select) =
+  select.submitFun(select.oselected)
+  select.close()
+
+proc submit(select: var Select) =
+  select.submitFun(select.selected)
+  select.close()
+
+proc click*(select: var Select) =
+  if not select.multiple:
+    select.selected = @[select.hover]
+    select.submit()
+  elif select.cursor == -1:
+    select.submit()
+  else:
+    var k = select.selected.len
+    let i = select.hover
+    for j in 0 ..< select.selected.len:
+      if select.selected[j] >= i:
+        k = j
+        break
+    if k < select.selected.len and select.selected[k] == i:
+      select.selected.delete(k)
+    else:
+      select.selected.insert(i, k)
+    select.redraw = true
+
+proc cursorLeft*(select: var Select) =
+  select.submit()
+
+proc cursorRight*(select: var Select) =
+  select.click()
+
+proc getCursorX*(select: var Select): int =
+  if select.cursor == -1:
+    return select.x
+  return select.x + 1
+
+proc getCursorY*(select: var Select): int =
+  return select.y + 1 + select.cursor
+
+proc cursorFirstLine*(select: var Select) =
+  if select.cursor != 0 or select.si != 0:
+    select.cursor = 0
+    select.si = 0
+    select.redraw = true
+
+proc cursorLastLine*(select: var Select) =
+  if select.hover < select.options.len:
+    select.cursor = select.dispheight - 1
+    select.si = max(select.options.len - select.maxh, 0)
+    select.redraw = true
+
+proc cursorNextMatch*(select: var Select, regex: Regex, wrap: bool) =
+  var j = -1
+  for i in select.hover + 1 ..< select.options.len:
+    if regex.exec(select.options[i]).success:
+      j = i
+      break
+  if j != -1:
+    select.hover = j
+    select.redraw = true
+  elif wrap:
+    for i in 0 ..< select.hover:
+      if regex.exec(select.options[i]).success:
+        j = i
+        break
+    if j != -1:
+      select.hover = j
+      select.redraw = true
+
+proc cursorPrevMatch*(select: var Select, regex: Regex, wrap: bool) =
+  var j = -1
+  for i in countdown(select.hover - 1, 0):
+    if regex.exec(select.options[i]).success:
+      j = i
+      break
+  if j != -1:
+    select.hover = j
+    select.redraw = true
+  elif wrap:
+    for i in countdown(select.options.high, select.hover):
+      if regex.exec(select.options[i]).success:
+        j = i
+        break
+    if j != -1:
+      select.hover = j
+      select.redraw = true
+
+proc pushCursorPos*(select: var Select) =
+  select.bpos.add(select.hover)
+
+proc popCursorPos*(select: var Select, nojump = false) =
+  select.hover = select.bpos.pop()
+  if not nojump:
+    select.redraw = true
+
+const HorizontalBar = $Rune(0x2500)
+const VerticalBar = $Rune(0x2502)
+const CornerTopLeft = $Rune(0x250C)
+const CornerTopRight = $Rune(0x2510)
+const CornerBottomLeft = $Rune(0x2514)
+const CornerBottomRight = $Rune(0x2518)
+
+proc drawBorders(display: var FixedGrid, sx, ex, sy, ey: int,
+    upmore, downmore: bool) =
+  for y in sy .. ey:
+    var x = 0
+    while x < sx:
+      if display[y * display.width + x].str == "":
+        display[y * display.width + x].str = " "
+        inc x
+      else:
+        #x = display[y * display.width + x].str.twidth(x)
+        inc x
+  # Draw corners.
+  let tl = if upmore: VerticalBar else: CornerTopLeft
+  let tr = if upmore: VerticalBar else: CornerTopRight
+  let bl = if downmore: VerticalBar else: CornerBottomLeft
+  let br = if downmore: VerticalBar else: CornerBottomRight
+  const fmt = newFormat()
+  display[sy * display.width + sx].str = tl
+  display[sy * display.width + ex].str = tr
+  display[ey * display.width + sx].str = bl
+  display[ey * display.width + ex].str = br
+  display[sy * display.width + sx].format = fmt
+  display[sy * display.width + ex].format = fmt
+  display[ey * display.width + sx].format = fmt
+  display[ey * display.width + ex].format = fmt
+  # Draw top, bottom borders.
+  let ups = if upmore: " " else: HorizontalBar
+  let downs = if downmore: " " else: HorizontalBar
+  for x in sx + 1 .. ex - 1:
+    display[sy * display.width + x].str = ups
+    display[ey * display.width + x].str = downs
+    display[sy * display.width + x].format = fmt
+    display[ey * display.width + x].format = fmt
+  if upmore:
+    display[sy * display.width + sx + (ex - sx) div 2].str = ":"
+  if downmore:
+    display[ey * display.width + sx + (ex - sx) div 2].str = ":"
+  # Draw left, right borders.
+  for y in sy + 1 .. ey - 1:
+    display[y * display.width + sx].str = VerticalBar
+    display[y * display.width + ex].str = VerticalBar
+    display[y * display.width + sx].format = fmt
+    display[y * display.width + ex].format = fmt
+
+proc drawSelect*(select: Select, display: var FixedGrid) =
+  if display.width < 2 or display.height < 2:
+    return # border does not fit...
+  # Max width, height with one row/column on the sides.
+  let mw = display.width - 2
+  let mh = display.height - 2
+  var sy = select.y
+  let si = select.si
+  var ey = min(sy + select.options.len, mh) + 1
+  var sx = select.x
+  if sx + select.maxw >= mw:
+    sx = display.width - select.maxw
+    if sx < 0:
+      # This means the widest option is wider than the available screen.
+      # w3m simply cuts off the part that doesn't fit, and we do that too,
+      # but I feel like this may not be the best solution.
+      sx = 0
+  var ex = min(sx + select.maxw, mw) + 1
+  let upmore = select.si > 0
+  let downmore = select.si + mh < select.options.len
+  drawBorders(display, sx, ex, sy, ey, upmore, downmore)
+  if select.multiple and not upmore:
+    display[sy * display.width + sx].str = "X"
+  # move inside border
+  inc sy
+  inc sx
+  var r: Rune
+  var k = 0
+  var format = newFormat()
+  while k < select.selected.len and select.selected[k] < si:
+    inc k
+  for y in sy ..< ey:
+    let i = y - sy + si
+    var j = 0
+    var x = sx
+    let dls = y * display.width
+    if k < select.selected.len and select.selected[k] == i:
+      format.reverse = true
+      inc k
+    else:
+      format.reverse = false
+    while j < select.options[i].len:
+      fastRuneAt(select.options[i], j, r)
+      let rw = r.twidth(x)
+      let ox = x
+      x += rw
+      if x > ex:
+        break
+      display[dls + ox].str = $r
+      display[dls + ox].format = format
+    while x < ex:
+      display[dls + x].str = " "
+      display[dls + x].format = format
+      inc x