sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 8d38421db6b09a1549505ec20dc4401491f1807a
parent 4d7b7c7ba91568d8001ba369c379c57cc8608892
Author: Eric Weikl <eric.weikl@gmx.net>
Date:   Thu, 23 May 2013 20:34:40 +0200

Merge branch 'sup-heliotrope/develop' into maildir-sync

Diffstat:
M CONTRIBUTORS | 37 +++++++++++++++++++++++++------------
M History.txt | 5 +++++
A README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
D README.txt | 132 -------------------------------------------------------------------------------
M Rakefile | 5 ++++-
M ReleaseNotes | 5 +++++
M bin/sup | 4 +++-
M bin/sup-add | 4 +++-
M bin/sup-config | 17 +++++++++++------
M bin/sup-dump | 2 ++
M bin/sup-import-dump | 4 +++-
M bin/sup-recover-sources | 2 ++
M bin/sup-sync | 4 +++-
M bin/sup-sync-back | 4 +++-
M bin/sup-tweak-labels | 4 +++-
D doc/NewUserGuide.txt | 258 -------------------------------------------------------------------------------
M lib/sup.rb | 93 ++++++++++++++++++++++++++-----------------------------------------------------
M lib/sup/buffer.rb | 2 +-
M lib/sup/hook.rb | 2 ++
R lib/sup/horizontal-selector.rb -> lib/sup/horizontal_selector.rb | 0
M lib/sup/index.rb | 53 +++++++++++++++++++++++++++--------------------------
R lib/sup/interactive-lock.rb -> lib/sup/interactive_lock.rb | 0
M lib/sup/logger.rb | 2 +-
A lib/sup/logger/singleton.rb | 10 ++++++++++
R lib/sup/message-chunks.rb -> lib/sup/message_chunks.rb | 0
R lib/sup/modes/buffer-list-mode.rb -> lib/sup/modes/buffer_list_mode.rb | 0
R lib/sup/modes/completion-mode.rb -> lib/sup/modes/completion_mode.rb | 0
R lib/sup/modes/compose-mode.rb -> lib/sup/modes/compose_mode.rb | 0
D lib/sup/modes/console-mode.rb | 114 -------------------------------------------------------------------------------
A lib/sup/modes/console_mode.rb | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/contact-list-mode.rb -> lib/sup/modes/contact_list_mode.rb | 0
R lib/sup/modes/edit-message-async-mode.rb -> lib/sup/modes/edit_message_async_mode.rb | 0
R lib/sup/modes/edit-message-mode.rb -> lib/sup/modes/edit_message_mode.rb | 0
R lib/sup/modes/file-browser-mode.rb -> lib/sup/modes/file_browser_mode.rb | 0
R lib/sup/modes/forward-mode.rb -> lib/sup/modes/forward_mode.rb | 0
R lib/sup/modes/help-mode.rb -> lib/sup/modes/help_mode.rb | 0
R lib/sup/modes/inbox-mode.rb -> lib/sup/modes/inbox_mode.rb | 0
R lib/sup/modes/label-list-mode.rb -> lib/sup/modes/label_list_mode.rb | 0
R lib/sup/modes/label-search-results-mode.rb -> lib/sup/modes/label_search_results_mode.rb | 0
R lib/sup/modes/line-cursor-mode.rb -> lib/sup/modes/line_cursor_mode.rb | 0
R lib/sup/modes/log-mode.rb -> lib/sup/modes/log_mode.rb | 0
R lib/sup/modes/person-search-results-mode.rb -> lib/sup/modes/person_search_results_mode.rb | 0
R lib/sup/modes/poll-mode.rb -> lib/sup/modes/poll_mode.rb | 0
R lib/sup/modes/reply-mode.rb -> lib/sup/modes/reply_mode.rb | 0
R lib/sup/modes/resume-mode.rb -> lib/sup/modes/resume_mode.rb | 0
R lib/sup/modes/scroll-mode.rb -> lib/sup/modes/scroll_mode.rb | 0
R lib/sup/modes/search-list-mode.rb -> lib/sup/modes/search_list_mode.rb | 0
R lib/sup/modes/search-results-mode.rb -> lib/sup/modes/search_results_mode.rb | 0
R lib/sup/modes/text-mode.rb -> lib/sup/modes/text_mode.rb | 0
D lib/sup/modes/thread-index-mode.rb | 980 -------------------------------------------------------------------------------
A lib/sup/modes/thread_index_mode.rb | 980 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/thread-view-mode.rb -> lib/sup/modes/thread_view_mode.rb | 0
A lib/sup/service/label_service.rb | 45 +++++++++++++++++++++++++++++++++++++++++++++
A lib/sup/util/path.rb | 9 +++++++++
A lib/sup/util/uri.rb | 15 +++++++++++++++
M sup.gemspec | 19 +++++++++++--------
A test/integration/test_label_service.rb | 18 ++++++++++++++++++
M test/test_header_parsing.rb | 8 ++++----
A test/test_helper.rb | 7 +++++++
M test/test_message.rb | 18 +++++-------------
M test/test_yaml_regressions.rb | 4 ++--
A test/unit/service/test_label_service.rb | 19 +++++++++++++++++++
A test/unit/util/uri.rb | 19 +++++++++++++++++++
63 files changed, 1472 insertions(+), 1627 deletions(-)
diff --git a/CONTRIBUTORS b/CONTRIBUTORS
@@ -1,26 +1,29 @@
 William Morgan <wmorgan-sup at the masanjin dot nets>
 Rich Lane <rlane at the club.cc.cmu dot edus>
+Gaute Hope <eg at the gaute.vetsj dot coms>
+Hamish Downer <dmishd at the gmail dot coms>
+Whyme Lyu <callme5long at the gmail dot coms>
+Sascha Silbe <sascha-pgp at the silbe dot orgs>
 Ismo Puustinen <ismo at the iki dot fis>
 Nicolas Pouillard <nicolas.pouillard at the gmail dot coms>
-Eric Sherman <hyperbolist at the gmail dot coms>
 Michael Stapelberg <michael at the stapelberg dot des>
+Eric Sherman <hyperbolist at the gmail dot coms>
+Tero Tilus <tero at the tilus dot nets>
 Ben Walton <bwalton at the artsci.utoronto dot cas>
 Mike Stipicevic <stipim at the rpi dot edus>
 Marcus Williams <marcus-sup at the bar-coded dot nets>
 Lionel Ott <white.magic at the gmx dot des>
-Tero Tilus <tero at the tilus dot nets>
+Gaudenz Steinlin <gaudenz at the soziologie dot chs>
+Damien Leone <damien.leone at the fensalir dot frs>
 Ingmar Vanhassel <ingmar at the exherbo dot orgs>
 Mark Alexander <marka at the pobox dot coms>
-Gaute Hope <eg at the gaute.vetsj dot coms>
+Eric Weikl <eric.weikl at the tngtech dot coms>
 Christopher Warrington <chrisw at the rice dot edus>
 W. Trevor King <wking at the drexel dot edus>
-Gaudenz Steinlin <gaudenz at the soziologie dot chs>
 Richard Brown <rbrown at the exherbo dot orgs>
 Marc Hartstein <marc.hartstein at the alum.vassar dot edus>
-Sascha Silbe <sascha-pgp at the silbe dot orgs>
 Israel Herraiz <israel.herraiz at the gmail dot coms>
 Anthony Martinez <pi+sup at the pihost dot uss>
-Hamish Downer <dmishd at the gmail dot coms>
 Bo Borgerson <gigabo at the gmail dot coms>
 William Erik Baxter <web at the superscript dot coms>
 Michael Hamann <michael at the content-space dot des>
@@ -29,26 +32,36 @@ Adeodato Simó <dato at the net.com.org dot ess>
 Daniel Schoepe <daniel.schoepe at the googlemail dot coms>
 Jason Petsod <jason at the petsod dot orgs>
 Steve Goldman <sgoldman at the tower-research dot coms>
+Robin Burchell <viroteck at the viroteck dot nets>
+Peter Harkins <ph at the malaprop dot orgs>
 Edward Z. Yang <ezyang at the MIT dot EDUs>
 Decklin Foster <decklin at the red-bean dot coms>
 Cameron Matheson <cam+sup at the cammunism dot orgs>
 Carl Worth <cworth at the cworth dot orgs>
 Jeff Balogh <its.jeff.balogh at the gmail dot coms>
-Andrew Pimlott <andrew at the pimlott dot nets>
 Alex Vandiver <alexmv at the mit dot edus>
-Peter Harkins <ph at the malaprop dot orgs>
+Andrew Pimlott <andrew at the pimlott dot nets>
+Matías Aguirre <matiasaguirre at the gmail dot coms>
+Anthony Martinez <pi at the pihost dot uss>
 Kornilios Kourtis <kkourt at the cslab.ece.ntua dot grs>
+Kevin Riggle <kevinr at the free-dissociation dot coms>
 Giorgio Lando <patroclo7 at the gmail dot coms>
-Damien Leone <damien.leone at the fensalir dot frs>
 Benoît PIERRE <benoit.pierre at the gmail dot coms>
 Alvaro Herrera <alvherre at the alvh.no-ip dot orgs>
+Eric Weikl <eric.weikl at the gmx dot nets>
 Jonah <Jonah at the GoodCoffee dot cas>
+ian <ian at the lorf dot orgs>
 Adam Lloyd <adam at the alloy-d dot nets>
 Todd Eisenberger <teisenbe at the andrew.cmu dot edus>
-ian <ian at the lorf dot orgs>
 Steven Walter <swalter at the monarch.(none)>
-ian <itaylor at the uark dot edus>
-Jon M. Dugan <jdugan at the es dot nets>
+Alex Vandiver <alex at the chmrr dot nets>
 Gregor Hoffleit <gregor at the sam.mediasupervision dot des>
+Jon M. Dugan <jdugan at the es dot nets>
+Matthieu Rakotojaona <matthieu.rakotojaona at the gmail dot coms>
 Stefan Lundström <lundst at the snabb.(none)>
+Matthias Vallentin <vallentin at the icir dot orgs>
+Steven Lawrance <stl at the redhat dot coms>
+Jonathan Lassoff <jof at the thejof dot coms>
+ian <itaylor at the uark dot edus>
+Gregor Hoffleit <gregor at the hoffleit dot des>
 Kirill Smelkov <kirr at the landau.phys.spbu dot rus>
diff --git a/History.txt b/History.txt
@@ -1,3 +1,8 @@
+== 0.13.0 / 2013-05-15
+
+* Bugfixes
+* Depend on ncursesw-sup
+
 == 0.12.1 / 2011-01-23
 * Depend on ncursesw rather than ncurses (Ruby 1.9 compatibility)
 * Add sup-import-dump
diff --git a/README.md b/README.md
@@ -0,0 +1,70 @@
+# Sup
+
+A console-based email client with the best features of GMail, mutt and
+Emacs.
+
+## Installation
+
+[See the wiki][Installation]
+
+## Features / Problems
+
+Features:
+
+* GMail-like thread-centered archiving, tagging and muting
+* [Handling mail from multiple mbox and Maildir sources][sources]
+* Blazing fast full-text search with a [rich query language][search]
+* Multiple accounts - pick the right one when sending mail
+* [Ruby-programmable hooks][hooks]
+* Automatically tracking recent contacts
+
+Current limitations which will be fixed:
+
+* [Doesn't run on Ruby 2.0][ruby20]
+
+* Sup doesn't play nicely with other mail clients. Changes in Sup won't be
+  synced back to mail source.
+
+* Unix-centrism in MIME attachment handling and in sendmail invocation.
+
+## Problems
+
+Please report bugs to the [Github issue tracker](https://github.com/sup-heliotrope/sup/issues).
+
+## Links
+
+* [Homepage](http://supmua.org/)
+* [Code repository](https://github.com/sup-heliotrope/sup)
+* [Wiki](https://github.com/sup-heliotrope/sup/wiki)
+* IRC: [#sup @ freenode.net](http://webchat.freenode.net/?channels=#sup)
+* Mailing list: [sup-talk] and [sup-devel]
+
+## License
+
+```
+Copyright (c) 2013       Sup developers.
+Copyright (c) 2006--2009 William Morgan.
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+02110-1301, USA.
+```
+
+[sources]: https://github.com/sup-heliotrope/sup/wiki/Adding-sources
+[hooks]: https://github.com/sup-heliotrope/sup/wiki/Hooks
+[search]: https://github.com/sup-heliotrope/sup/wiki/Searching-your-mail
+[Installation]: https://github.com/sup-heliotrope/sup/wiki#installation
+[ruby20]: https://github.com/sup-heliotrope/sup/wiki/Development#sup-014
+[sup-talk]: http://rubyforge.org/mailman/listinfo/sup-talk
+[sup-devel]: http://rubyforge.org/mailman/listinfo/sup-devel
diff --git a/README.txt b/README.txt
@@ -1,132 +0,0 @@
-sup
-    originally by William Morgan <wmorgan-sup@masanjin.net>
-    http://supmua.org
-
-== DESCRIPTION:
-
-Sup is a console-based email client for people with a lot of email.
-It supports tagging, very fast full-text search, automatic contact-
-list management, and more. If you're the type of person who treats
-email as an extension of your long-term memory, Sup is for you.
-
-Sup makes it easy to:
-- Handle massive amounts of email.
-
-- Mix email from different sources: mbox files and Maildirs.
-
-- Instantaneously search over your entire email collection. Search over
-  body text, or use a query language to combine search predicates in any
-  way.
-
-- Handle multiple accounts. Replying to email sent to a particular
-  account will use the correct SMTP server, signature, and from address.
-
-- Add custom code to customize Sup to whatever particular and bizarre
-  needs you may have.
-
-- Organize email with user-defined labels, automatically track recent
-  contacts, and much more!
-
-The goal of Sup is to become the email client of choice for nerds
-everywhere.
-
-== FEATURES/PROBLEMS:
-
-Features:
-
-- Scalability to massive amounts of email. Immediate startup and
-  operability, regardless of how much amount of email you have.
-
-- Immediate full-text search of your entire email archive, using the
-  Xapian query language. Search over message bodies, labels, from: and
-  to: fields, or any combination thereof.
-
-- Thread-centrism. Operations are performed at the thread, not the
-  message level. Entire threads are manipulated and viewed (with
-  redundancies removed) at a time.
-
-- Labels instead of folders. Drop that tired old metaphor and you'll see
-  how much easier it is to organize email.
-
-- GMail-style thread management. Archive a thread, and it will disappear
-  from your inbox until someone replies. Kill a thread, and it will
-  never come back to your inbox (but will still show up in searches.)
-  Mark a thread as spam and you'll never again see it unless explicitly
-  searching for spam.
-
-- Console based interface. No mouse clicking required!
-
-- Programmability. It's in Ruby. The code is good. It has an extensive
-  hook system that makes it easy to extend and customize.
-
-- Multiple buffer support. Why be limited to viewing one thing at a
-  time?
-
-- Tons of other little features, like automatic context-sensitive help,
-  multi-message operations, MIME attachment viewing, recent contact list
-  generation, etc.
-
-Current limitations which will be fixed:
-
-- Sup doesn't play nicely with other mail clients. If you alter a mail
-  source (read, move, delete, etc) with another client Sup will punish
-  you with a lengthy reindexing process.
-
-- Unix-centrism in MIME attachment handling and in sendmail invocation.
-
-== SYNOPSYS:
-
-  0. sup-config
-  1. sup
-
-  Note that Sup never changes the contents of any mailboxes; it only
-  indexes in to them. So it shouldn't ever corrupt your mail. The flip
-  side is that if you change a mailbox (e.g. delete messages, or, in the
-  case of mbox files, read an unread message) then Sup will be unable to
-  load messages from that source and will ask you to run sup-sync
-  --changed.
-
-== REQUIREMENTS:
-
- - xapian-full-alaveteli >= 1.2
- - ncursesw-sup >= 1.3.1
- - rmail >= 0.17
- - highline
- - trollop >= 1.12
- - lockfile
- - mime-types
- - gettext
-
-== INSTALL:
-
-* gem install sup
-
-== PROBLEMS:
-
-Report bugs to the github page:
-  https://github.com/sup-heliotrope/sup/issues
-
-Or check the mailing lists:
-  * http://rubyforge.org/mailman/listinfo/sup-talk
-  * http://rubyforge.org/mailman/listinfo/sup-devel
-
-== LICENSE:
-
-Copyright (c) 2013       Sup developers.
-Copyright (c) 2006--2009 William Morgan.
-
-This program is free software; you can redistribute it and/or
-modify it under the terms of the GNU General Public License
-as published by the Free Software Foundation; either version 2
-of the License, or (at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program; if not, write to the Free Software
-Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-02110-1301, USA.
-
diff --git a/Rakefile b/Rakefile
@@ -3,11 +3,14 @@ require 'rake/testtask'
 
 Rake::TestTask.new(:test) do |test|
   test.libs << 'test'
-  test.test_files = FileList.new('test/test_*.rb')
+  test.test_files = FileList.new('test/**/test_*.rb')
   test.verbose = true
 end
+task :default => :test
 
 require 'rubygems/package_task'
+# For those who don't have `rubygems-bundler` installed
+load 'sup.gemspec' unless defined? Redwood::Gemspec
 
 Gem::PackageTask.new(Redwood::Gemspec) do |pkg|
   pkg.need_tar = true
diff --git a/ReleaseNotes b/ReleaseNotes
@@ -1,3 +1,8 @@
+Release 0.13.0:
+
+Collection of bugfixes and stability fixes since 0.12.1. We now depend on our
+own ncursesw-sup fork.
+
 Release 0.12.1:
 
 This release changes the gem dependency on ncurses to ncursesw, which
diff --git a/bin/sup b/bin/sup
@@ -1,5 +1,7 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 
 require 'ncursesw'
@@ -15,7 +17,7 @@ end
 
 require 'fileutils'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 if ENV['SUP_PROFILE']
   require 'ruby-prof'
diff --git a/bin/sup-add b/bin/sup-add
@@ -1,10 +1,12 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'uri'
 require 'rubygems'
 require 'highline/import'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 $opts = Trollop::options do
   version "sup-add (sup #{Redwood::VERSION})"
diff --git a/bin/sup-config b/bin/sup-config
@@ -1,5 +1,7 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 require 'highline/import'
 require 'trollop'
@@ -19,11 +21,12 @@ EOS
 end
 
 def axe q, default=nil
-  ans = if default && !default.empty?
-    ask "#{q} (enter for \"#{default}\"): "
-  else
-    ask "#{q}: "
-  end
+  question = if default && !default.empty?
+               "#{q} (enter for \"#{default}\"): "
+             else
+               "#{q}: "
+             end
+  ans = ask question
   ans.empty? ? default : ans.to_s
 end
 
@@ -36,6 +39,8 @@ def build_cmd cmd
 end
 
 def add_source
+  require "sup/util/uri"
+
   type = nil
 
   say "Ok, adding a new source."
@@ -69,7 +74,7 @@ def add_source
     end
 
     uri = begin
-      URI::Generic.build components
+      Redwood::Util::Uri.build components
     rescue URI::Error => e
       say "Whoopsie! I couldn't build a URI from that: #{e.message}"
       if axe_yes("Try again?") then next else return end
diff --git a/bin/sup-dump b/bin/sup-dump
@@ -1,5 +1,7 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 require 'xapian'
 require 'trollop'
diff --git a/bin/sup-import-dump b/bin/sup-import-dump
@@ -1,9 +1,11 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'uri'
 require 'rubygems'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 PROGRESS_UPDATE_INTERVAL = 15 # seconds
 
diff --git a/bin/sup-recover-sources b/bin/sup-recover-sources
@@ -1,5 +1,7 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'optparse'
 
 $opts = {
diff --git a/bin/sup-sync b/bin/sup-sync
@@ -1,9 +1,11 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'uri'
 require 'rubygems'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 PROGRESS_UPDATE_INTERVAL = 15 # seconds
 
diff --git a/bin/sup-sync-back b/bin/sup-sync-back
@@ -1,10 +1,12 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 require 'uri'
 require 'tempfile'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 fail "not working yet"
 
diff --git a/bin/sup-tweak-labels b/bin/sup-tweak-labels
@@ -1,8 +1,10 @@
 #!/usr/bin/env ruby
 
+$:.unshift File.join(File.dirname(__FILE__), *%w[.. lib])
+
 require 'rubygems'
 require 'trollop'
-require "sup"; Redwood::check_library_version_against "git"
+require "sup"
 
 class Float
   def to_s; sprintf '%.2f', self; end
diff --git a/doc/NewUserGuide.txt b/doc/NewUserGuide.txt
@@ -1,258 +0,0 @@
-Welcome to Sup! Here's how to get started.
-
-First, try running `sup`. Since this is your first time, you'll be
-confronted with a mostly blank screen, and a notice at the bottom that
-you have no new messages. That's because Sup doesn't hasn't loaded
-anything into its index yet, and has no idea where to look for them
-anyways.
-
-If you want to play around a little at this point, you can press 'b'
-to cycle between buffers, ';' to get a list of the open buffers, and
-'x' to kill a buffer. There's probably not too much interesting there,
-but there's a log buffer with some cryptic messages. You can also
-press '?' at any point to get a list of keyboard commands, but in the
-absence of any email, these will be mostly useless. When you get
-bored, press 'q' to quit.
-
-To use Sup for email, we need to load messages into the index. The
-index is where Sup stores all message state (e.g. read or unread, any
-message labels), and all information necessary for searching and for
-threading messages. Sup only knows about messages in its index.
-
-We can add messages to the index by telling Sup about the "source"
-where the messages reside. Sources are things like mbox folders, and
-maildir directories. Sup doesn't duplicate the actual message content
-in the index; it only stores whatever information is necessary for
-searching, threading and labelling. So when you search for messages or
-view your inbox, Sup talks only to the index (stored locally on
-disk). When you view a thread, Sup requests the full content of all
-the messages from the source.
-
-The easiest way to set up all your sources is to run `sup-config`.
-This will interactively walk you through some basic configuration,
-prompt you for all the sources you need, and optionally import
-messages from them.  Sup-config uses two other tools, sup-add and
-sup-sync, to load messages into the index. In the future you may make
-use of these tools directly (see below).
-
-Once you've run sup-config, you're ready to run `sup`. You should see
-the most recent unarchived messages appear in your inbox.
-Congratulations, you've got Sup working!
-
-If you're coming from the world of traditional MUAs, there are a
-couple differences you should be aware of at this point. First, Sup
-has no folders. Instead, you organize and find messages by a
-combination of search and labels (known as "tags" everywhere else in
-the world). Search and labels are an integral part of Sup because in
-Sup, rather than viewing the contents of a folder, you view the
-results of a search. I mentioned above that your inbox is, by
-definition, the set of all messages that aren't archived. This means
-that your inbox is nothing more than the result of the search for all
-messages with the label "inbox". (It's actually slightly more
-complicated---we also omit messages marked as killed, deleted or
-spam.)
-
-You could replicate the folder paradigm easily under this scheme, by
-giving each message exactly one label and only viewing the results of
-simple searches for those labels. But you'd quickly find out that life
-can be easier than that if you just trust the search engine, and use
-labels judiciously for things that are too hard to find with search.
-The idea is that a labeling system that allows arbitrary, user-defined
-labels, supplemented by a quick and easy-to-access search mechanism
-provides all the functionality that folders does, plus much more, at a
-far lower cost to the user.
-
-Now let's take a look at your inbox. You'll see that Sup groups
-messages together into threads: each line in the inbox is a thread,
-and the number in parentheses is the number of messages in that
-thread. (If there's no number, there's just one message in the
-thread.) In Sup, most operations are on threads, not individual
-messages. The idea is that you rarely want to operate on a message
-independent of its context. You typically want to view, archive, kill,
-or label all the messages in a thread at one time.
-
-Use the up and down arrows to highlight a thread. ('j' and 'k' do the
-same thing, and 'J' and 'K' will scroll the whole window. Even the
-left and right arrow keys work.) By default, Sup only loads as many
-threads as it takes to fill the window; if you'd like to load more,
-press 'M'. You can hit tab to cycle between only threads with new
-messages.
-
-Highlight a thread and press enter to view it. You'll notice that all
-messages in the thread are displayed together, laid out graphically by
-their relationship to each other (replies are nested under parents).
-By default, only the new messages in a thread are expanded, and the
-others are hidden. You can toggle an individual message's state by
-highlighting a green line and pressing enter. You can use 'E' to
-expand or collapse all messages or 'N' to expand only the new
-messages. You'll also notice that Sup hides quoted text and
-signatures. If you highlight a particular hidden chunk, you can press
-enter to expand it, or you can press 'o' to toggle every hidden chunk
-in a particular message.
-
-Other useful keyboard commands when viewing a thread are: 'n' and 'p'
-to jump to the next and previous open messages, 'h' to toggle the
-detailed headers for the current message, and enter to expand or
-collapse the current message (when it's on a text region). Enter and
-'n' in combination are useful for scanning through a thread---press
-enter to close the current message and jump to the next open one, and
-'n' to keep it open and jump. If the buffer is misaligned with a
-message, you can press 'z' to highlight it.
-
-This is a lot to remember, but you can always hit '?' to see the full
-list of keyboard commands at any point. There's a lot of useful stuff
-in there---once you learn some, try out some of the others!
-
-Now press 'x' to kill the thread view buffer. You should see the inbox
-again. If you don't, you can cycle through the buffers by pressing
-'b', or you can press ';' to see a list of all buffers and simply
-select the inbox.
-
-There are many operations you can perform on threads beyond viewing
-them. To archive a thread, press 'a'. The thread will disappear from
-your inbox, but will still appear in search results. If someone
-replies an archived thread, it will reappear in your inbox. To kill a
-thread, press '&'. Killed threads will never come back to your inbox,
-even if people reply, but will still be searchable. (This is useful
-for those interminable threads that you really have no immediate
-interest in, but which seem to pop up on every mailing list.)
-
-If a thread is spam, press 'S'. It will disappear and won't come back.
-It won't even appear in search results, unless you explicitly search
-for spam.
-
-You can star a thread by pressing '*'. Starred threads are displayed
-with a little yellow asterisk next to them, but otherwise have no
-special semantics. But you can also search for them easily---we'll see
-how in a moment.
-
-To edit the labels for (all the messages in) a thread, press 'l'. Type
-in the labels as a sequence of space-separated words. To cancel the
-input, press Ctrl-G.
-
-Many of these operations can be applied to a group of threads. Press
-'t' to tag a thread. Tag a couple, then press '=' to apply the next
-command to the set of threads. '=t', of course, will untag all tagged
-messages.
-
-Ok, let's try using labels and search. Press 'L' to do a quick label
-search. You'll be prompted for a label; simply hit enter to bring up
-scrollable list of all the labels you've ever used, along with some
-special labels (Draft, Starred, Sent, Spam, etc.). Highlight a label
-and press enter to view all the messages with that label.
-
-What you just did was actually a specific search. For a general search,
-press '\' (backslash---forward slash is used for in-buffer search,
-following console conventions). Now type in your query (again, Ctrl-G to
-cancel at any point.) You can just type in arbitrary text, which will be
-matched on a per-word basis against the bodies of all email in the
-index, or you can make use of the full Xapian query syntax
-(http://xapian.org/docs/queryparser.html):
-
-- Phrasal queries using double-quotes, e.g.: "three contiguous words"
-- Queries against a particular field using <field name>:<query>,
-  e.g.: label:ruby-talk, or from:matz@ruby-lang.org. (Fields include:
-  body, from, to, and subject.)
-- Force non-occurrence by -, e.g. -body:"hot soup".
-- If you have the chronic gem installed, date queries like
-  "before:today", "on:today", "after:yesterday", "after:(2 days ago)"
-  (parentheses required for multi-word descriptions).
-
-You can combine those all together. For example:
-
-     label:ruby-talk subject:\[ANN\] -rails on:today
-
-Play around with the search, and see the Xapian documentation for
-details on more sophisticated queries (date ranges, "within n words",
-etc.)
-
-At this point, you're well on your way to figuring out all the cool
-things Sup can do. By repeated application of the '?' key, see if you
-can figure out how to:
-
- - List some recent contacts
- - Easily search for all mail from a recent contact
- - Easily search for all mail from several recent contacts
- - Add someone to your address book
- - Postpone a message (i.e., save a draft)
- - Quickly re-edit a just-saved draft message
- - View the raw header of a message
- - Star an individual message, not just a thread
-
-There's one last thing to be aware of when using Sup: how it interacts
-with other email programs. As I described above, Sup stores data about
-messages in the index, but doesn't duplicate the message contents
-themselves. The messages remain on the source. If the index and the
-source every fall out of sync, e.g. due to another email client
-modifying the source, then Sup will be unable to operate on that
-source. For example, for mbox files, Sup stores a byte offset into the
-file for each message. If a message deleted from that file by another
-client, or even marked as read (yeah, mbox sucks), all succeeding
-offsets will be wrong.
-
-That's the bad news. The good news is that Sup is pretty good at being
-able to detect this type of situation, and fixing it is just a matter
-of running `sup-sync --changed` on the source. Sup will even tell you
-how to invoke sup-sync when it detects a problem. This is a
-complication you will almost certainly run in to if you use both Sup
-and another MUA on the same source, so it's good to be aware of it.
-
-Have fun, and email sup-talk@rubyforge.org if you have any problems!
-
-Appendix A: sup-add and sup-sync
----------------------------------
-
-Instead of using sup-config to add a new source, you can manually run
-`sup-add` with a URI pointing to it. The URI should be of the form:
-
-- mbox://path/to/a/filename, for an mbox file on disk.
-- maildir://path/to/a/filename, for a maildir directory on disk.
-
-Before you add the source, you need make three decisions. The first is
-whether you want Sup to regularly poll this source for new messages.
-By default it will, but if this is a source that will never have new
-messages, you can specify `--unusual`. Sup polls only "usual" sources
-when checking for new mail (unless you manually invoke sup-sync).
-
-The second is whether you want messages from the source to be
-automatically archived. An archived message will not show up in your
-inbox, but will be found when you search. (Your inbox in Sup is, by
-definition, the set of all all non-archived messages). Specify
-`--archive` to automatically archive all messages from the source. This
-is useful for sources that contain, for example, high-traffic mailing
-lists that you don't want polluting your inbox.
-
-The final decision is whether you want any labels automatically
-applied to messages from this source. You can use `--labels` to do this.
-
-Now that you've added the source, let's import all the current
-messages from it, by running sup-sync with the source URI. You can
-specify `--archive` to automatically archive all messages in this
-import; typically you'll want to specify this for every source you
-import except your actual inbox. You can also specify `--read` to mark
-all imported messages as read; the default is to preserve the
-read/unread status from the source.
-
-Sup-sync will now load all the messages from the source into the
-index. Depending on the size of the source, this may take a while.
-Don't panic! It's a one-time process.
-
-Appendix B: Automatically labeling incoming email
--------------------------------------------------
-
-One option is to filter incoming email into different sources with
-something like procmail, and have each of these sources auto-apply
-labels by using `sup-add --labels`.
-
-But the better option is to learn Ruby and write a before-add hook.
-This will allow you to apply labels based on whatever crazy logic you
-can come up with. See http://sup.rubyforge.org/wiki/wiki.pl?Hooks for
-examples.
-
-Appendix C: Reading blogs with Sup
-----------------------------------
-
-Really, blog posts should be read like emails are read---you should be
-able to mark them as unread, flag them, label them, etc. Use rss2email
-to transform RSS feeds into emails, direct them all into a source, and
-add that source to Sup. Voila!
diff --git a/lib/sup.rb b/lib/sup.rb
@@ -266,36 +266,6 @@ EOM
     end
   end
 
-  ## to be called by entry points in bin/, to ensure that
-  ## their versions match up against the library versions.
-  ##
-  ## this is a perennial source of bug reports from people
-  ## who both use git and have a gem version installed.
-  def check_library_version_against v
-    unless Redwood::VERSION == v
-      $stderr.puts <<EOS
-Error: version mismatch!
-The sup executable is at version #{v.inspect}.
-The sup libraries are at version #{Redwood::VERSION.inspect}.
-
-Your development environment may be picking up code from a
-rubygems installation of sup.
-
-If you're running from git with a commandline like
-
-  ruby -Ilib #{$0}
-
-try this instead:
-
-  RUBY_INVOCATION="ruby -Ilib" ruby -Ilib #{$0}
-
-You can also try `gem uninstall sup` and removing all Sup rubygems.
-
-EOS
-#' duh!
-      abort
-    end
-  end
 
   ## set up default configuration file
   def load_config filename
@@ -363,8 +333,7 @@ EOS
   end
 
   module_function :save_yaml_obj, :load_yaml_obj, :start, :finish,
-                  :report_broken_sources, :check_library_version_against,
-                  :load_config, :managers
+                  :report_broken_sources, :load_config, :managers
 end
 
 require 'sup/version'
@@ -373,9 +342,7 @@ require "sup/hook"
 require "sup/time"
 
 ## everything we need to get logging working
-require "sup/logger"
-Redwood::Logger.init.add_sink $stderr
-include Redwood::LogsStuff
+require "sup/logger/singleton"
 
 ## determine encoding and character set
 $encoding = Locale.current.charset
@@ -390,11 +357,11 @@ end
 require "sup/buffer"
 require "sup/keymap"
 require "sup/mode"
-require "sup/modes/scroll-mode"
-require "sup/modes/text-mode"
-require "sup/modes/log-mode"
+require "sup/modes/scroll_mode"
+require "sup/modes/text_mode"
+require "sup/modes/log_mode"
 require "sup/update"
-require "sup/message-chunks"
+require "sup/message_chunks"
 require "sup/message"
 require "sup/source"
 require "sup/mbox"
@@ -402,7 +369,7 @@ require "sup/maildir"
 require "sup/person"
 require "sup/account"
 require "sup/thread"
-require "sup/interactive-lock"
+require "sup/interactive_lock"
 require "sup/index"
 require "sup/textfield"
 require "sup/colormap"
@@ -413,31 +380,31 @@ require "sup/draft"
 require "sup/poll"
 require "sup/crypto"
 require "sup/undo"
-require "sup/horizontal-selector"
-require "sup/modes/line-cursor-mode"
-require "sup/modes/help-mode"
-require "sup/modes/edit-message-mode"
-require "sup/modes/edit-message-async-mode"
-require "sup/modes/compose-mode"
-require "sup/modes/resume-mode"
-require "sup/modes/forward-mode"
-require "sup/modes/reply-mode"
-require "sup/modes/label-list-mode"
-require "sup/modes/contact-list-mode"
-require "sup/modes/thread-view-mode"
-require "sup/modes/thread-index-mode"
-require "sup/modes/label-search-results-mode"
-require "sup/modes/search-results-mode"
-require "sup/modes/person-search-results-mode"
-require "sup/modes/inbox-mode"
-require "sup/modes/buffer-list-mode"
-require "sup/modes/poll-mode"
-require "sup/modes/file-browser-mode"
-require "sup/modes/completion-mode"
-require "sup/modes/console-mode"
+require "sup/horizontal_selector"
+require "sup/modes/line_cursor_mode"
+require "sup/modes/help_mode"
+require "sup/modes/edit_message_mode"
+require "sup/modes/edit_message_async_mode"
+require "sup/modes/compose_mode"
+require "sup/modes/resume_mode"
+require "sup/modes/forward_mode"
+require "sup/modes/reply_mode"
+require "sup/modes/label_list_mode"
+require "sup/modes/contact_list_mode"
+require "sup/modes/thread_view_mode"
+require "sup/modes/thread_index_mode"
+require "sup/modes/label_search_results_mode"
+require "sup/modes/search_results_mode"
+require "sup/modes/person_search_results_mode"
+require "sup/modes/inbox_mode"
+require "sup/modes/buffer_list_mode"
+require "sup/modes/poll_mode"
+require "sup/modes/file_browser_mode"
+require "sup/modes/completion_mode"
+require "sup/modes/console_mode"
 require "sup/sent"
 require "sup/search"
-require "sup/modes/search-list-mode"
+require "sup/modes/search_list_mode"
 require "sup/idle"
 
 $:.each do |base|
diff --git a/lib/sup/buffer.rb b/lib/sup/buffer.rb
@@ -267,7 +267,7 @@ EOS
 
   def handle_input c
     if @focus_buf
-      if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0]
+      if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY.ord
         @focus_buf.mode.cancel_search!
         @focus_buf.mark_dirty
       end
diff --git a/lib/sup/hook.rb b/lib/sup/hook.rb
@@ -1,3 +1,5 @@
+require "sup/util"
+
 module Redwood
 
 class HookManager
diff --git a/lib/sup/horizontal-selector.rb b/lib/sup/horizontal_selector.rb
diff --git a/lib/sup/index.rb b/lib/sup/index.rb
@@ -4,14 +4,12 @@ require 'xapian'
 require 'set'
 require 'fileutils'
 require 'monitor'
+require 'chronic'
+
+require "sup/interactive_lock"
+require "sup/hook"
+require "sup/logger/singleton"
 
-begin
-  require 'chronic'
-  $have_chronic = true
-rescue LoadError => e
-  debug "No 'chronic' gem detected. Install it for date/time query restrictions."
-  $have_chronic = false
-end
 
 if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,1]) < 0
 	fail "Xapian version 1.2.1 or higher required"
@@ -291,6 +289,11 @@ EOS
     end
   end
 
+  # Search messages. Returns an Enumerator.
+  def find_messages query_expr
+    enum_for :each_message, parse_query(query_expr)
+  end
+
   # wrap all future changes inside a transaction so they're done atomically
   def begin_transaction
     synchronize { @xapian.begin_transaction }
@@ -418,27 +421,25 @@ EOS
       end
     end
 
-    if $have_chronic
-      lastdate = 2<<32 - 1
-      firstdate = 0
-      subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
-        field, datestr = $1, ($3 || $4)
-        realdate = Chronic.parse datestr, :guess => false, :context => :past
-        if realdate
-          case field
-          when "after"
-            debug "chronic: translated #{field}:#{datestr} to #{realdate.end}"
-            "date:#{realdate.end.to_i}..#{lastdate}"
-          when "before"
-            debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}"
-            "date:#{firstdate}..#{realdate.end.to_i}"
-          else
-            debug "chronic: translated #{field}:#{datestr} to #{realdate}"
-            "date:#{realdate.begin.to_i}..#{realdate.end.to_i}"
-          end
+    lastdate = 2<<32 - 1
+    firstdate = 0
+    subs = subs.gsub(/\b(before|on|in|during|after):(\((.+?)\)\B|(\S+)\b)/) do
+      field, datestr = $1, ($3 || $4)
+      realdate = Chronic.parse datestr, :guess => false, :context => :past
+      if realdate
+        case field
+        when "after"
+          debug "chronic: translated #{field}:#{datestr} to #{realdate.end}"
+          "date:#{realdate.end.to_i}..#{lastdate}"
+        when "before"
+          debug "chronic: translated #{field}:#{datestr} to #{realdate.begin}"
+          "date:#{firstdate}..#{realdate.end.to_i}"
         else
-          raise ParseError, "can't understand date #{datestr.inspect}"
+          debug "chronic: translated #{field}:#{datestr} to #{realdate}"
+          "date:#{realdate.begin.to_i}..#{realdate.end.to_i}"
         end
+      else
+        raise ParseError, "can't understand date #{datestr.inspect}"
       end
     end
 
diff --git a/lib/sup/interactive-lock.rb b/lib/sup/interactive_lock.rb
diff --git a/lib/sup/logger.rb b/lib/sup/logger.rb
@@ -1,4 +1,4 @@
-require "sup"
+require "sup/util"
 require 'stringio'
 require 'thread'
 
diff --git a/lib/sup/logger/singleton.rb b/lib/sup/logger/singleton.rb
@@ -0,0 +1,10 @@
+# TODO: this is ugly. It's better to have a application singleton passed
+# down to lower level components instead of including logging methods in
+# class `Object'
+#
+# For now this is what we have to do.
+require "sup/logger"
+Redwood::Logger.init.add_sink $stderr
+class Object
+  include Redwood::LogsStuff
+end
diff --git a/lib/sup/message-chunks.rb b/lib/sup/message_chunks.rb
diff --git a/lib/sup/modes/buffer-list-mode.rb b/lib/sup/modes/buffer_list_mode.rb
diff --git a/lib/sup/modes/completion-mode.rb b/lib/sup/modes/completion_mode.rb
diff --git a/lib/sup/modes/compose-mode.rb b/lib/sup/modes/compose_mode.rb
diff --git a/lib/sup/modes/console-mode.rb b/lib/sup/modes/console-mode.rb
@@ -1,114 +0,0 @@
-require 'pp'
-
-module Redwood
-
-class Console
-  def initialize mode
-    @mode = mode
-  end
-
-  def query(query)
-    Enumerator.new(Index.instance, :each_message, Index.parse_query(query))
-  end
-
-  def add_labels(query, *labels)
-    query(query).each { |m| m.labels += labels; m.save Index }
-  end
-
-  def remove_labels(query, *labels)
-    query(query).each { |m| m.labels -= labels; m.save Index }
-  end
-
-  def xapian; Index.instance.instance_variable_get :@xapian; end
-
-  def loglevel; Redwood::Logger.level; end
-  def set_loglevel(level); Redwood::Logger.level = level; end
-
-  def special_methods; methods - Object.methods end
-
-  def puts x; @mode << "#{x.to_s.rstrip}\n" end
-  def p x; puts x.inspect end
-
-  ## files that won't cause problems when reloaded
-  ## TODO expand this list / convert to blacklist
-  RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb)
-
-  def reload
-    old_verbose = $VERBOSE
-    $VERBOSE = nil
-    old_features = $".dup
-    begin
-      fs = $".grep(/^sup\//)
-      fs.reject! { |f| not RELOAD_WHITELIST.member? f }
-      fs.each { |f| $".delete f }
-      fs.each do |f|
-        @mode << "reloading #{f}\n"
-        begin
-          require f
-        rescue LoadError => e
-          raise unless e.message =~ /no such file to load/
-        end
-      end
-    rescue Exception
-      $".clear
-      $".concat old_features
-      raise
-    ensure
-      $VERBOSE = old_verbose
-    end
-    true
-  end
-
-  def clear_hooks
-    HookManager.clear
-    nil
-  end
-end
-
-class ConsoleMode < LogMode
-  register_keymap do |k|
-    k.add :run, "Restart evaluation", 'e'
-  end
-
-  def initialize
-    super "console"
-    @console = Console.new self
-    @binding = @console.instance_eval { binding }
-  end
-
-  def execute cmd
-    begin
-      self << ">> #{cmd}\n"
-      ret = eval cmd, @binding
-      self << "=> #{ret.pretty_inspect}\n"
-    rescue Exception
-      self << "#{$!.class}: #{$!.message}\n"
-      clean_backtrace = []
-      $!.backtrace.each { |l| break if l =~ /console-mode/; clean_backtrace << l }
-      clean_backtrace.each { |l| self << "#{l}\n" }
-    end
-  end
-
-  def prompt
-    BufferManager.ask :console, ">> "
-  end
-
-  def run
-    self << <<EOS
-Sup v#{VERSION} console session started.
-Available extra commands: #{(@console.special_methods) * ", "}
-Ctrl-G stops evaluation; 'e' restarts it.
-
-EOS
-    while true
-      if(cmd = prompt)
-        execute cmd
-      else
-        self << "Console session ended."
-        break
-      end
-    end
-  end
-end
-
-end
diff --git a/lib/sup/modes/console_mode.rb b/lib/sup/modes/console_mode.rb
@@ -0,0 +1,125 @@
+require 'pp'
+
+require "sup/service/label_service"
+
+module Redwood
+
+class Console
+  def initialize mode
+    @mode = mode
+    @label_service = LabelService.new
+  end
+
+  def query(query)
+    Enumerator.new(Index.instance, :each_message, Index.parse_query(query))
+  end
+
+  def add_labels(query, *labels)
+    count = @label_service.add_labels(query, *labels)
+    print_buffer_dirty_msg count
+  end
+
+  def remove_labels(query, *labels)
+    count = @label_service.remove_labels(query, *labels)
+    print_buffer_dirty_msg count
+  end
+
+  def print_buffer_dirty_msg msg_count
+    puts "Scanned #{msg_count} messages."
+    puts "You might want to refresh open buffers with `@` key."
+  end
+  private :print_buffer_dirty_msg
+
+  def xapian; Index.instance.instance_variable_get :@xapian; end
+
+  def loglevel; Redwood::Logger.level; end
+  def set_loglevel(level); Redwood::Logger.level = level; end
+
+  def special_methods; public_methods - Object.methods end
+
+  def puts x; @mode << "#{x.to_s.rstrip}\n" end
+  def p x; puts x.inspect end
+
+  ## files that won't cause problems when reloaded
+  ## TODO expand this list / convert to blacklist
+  RELOAD_WHITELIST = %w(sup/index.rb sup/modes/console-mode.rb)
+
+  def reload
+    old_verbose = $VERBOSE
+    $VERBOSE = nil
+    old_features = $".dup
+    begin
+      fs = $".grep(/^sup\//)
+      fs.reject! { |f| not RELOAD_WHITELIST.member? f }
+      fs.each { |f| $".delete f }
+      fs.each do |f|
+        @mode << "reloading #{f}\n"
+        begin
+          require f
+        rescue LoadError => e
+          raise unless e.message =~ /no such file to load/
+        end
+      end
+    rescue Exception
+      $".clear
+      $".concat old_features
+      raise
+    ensure
+      $VERBOSE = old_verbose
+    end
+    true
+  end
+
+  def clear_hooks
+    HookManager.clear
+    nil
+  end
+end
+
+class ConsoleMode < LogMode
+  register_keymap do |k|
+    k.add :run, "Restart evaluation", 'e'
+  end
+
+  def initialize
+    super "console"
+    @console = Console.new self
+    @binding = @console.instance_eval { binding }
+  end
+
+  def execute cmd
+    begin
+      self << ">> #{cmd}\n"
+      ret = eval cmd, @binding
+      self << "=> #{ret.pretty_inspect}\n"
+    rescue Exception
+      self << "#{$!.class}: #{$!.message}\n"
+      clean_backtrace = []
+      $!.backtrace.each { |l| break if l =~ /console-mode/; clean_backtrace << l }
+      clean_backtrace.each { |l| self << "#{l}\n" }
+    end
+  end
+
+  def prompt
+    BufferManager.ask :console, ">> "
+  end
+
+  def run
+    self << <<EOS
+Sup v#{VERSION} console session started.
+Available extra commands: #{(@console.special_methods) * ", "}
+Ctrl-G stops evaluation; 'e' restarts it.
+
+EOS
+    while true
+      if(cmd = prompt)
+        execute cmd
+      else
+        self << "Console session ended."
+        break
+      end
+    end
+  end
+end
+
+end
diff --git a/lib/sup/modes/contact-list-mode.rb b/lib/sup/modes/contact_list_mode.rb
diff --git a/lib/sup/modes/edit-message-async-mode.rb b/lib/sup/modes/edit_message_async_mode.rb
diff --git a/lib/sup/modes/edit-message-mode.rb b/lib/sup/modes/edit_message_mode.rb
diff --git a/lib/sup/modes/file-browser-mode.rb b/lib/sup/modes/file_browser_mode.rb
diff --git a/lib/sup/modes/forward-mode.rb b/lib/sup/modes/forward_mode.rb
diff --git a/lib/sup/modes/help-mode.rb b/lib/sup/modes/help_mode.rb
diff --git a/lib/sup/modes/inbox-mode.rb b/lib/sup/modes/inbox_mode.rb
diff --git a/lib/sup/modes/label-list-mode.rb b/lib/sup/modes/label_list_mode.rb
diff --git a/lib/sup/modes/label-search-results-mode.rb b/lib/sup/modes/label_search_results_mode.rb
diff --git a/lib/sup/modes/line-cursor-mode.rb b/lib/sup/modes/line_cursor_mode.rb
diff --git a/lib/sup/modes/log-mode.rb b/lib/sup/modes/log_mode.rb
diff --git a/lib/sup/modes/person-search-results-mode.rb b/lib/sup/modes/person_search_results_mode.rb
diff --git a/lib/sup/modes/poll-mode.rb b/lib/sup/modes/poll_mode.rb
diff --git a/lib/sup/modes/reply-mode.rb b/lib/sup/modes/reply_mode.rb
diff --git a/lib/sup/modes/resume-mode.rb b/lib/sup/modes/resume_mode.rb
diff --git a/lib/sup/modes/scroll-mode.rb b/lib/sup/modes/scroll_mode.rb
diff --git a/lib/sup/modes/search-list-mode.rb b/lib/sup/modes/search_list_mode.rb
diff --git a/lib/sup/modes/search-results-mode.rb b/lib/sup/modes/search_results_mode.rb
diff --git a/lib/sup/modes/text-mode.rb b/lib/sup/modes/text_mode.rb
diff --git a/lib/sup/modes/thread-index-mode.rb b/lib/sup/modes/thread-index-mode.rb
@@ -1,980 +0,0 @@
-require 'set'
-
-module Redwood
-
-## subclasses should implement:
-## - is_relevant?
-
-class ThreadIndexMode < LineCursorMode
-  DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
-  MIN_FROM_WIDTH = 15
-  LOAD_MORE_THREAD_NUM = 20
-
-  HookManager.register "index-mode-size-widget", <<EOS
-Generates the per-thread size widget for each thread.
-Variables:
-  thread: The message thread to be formatted.
-EOS
-
-  HookManager.register "index-mode-date-widget", <<EOS
-Generates the per-thread date widget for each thread.
-Variables:
-  thread: The message thread to be formatted.
-EOS
-
-  HookManager.register "mark-as-spam", <<EOS
-This hook is run when a thread is marked as spam
-Variables:
-  thread: The message thread being marked as spam.
-EOS
-
-  register_keymap do |k|
-    k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
-    k.add_multi "Load all threads (! to confirm) :", '!' do |kk|
-      kk.add :load_all_threads, "Load all threads (may list a _lot_ of threads)", '!'
-    end
-    k.add :cancel_search, "Cancel current search", :ctrl_g
-    k.add :reload, "Refresh view", '@'
-    k.add :toggle_archived, "Toggle archived status", 'a'
-    k.add :toggle_starred, "Star or unstar all messages in thread", '*'
-    k.add :toggle_new, "Toggle new/read status of all messages in thread", 'N'
-    k.add :edit_labels, "Edit or add labels for a thread", 'l'
-    k.add :edit_message, "Edit message (drafts only)", 'e'
-    k.add :toggle_spam, "Mark/unmark thread as spam", 'S'
-    k.add :toggle_deleted, "Delete/undelete thread", 'd'
-    k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
-    k.add :flush_index, "Flush all changes now", '$'
-    k.add :jump_to_next_new, "Jump to next new thread", :tab
-    k.add :reply, "Reply to latest message in a thread", 'r'
-    k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
-    k.add :forward, "Forward latest message in a thread", 'f'
-    k.add :toggle_tagged, "Tag/untag selected thread", 't'
-    k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
-    k.add :tag_matching, "Tag matching threads", 'g'
-    k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
-    k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
-    k.add :undo, "Undo the previous action", 'u'
-  end
-
-  def initialize hidden_labels=[], load_thread_opts={}
-    super()
-    @mutex = Mutex.new # covers the following variables:
-    @threads = []
-    @hidden_threads = {}
-    @size_widget_width = nil
-    @size_widgets = []
-    @date_widget_width = nil
-    @date_widgets = []
-    @tags = Tagger.new self
-
-    ## these guys, and @text and @lines, are not covered
-    @load_thread = nil
-    @load_thread_opts = load_thread_opts
-    @hidden_labels = hidden_labels + LabelManager::HIDDEN_RESERVED_LABELS
-    @date_width = DATE_WIDTH
-
-    @interrupt_search = false
-
-    initialize_threads # defines @ts and @ts_mutex
-    update # defines @text and @lines
-
-    UpdateManager.register self
-
-    @save_thread_mutex = Mutex.new
-
-    @last_load_more_size = nil
-    to_load_more do |size|
-      next if @last_load_more_size == 0
-      load_threads :num => size,
-                   :when_done => lambda { |num| @last_load_more_size = num }
-    end
-  end
-
-  def unsaved?; dirty? end
-  def lines; @text.length; end
-  def [] i; @text[i]; end
-  def contains_thread? t; @threads.include?(t) end
-
-  def reload
-    drop_all_threads
-    UndoManager.clear
-    BufferManager.draw_screen
-    load_threads :num => buffer.content_height
-  end
-
-  ## open up a thread view window
-  def select t=nil, when_done=nil
-    t ||= cursor_thread or return
-
-    Redwood::reporting_thread("load messages for thread-view-mode") do
-      num = t.size
-      message = "Loading #{num.pluralize 'message body'}..."
-      BufferManager.say(message) do |sid|
-        t.each_with_index do |(m, *o), i|
-          next unless m
-          BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1
-          m.load_from_source!
-        end
-      end
-      mode = ThreadViewMode.new t, @hidden_labels, self
-      BufferManager.spawn t.subj, mode
-      BufferManager.draw_screen
-      mode.jump_to_first_open if $config[:jump_to_open_message]
-      BufferManager.draw_screen # lame TODO: make this unnecessary
-      ## the first draw_screen is needed before topline and botline
-      ## are set, and the second to show the cursor having moved
-
-      t.remove_label :unread
-      Index.save_thread t
-
-      update_text_for_line curpos
-      UpdateManager.relay self, :read, t.first
-      when_done.call if when_done
-    end
-  end
-
-  def multi_select threads
-    threads.each { |t| select t }
-  end
-
-  ## these two methods are called by thread-view-modes when the user
-  ## wants to view the previous/next thread without going back to
-  ## index-mode. we update the cursor as a convenience.
-  def launch_next_thread_after thread, &b
-    launch_another_thread thread, 1, &b
-  end
-
-  def launch_prev_thread_before thread, &b
-    launch_another_thread thread, -1, &b
-  end
-
-  def launch_another_thread thread, direction, &b
-    l = @lines[thread] or return
-    target_l = l + direction
-    t = @mutex.synchronize do
-      if target_l >= 0 && target_l < @threads.length
-        @threads[target_l]
-      end
-    end
-
-    if t # there's a next thread
-      set_cursor_pos target_l # move out of mutex?
-      select t, b
-    elsif b # no next thread. call the block anyways
-      b.call
-    end
-  end
-
-  def handle_single_message_labeled_update sender, m
-    ## no need to do anything different here; we don't differentiate
-    ## messages from their containing threads
-    handle_labeled_update sender, m
-  end
-
-  def handle_labeled_update sender, m
-    if(t = thread_containing(m))
-      l = @lines[t] or return
-      update_text_for_line l
-    elsif is_relevant?(m)
-      add_or_unhide m
-    end
-  end
-
-  def handle_simple_update sender, m
-    t = thread_containing(m) or return
-    l = @lines[t] or return
-    update_text_for_line l
-  end
-
-  %w(read unread archived starred unstarred).each do |state|
-    define_method "handle_#{state}_update" do |*a|
-      handle_simple_update(*a)
-    end
-  end
-
-  ## overwrite me!
-  def is_relevant? m; false; end
-
-  def handle_added_update sender, m
-    add_or_unhide m
-    BufferManager.draw_screen
-  end
-
-  def handle_updated_update sender, m
-    t = thread_containing(m) or return
-    l = @lines[t] or return
-    @ts_mutex.synchronize do
-      @ts.delete_message m
-      @ts.add_message m
-    end
-    Index.save_thread t
-    update_text_for_line l
-  end
-
-  def handle_location_deleted_update sender, m
-    t = thread_containing(m)
-    delete_thread t if t and t.first.id == m.id
-    @ts_mutex.synchronize do
-      @ts.delete_message m if t
-    end
-    update
-  end
-
-  def handle_single_message_deleted_update sender, m
-    @ts_mutex.synchronize do
-      return unless @ts.contains? m
-      @ts.remove_id m.id
-    end
-    update
-  end
-
-  def handle_deleted_update sender, m
-    t = @ts_mutex.synchronize { @ts.thread_for m }
-    return unless t
-    hide_thread t
-    update
-  end
-
-  def handle_spammed_update sender, m
-    t = @ts_mutex.synchronize { @ts.thread_for m }
-    return unless t
-    hide_thread t
-    update
-  end
-
-  def handle_undeleted_update sender, m
-    add_or_unhide m
-  end
-
-  def undo
-    UndoManager.undo
-  end
-
-  def update
-    old_cursor_thread = cursor_thread
-    @mutex.synchronize do
-      ## let's see you do THIS in python
-      @threads = @ts.threads.select { |t| !@hidden_threads.member?(t) }.select(&:has_message?).sort_by(&:sort_key)
-      @size_widgets = @threads.map { |t| size_widget_for_thread t }
-      @size_widget_width = @size_widgets.max_of { |w| w.display_length }
-      @date_widgets = @threads.map { |t| date_widget_for_thread t }
-      @date_widget_width = @date_widgets.max_of { |w| w.display_length }
-    end
-    set_cursor_pos @threads.index(old_cursor_thread)||curpos
-
-    regen_text
-  end
-
-  def edit_message
-    return unless(t = cursor_thread)
-    message, *crap = t.find { |m, *o| m.has_label? :draft }
-    if message
-      mode = ResumeMode.new message
-      BufferManager.spawn "Edit message", mode
-    else
-      BufferManager.flash "Not a draft message!"
-    end
-  end
-
-  ## returns an undo lambda
-  def actually_toggle_starred t
-    pos = curpos
-    if t.has_label? :starred # if ANY message has a star
-      t.remove_label :starred # remove from all
-      UpdateManager.relay self, :unstarred, t.first
-      lambda do
-        t.first.add_label :starred
-        UpdateManager.relay self, :starred, t.first
-        regen_text
-      end
-    else
-      t.first.add_label :starred # add only to first
-      UpdateManager.relay self, :starred, t.first
-      lambda do
-        t.remove_label :starred
-        UpdateManager.relay self, :unstarred, t.first
-        regen_text
-      end
-    end
-  end
-
-  def toggle_starred
-    t = cursor_thread or return
-    undo = actually_toggle_starred t
-    UndoManager.register "toggling thread starred status", undo, lambda { Index.save_thread t }
-    update_text_for_line curpos
-    cursor_down
-    Index.save_thread t
-  end
-
-  def multi_toggle_starred threads
-    UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
-      threads.map { |t| actually_toggle_starred t },
-      lambda { threads.each { |t| Index.save_thread t } }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  ## returns an undo lambda
-  def actually_toggle_archived t
-    thread = t
-    pos = curpos
-    if t.has_label? :inbox
-      t.remove_label :inbox
-      UpdateManager.relay self, :archived, t.first
-      lambda do
-        thread.apply_label :inbox
-        update_text_for_line pos
-        UpdateManager.relay self,:unarchived, thread.first
-      end
-    else
-      t.apply_label :inbox
-      UpdateManager.relay self, :unarchived, t.first
-      lambda do
-        thread.remove_label :inbox
-        update_text_for_line pos
-        UpdateManager.relay self, :unarchived, thread.first
-      end
-    end
-  end
-
-  ## returns an undo lambda
-  def actually_toggle_spammed t
-    thread = t
-    if t.has_label? :spam
-      t.remove_label :spam
-      add_or_unhide t.first
-      UpdateManager.relay self, :unspammed, t.first
-      lambda do
-        thread.apply_label :spam
-        self.hide_thread thread
-        UpdateManager.relay self,:spammed, thread.first
-      end
-    else
-      t.apply_label :spam
-      hide_thread t
-      UpdateManager.relay self, :spammed, t.first
-      lambda do
-        thread.remove_label :spam
-        add_or_unhide thread.first
-        UpdateManager.relay self,:unspammed, thread.first
-      end
-    end
-  end
-
-  ## returns an undo lambda
-  def actually_toggle_deleted t
-    if t.has_label? :deleted
-      t.remove_label :deleted
-      add_or_unhide t.first
-      UpdateManager.relay self, :undeleted, t.first
-      lambda do
-        t.apply_label :deleted
-        hide_thread t
-        UpdateManager.relay self, :deleted, t.first
-      end
-    else
-      t.apply_label :deleted
-      hide_thread t
-      UpdateManager.relay self, :deleted, t.first
-      lambda do
-        t.remove_label :deleted
-        add_or_unhide t.first
-        UpdateManager.relay self, :undeleted, t.first
-      end
-    end
-  end
-
-  def toggle_archived
-    t = cursor_thread or return
-    undo = actually_toggle_archived t
-    UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos },
-                         lambda { Index.save_thread t }
-    update_text_for_line curpos
-    Index.save_thread t
-  end
-
-  def multi_toggle_archived threads
-    undos = threads.map { |t| actually_toggle_archived t }
-    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text },
-                         lambda { threads.each { |t| Index.save_thread t } }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def toggle_new
-    t = cursor_thread or return
-    t.toggle_label :unread
-    update_text_for_line curpos
-    cursor_down
-    Index.save_thread t
-  end
-
-  def multi_toggle_new threads
-    threads.each { |t| t.toggle_label :unread }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def multi_toggle_tagged threads
-    @mutex.synchronize { @tags.drop_all_tags }
-    regen_text
-  end
-
-  def join_threads
-    ## this command has no non-tagged form. as a convenience, allow this
-    ## command to be applied to tagged threads without hitting ';'.
-    @tags.apply_to_tagged :join_threads
-  end
-
-  def multi_join_threads threads
-    @ts.join_threads threads or return
-    threads.each { |t| Index.save_thread t }
-    @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
-    update
-  end
-
-  def jump_to_next_new
-    n = @mutex.synchronize do
-      ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
-        (0 ... curpos).find { |i| @threads[i].has_label? :unread }
-    end
-    if n
-      ## jump there if necessary
-      jump_to_line n unless n >= topline && n < botline
-      set_cursor_pos n
-    else
-      BufferManager.flash "No new messages."
-    end
-  end
-
-  def toggle_spam
-    t = cursor_thread or return
-    multi_toggle_spam [t]
-  end
-
-  ## both spam and deleted have the curious characteristic that you
-  ## always want to hide the thread after either applying or removing
-  ## that label. in all thread-index-views except for
-  ## label-search-results-mode, when you mark a message as spam or
-  ## deleted, you want it to disappear immediately; in LSRM, you only
-  ## see deleted or spam emails, and when you undelete or unspam them
-  ## you also want them to disappear immediately.
-  def multi_toggle_spam threads
-    undos = threads.map { |t| actually_toggle_spammed t }
-    threads.each { |t| HookManager.run("mark-as-spam", :thread => t) }
-    UndoManager.register "marking/unmarking  #{threads.size.pluralize 'thread'} as spam",
-                         undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def toggle_deleted
-    t = cursor_thread or return
-    multi_toggle_deleted [t]
-  end
-
-  ## see comment for multi_toggle_spam
-  def multi_toggle_deleted threads
-    undos = threads.map { |t| actually_toggle_deleted t }
-    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
-                         undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
-    regen_text
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def kill
-    t = cursor_thread or return
-    multi_kill [t]
-  end
-
-  def flush_index
-    @flush_id = BufferManager.say "Flushing index..."
-    Index.save_index
-    BufferManager.clear @flush_id
-  end
-
-  ## m-m-m-m-MULTI-KILL
-  def multi_kill threads
-    UndoManager.register "killing/unkilling #{threads.size.pluralize 'threads'}" do
-      threads.each do |t|
-        if t.toggle_label :killed
-          add_or_unhide t.first
-        else
-          hide_thread t
-        end
-      end.each do |t|
-        UpdateManager.relay self, :labeled, t.first
-        Index.save_thread t
-      end
-      regen_text
-    end
-
-    threads.each do |t|
-      if t.toggle_label :killed
-        hide_thread t
-      else
-        add_or_unhide t.first
-      end
-    end.each do |t|
-      # send 'labeled'... this might be more specific
-      UpdateManager.relay self, :labeled, t.first
-      Index.save_thread t
-    end
-
-    killed, unkilled = threads.partition { |t| t.has_label? :killed }.map(&:size)
-    BufferManager.flash "#{killed.pluralize 'thread'} killed, #{unkilled} unkilled"
-    regen_text
-  end
-
-  def cleanup
-    UpdateManager.unregister self
-
-    if @load_thread
-      @load_thread.kill
-      BufferManager.clear @mbid if @mbid
-      sleep 0.1 # TODO: necessary?
-      BufferManager.erase_flash
-    end
-    dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
-    fail "dirty threads remain" unless dirty_threads.empty?
-    super
-  end
-
-  def toggle_tagged
-    t = cursor_thread or return
-    @mutex.synchronize { @tags.toggle_tag_for t }
-    update_text_for_line curpos
-    cursor_down
-  end
-
-  def toggle_tagged_all
-    @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } }
-    regen_text
-  end
-
-  def tag_matching
-    query = BufferManager.ask :search, "tag threads matching (regex): "
-    return if query.nil? || query.empty?
-    query = begin
-      /#{query}/i
-    rescue RegexpError => e
-      BufferManager.flash "error interpreting '#{query}': #{e.message}"
-      return
-    end
-    @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
-    regen_text
-  end
-
-  def apply_to_tagged; @tags.apply_to_tagged; end
-
-  def edit_labels
-    thread = cursor_thread or return
-    speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
-
-    old_labels = thread.labels
-    pos = curpos
-
-    keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
-
-    user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl.sort_by {|x| x.to_s}, @hidden_labels
-    return unless user_labels
-
-    thread.labels = Set.new(keepl) + user_labels
-    user_labels.each { |l| LabelManager << l }
-    update_text_for_line curpos
-
-    UndoManager.register "labeling thread" do
-      thread.labels = old_labels
-      update_text_for_line pos
-      UpdateManager.relay self, :labeled, thread.first
-      Index.save_thread thread
-    end
-
-    UpdateManager.relay self, :labeled, thread.first
-    Index.save_thread thread
-  end
-
-  def multi_edit_labels threads
-    user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
-    return unless user_labels
-
-    user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
-    hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
-    unless hl.empty?
-      BufferManager.flash "'#{hl}' is a reserved label!"
-      return
-    end
-
-    old_labels = threads.map { |t| t.labels.dup }
-
-    threads.each do |t|
-      user_labels.each do |(l, to_remove)|
-        if to_remove
-          t.remove_label l
-        else
-          t.apply_label l
-          LabelManager << l
-        end
-      end
-      UpdateManager.relay self, :labeled, t.first
-    end
-
-    regen_text
-
-    UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
-      threads.zip(old_labels).map do |t, old_labels|
-        t.labels = old_labels
-        UpdateManager.relay self, :labeled, t.first
-        Index.save_thread t
-      end
-      regen_text
-    end
-
-    threads.each { |t| Index.save_thread t }
-  end
-
-  def reply type_arg=nil
-    t = cursor_thread or return
-    m = t.latest_message
-    return if m.nil? # probably won't happen
-    m.load_from_source!
-    mode = ReplyMode.new m, type_arg
-    BufferManager.spawn "Reply to #{m.subj}", mode
-  end
-
-  def reply_all; reply :all; end
-
-  def forward
-    t = cursor_thread or return
-    m = t.latest_message
-    return if m.nil? # probably won't happen
-    m.load_from_source!
-    ForwardMode.spawn_nicely :message => m
-  end
-
-  def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
-    return if @load_thread # todo: wrap in mutex
-    @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do
-      num = load_n_threads n, opts
-      opts[:when_done].call(num) if opts[:when_done]
-      @load_thread = nil
-    end
-  end
-
-  ## TODO: figure out @ts_mutex in this method
-  def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
-    @interrupt_search = false
-    @mbid = BufferManager.say "Searching for threads..."
-
-    ts_to_load = n
-    ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads
-
-    orig_size = @ts.size
-    last_update = Time.now
-    @ts.load_n_threads(ts_to_load, opts) do |i|
-      if (Time.now - last_update) >= 0.25
-        BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid
-        update
-        BufferManager.draw_screen
-        last_update = Time.now
-      end
-      ::Thread.pass
-      break if @interrupt_search
-    end
-    @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } }
-
-    update
-    BufferManager.clear @mbid
-    @mbid = nil
-    BufferManager.draw_screen
-    @ts.size - orig_size
-  end
-  ignore_concurrent_calls :load_n_threads
-
-  def status
-    if (l = lines) == 0
-      "line 0 of 0"
-    else
-      "line #{curpos + 1} of #{l}"
-    end
-  end
-
-  def cancel_search
-    @interrupt_search = true
-  end
-
-  def load_all_threads
-    load_threads :num => -1
-  end
-
-  def load_threads opts={}
-    if opts[:num].nil?
-      n = ThreadIndexMode::LOAD_MORE_THREAD_NUM
-    else
-      n = opts[:num]
-    end
-
-    myopts = @load_thread_opts.merge({ :when_done => (lambda do |num|
-      opts[:when_done].call(num) if opts[:when_done]
-
-      if num > 0
-        BufferManager.flash "Found #{num.pluralize 'thread'}."
-      else
-        BufferManager.flash "No matches."
-      end
-    end)})
-
-    if opts[:background] || opts[:background].nil?
-      load_n_threads_background n, myopts
-    else
-      load_n_threads n, myopts
-    end
-  end
-  ignore_concurrent_calls :load_threads
-
-  def resize rows, cols
-    regen_text
-    super
-  end
-
-protected
-
-  def add_or_unhide m
-    @ts_mutex.synchronize do
-      if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
-        @ts.load_thread_for_message m, @load_thread_opts
-      end
-
-      @hidden_threads.delete @ts.thread_for(m)
-    end
-
-    update
-  end
-
-  def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
-
-  ## used to tag threads by query. this can be made a lot more sophisticated,
-  ## but for right now we'll do the obvious this.
-  def thread_matches? t, query
-    t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
-  end
-
-  def size_widget_for_thread t
-    HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
-  end
-
-  def date_widget_for_thread t
-    HookManager.run("index-mode-date-widget", :thread => t) || default_date_widget_for(t)
-  end
-
-  def cursor_thread; @mutex.synchronize { @threads[curpos] }; end
-
-  def drop_all_threads
-    @tags.drop_all_tags
-    initialize_threads
-    update
-  end
-
-  def delete_thread t
-    @mutex.synchronize do
-      i = @threads.index(t) or return
-      @threads.delete_at i
-      @size_widgets.delete_at i
-      @date_widgets.delete_at i
-      @tags.drop_tag_for t
-    end
-  end
-
-  def hide_thread t
-    @mutex.synchronize do
-      i = @threads.index(t) or return
-      raise "already hidden" if @hidden_threads[t]
-      @hidden_threads[t] = true
-      @threads.delete_at i
-      @size_widgets.delete_at i
-      @date_widgets.delete_at i
-      @tags.drop_tag_for t
-    end
-  end
-
-  def update_text_for_line l
-    return unless l # not sure why this happens, but it does, occasionally
-
-    need_update = false
-
-    @mutex.synchronize do
-      @size_widgets[l] = size_widget_for_thread @threads[l]
-      @date_widgets[l] = date_widget_for_thread @threads[l]
-
-      ## if a widget size has increased, we need to redraw everyone
-      need_update =
-        (@size_widgets[l].size > @size_widget_width) or
-        (@date_widgets[l].size > @date_widget_width)
-    end
-
-    if need_update
-      update
-    else
-      @text[l] = text_for_thread_at l
-      buffer.mark_dirty if buffer
-    end
-  end
-
-  def regen_text
-    threads = @mutex.synchronize { @threads }
-    @text = threads.map_with_index { |t, i| text_for_thread_at i }
-    @lines = threads.map_with_index { |t, i| [t, i] }.to_h
-    buffer.mark_dirty if buffer
-  end
-
-  def authors; map { |m, *o| m.from if m }.compact.uniq; end
-
-  ## preserve author order from the thread
-  def author_names_and_newness_for_thread t, limit=nil
-    new = {}
-    seen = {}
-    authors = t.map do |m, *o|
-      next unless m && m.from
-      new[m.from] ||= m.has_label?(:unread)
-      next if seen[m.from]
-      seen[m.from] = true
-      m.from
-    end.compact
-
-    result = []
-    authors.each do |a|
-      break if limit && result.size >= limit
-      name = if AccountManager.is_account?(a)
-        "me"
-      elsif t.authors.size == 1
-        a.mediumname
-      else
-        a.shortname
-      end
-
-      result << [name, new[a]]
-    end
-
-    if result.size == 1 && (author_and_newness = result.assoc("me"))
-      unless (recipients = t.participants - t.authors).empty?
-        result = recipients.collect do |r|
-          break if limit && result.size >= limit
-          name = (recipients.size == 1) ? r.mediumname : r.shortname
-          ["(#{name})", author_and_newness[1]]
-        end
-      end
-    end
-
-    result
-  end
-
-  AUTHOR_LIMIT = 5
-  def text_for_thread_at line
-    t, size_widget, date_widget = @mutex.synchronize do
-      [@threads[line], @size_widgets[line], @date_widgets[line]]
-    end
-
-    starred = t.has_label? :starred
-
-    ## format the from column
-    cur_width = 0
-    ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT
-    from = []
-    ann.each_with_index do |(name, newness), i|
-      break if cur_width >= from_width
-      last = i == ann.length - 1
-
-      abbrev =
-        if cur_width + name.display_length > from_width
-          name[0 ... (from_width - cur_width - 1)] + "."
-        elsif cur_width + name.display_length == from_width
-          name[0 ... (from_width - cur_width)]
-        else
-          if last
-            name[0 ... (from_width - cur_width)]
-          else
-            name[0 ... (from_width - cur_width - 1)] + ","
-          end
-        end
-
-      cur_width += abbrev.display_length
-
-      if last && from_width > cur_width
-        abbrev += " " * (from_width - cur_width)
-      end
-
-      from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev]
-    end
-
-    dp = t.direct_participants.any? { |p| AccountManager.is_account? p }
-    p = dp || t.participants.any? { |p| AccountManager.is_account? p }
-
-    subj_color =
-      if t.has_label?(:draft)
-        :index_draft_color
-      elsif t.has_label?(:unread)
-        :index_new_color
-      elsif starred
-        :index_starred_color
-      elsif Colormap.sym_is_defined(:index_subject_color)
-        :index_subject_color
-      else
-        :index_old_color
-      end
-
-    size_padding = @size_widget_width - size_widget.display_length
-    size_widget_text = sprintf "%#{size_padding}s%s", "", size_widget
-
-    date_padding = @date_widget_width - date_widget.display_length
-    date_widget_text = sprintf "%#{date_padding}s%s", "", date_widget
-
-    [
-      [:tagged_color, @tags.tagged?(t) ? ">" : " "],
-      [:date_color, date_widget_text],
-      [:starred_color, (starred ? "*" : " ")],
-    ] +
-      from +
-      [
-      [:size_widget_color, size_widget_text],
-      [:to_me_color, t.labels.member?(:attachment) ? "@" : " "],
-      [:to_me_color, dp ? ">" : (p ? '+' : " ")],
-    ] +
-      (t.labels - @hidden_labels).sort_by {|x| x.to_s}.map {
-            |label| [Colormap.sym_is_defined("label_#{label}_color".to_sym) || :label_color, "#{label} "]
-      } +
-      [
-      [subj_color, t.subj + (t.subj.empty? ? "" : " ")],
-      [:snippet_color, t.snippet],
-    ]
-  end
-
-  def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
-
-private
-
-  def default_size_widget_for t
-    case t.size
-    when 1
-      ""
-    else
-      "(#{t.size})"
-    end
-  end
-
-  def default_date_widget_for t
-    t.date.getlocal.to_nice_s
-  end
-
-  def from_width
-    [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
-  end
-
-  def initialize_threads
-    @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
-    @ts_mutex = Mutex.new
-    @hidden_threads = {}
-  end
-end
-
-end
diff --git a/lib/sup/modes/thread_index_mode.rb b/lib/sup/modes/thread_index_mode.rb
@@ -0,0 +1,980 @@
+require 'set'
+
+module Redwood
+
+## subclasses should implement:
+## - is_relevant?
+
+class ThreadIndexMode < LineCursorMode
+  DATE_WIDTH = Time::TO_NICE_S_MAX_LEN
+  MIN_FROM_WIDTH = 15
+  LOAD_MORE_THREAD_NUM = 20
+
+  HookManager.register "index-mode-size-widget", <<EOS
+Generates the per-thread size widget for each thread.
+Variables:
+  thread: The message thread to be formatted.
+EOS
+
+  HookManager.register "index-mode-date-widget", <<EOS
+Generates the per-thread date widget for each thread.
+Variables:
+  thread: The message thread to be formatted.
+EOS
+
+  HookManager.register "mark-as-spam", <<EOS
+This hook is run when a thread is marked as spam
+Variables:
+  thread: The message thread being marked as spam.
+EOS
+
+  register_keymap do |k|
+    k.add :load_threads, "Load #{LOAD_MORE_THREAD_NUM} more threads", 'M'
+    k.add_multi "Load all threads (! to confirm) :", '!' do |kk|
+      kk.add :load_all_threads, "Load all threads (may list a _lot_ of threads)", '!'
+    end
+    k.add :cancel_search, "Cancel current search", :ctrl_g
+    k.add :reload, "Refresh view", '@'
+    k.add :toggle_archived, "Toggle archived status", 'a'
+    k.add :toggle_starred, "Star or unstar all messages in thread", '*'
+    k.add :toggle_new, "Toggle new/read status of all messages in thread", 'N'
+    k.add :edit_labels, "Edit or add labels for a thread", 'l'
+    k.add :edit_message, "Edit message (drafts only)", 'e'
+    k.add :toggle_spam, "Mark/unmark thread as spam", 'S'
+    k.add :toggle_deleted, "Delete/undelete thread", 'd'
+    k.add :kill, "Kill thread (never to be seen in inbox again)", '&'
+    k.add :flush_index, "Flush all changes now", '$'
+    k.add :jump_to_next_new, "Jump to next new thread", :tab
+    k.add :reply, "Reply to latest message in a thread", 'r'
+    k.add :reply_all, "Reply to all participants of the latest message in a thread", 'G'
+    k.add :forward, "Forward latest message in a thread", 'f'
+    k.add :toggle_tagged, "Tag/untag selected thread", 't'
+    k.add :toggle_tagged_all, "Tag/untag all threads", 'T'
+    k.add :tag_matching, "Tag matching threads", 'g'
+    k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
+    k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
+    k.add :undo, "Undo the previous action", 'u'
+  end
+
+  def initialize hidden_labels=[], load_thread_opts={}
+    super()
+    @mutex = Mutex.new # covers the following variables:
+    @threads = []
+    @hidden_threads = {}
+    @size_widget_width = nil
+    @size_widgets = []
+    @date_widget_width = nil
+    @date_widgets = []
+    @tags = Tagger.new self
+
+    ## these guys, and @text and @lines, are not covered
+    @load_thread = nil
+    @load_thread_opts = load_thread_opts
+    @hidden_labels = hidden_labels + LabelManager::HIDDEN_RESERVED_LABELS
+    @date_width = DATE_WIDTH
+
+    @interrupt_search = false
+
+    initialize_threads # defines @ts and @ts_mutex
+    update # defines @text and @lines
+
+    UpdateManager.register self
+
+    @save_thread_mutex = Mutex.new
+
+    @last_load_more_size = nil
+    to_load_more do |size|
+      next if @last_load_more_size == 0
+      load_threads :num => size,
+                   :when_done => lambda { |num| @last_load_more_size = num }
+    end
+  end
+
+  def unsaved?; dirty? end
+  def lines; @text.length; end
+  def [] i; @text[i]; end
+  def contains_thread? t; @threads.include?(t) end
+
+  def reload
+    drop_all_threads
+    UndoManager.clear
+    BufferManager.draw_screen
+    load_threads :num => buffer.content_height
+  end
+
+  ## open up a thread view window
+  def select t=nil, when_done=nil
+    t ||= cursor_thread or return
+
+    Redwood::reporting_thread("load messages for thread-view-mode") do
+      num = t.size
+      message = "Loading #{num.pluralize 'message body'}..."
+      BufferManager.say(message) do |sid|
+        t.each_with_index do |(m, *_), i|
+          next unless m
+          BufferManager.say "#{message} (#{i}/#{num})", sid if t.size > 1
+          m.load_from_source!
+        end
+      end
+      mode = ThreadViewMode.new t, @hidden_labels, self
+      BufferManager.spawn t.subj, mode
+      BufferManager.draw_screen
+      mode.jump_to_first_open if $config[:jump_to_open_message]
+      BufferManager.draw_screen # lame TODO: make this unnecessary
+      ## the first draw_screen is needed before topline and botline
+      ## are set, and the second to show the cursor having moved
+
+      t.remove_label :unread
+      Index.save_thread t
+
+      update_text_for_line curpos
+      UpdateManager.relay self, :read, t.first
+      when_done.call if when_done
+    end
+  end
+
+  def multi_select threads
+    threads.each { |t| select t }
+  end
+
+  ## these two methods are called by thread-view-modes when the user
+  ## wants to view the previous/next thread without going back to
+  ## index-mode. we update the cursor as a convenience.
+  def launch_next_thread_after thread, &b
+    launch_another_thread thread, 1, &b
+  end
+
+  def launch_prev_thread_before thread, &b
+    launch_another_thread thread, -1, &b
+  end
+
+  def launch_another_thread thread, direction, &b
+    l = @lines[thread] or return
+    target_l = l + direction
+    t = @mutex.synchronize do
+      if target_l >= 0 && target_l < @threads.length
+        @threads[target_l]
+      end
+    end
+
+    if t # there's a next thread
+      set_cursor_pos target_l # move out of mutex?
+      select t, b
+    elsif b # no next thread. call the block anyways
+      b.call
+    end
+  end
+
+  def handle_single_message_labeled_update sender, m
+    ## no need to do anything different here; we don't differentiate
+    ## messages from their containing threads
+    handle_labeled_update sender, m
+  end
+
+  def handle_labeled_update sender, m
+    if(t = thread_containing(m))
+      l = @lines[t] or return
+      update_text_for_line l
+    elsif is_relevant?(m)
+      add_or_unhide m
+    end
+  end
+
+  def handle_simple_update sender, m
+    t = thread_containing(m) or return
+    l = @lines[t] or return
+    update_text_for_line l
+  end
+
+  %w(read unread archived starred unstarred).each do |state|
+    define_method "handle_#{state}_update" do |*a|
+      handle_simple_update(*a)
+    end
+  end
+
+  ## overwrite me!
+  def is_relevant? m; false; end
+
+  def handle_added_update sender, m
+    add_or_unhide m
+    BufferManager.draw_screen
+  end
+
+  def handle_updated_update sender, m
+    t = thread_containing(m) or return
+    l = @lines[t] or return
+    @ts_mutex.synchronize do
+      @ts.delete_message m
+      @ts.add_message m
+    end
+    Index.save_thread t
+    update_text_for_line l
+  end
+
+  def handle_location_deleted_update sender, m
+    t = thread_containing(m)
+    delete_thread t if t and t.first.id == m.id
+    @ts_mutex.synchronize do
+      @ts.delete_message m if t
+    end
+    update
+  end
+
+  def handle_single_message_deleted_update sender, m
+    @ts_mutex.synchronize do
+      return unless @ts.contains? m
+      @ts.remove_id m.id
+    end
+    update
+  end
+
+  def handle_deleted_update sender, m
+    t = @ts_mutex.synchronize { @ts.thread_for m }
+    return unless t
+    hide_thread t
+    update
+  end
+
+  def handle_spammed_update sender, m
+    t = @ts_mutex.synchronize { @ts.thread_for m }
+    return unless t
+    hide_thread t
+    update
+  end
+
+  def handle_undeleted_update sender, m
+    add_or_unhide m
+  end
+
+  def undo
+    UndoManager.undo
+  end
+
+  def update
+    old_cursor_thread = cursor_thread
+    @mutex.synchronize do
+      ## let's see you do THIS in python
+      @threads = @ts.threads.select { |t| !@hidden_threads.member?(t) }.select(&:has_message?).sort_by(&:sort_key)
+      @size_widgets = @threads.map { |t| size_widget_for_thread t }
+      @size_widget_width = @size_widgets.max_of { |w| w.display_length }
+      @date_widgets = @threads.map { |t| date_widget_for_thread t }
+      @date_widget_width = @date_widgets.max_of { |w| w.display_length }
+    end
+    set_cursor_pos @threads.index(old_cursor_thread)||curpos
+
+    regen_text
+  end
+
+  def edit_message
+    return unless(t = cursor_thread)
+    message, *_ = t.find { |m, *o| m.has_label? :draft }
+    if message
+      mode = ResumeMode.new message
+      BufferManager.spawn "Edit message", mode
+    else
+      BufferManager.flash "Not a draft message!"
+    end
+  end
+
+  ## returns an undo lambda
+  def actually_toggle_starred t
+    if t.has_label? :starred # if ANY message has a star
+      t.remove_label :starred # remove from all
+      UpdateManager.relay self, :unstarred, t.first
+      lambda do
+        t.first.add_label :starred
+        UpdateManager.relay self, :starred, t.first
+        regen_text
+      end
+    else
+      t.first.add_label :starred # add only to first
+      UpdateManager.relay self, :starred, t.first
+      lambda do
+        t.remove_label :starred
+        UpdateManager.relay self, :unstarred, t.first
+        regen_text
+      end
+    end
+  end
+
+  def toggle_starred
+    t = cursor_thread or return
+    undo = actually_toggle_starred t
+    UndoManager.register "toggling thread starred status", undo, lambda { Index.save_thread t }
+    update_text_for_line curpos
+    cursor_down
+    Index.save_thread t
+  end
+
+  def multi_toggle_starred threads
+    UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
+      threads.map { |t| actually_toggle_starred t },
+      lambda { threads.each { |t| Index.save_thread t } }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  ## returns an undo lambda
+  def actually_toggle_archived t
+    thread = t
+    pos = curpos
+    if t.has_label? :inbox
+      t.remove_label :inbox
+      UpdateManager.relay self, :archived, t.first
+      lambda do
+        thread.apply_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self,:unarchived, thread.first
+      end
+    else
+      t.apply_label :inbox
+      UpdateManager.relay self, :unarchived, t.first
+      lambda do
+        thread.remove_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self, :unarchived, thread.first
+      end
+    end
+  end
+
+  ## returns an undo lambda
+  def actually_toggle_spammed t
+    thread = t
+    if t.has_label? :spam
+      t.remove_label :spam
+      add_or_unhide t.first
+      UpdateManager.relay self, :unspammed, t.first
+      lambda do
+        thread.apply_label :spam
+        self.hide_thread thread
+        UpdateManager.relay self,:spammed, thread.first
+      end
+    else
+      t.apply_label :spam
+      hide_thread t
+      UpdateManager.relay self, :spammed, t.first
+      lambda do
+        thread.remove_label :spam
+        add_or_unhide thread.first
+        UpdateManager.relay self,:unspammed, thread.first
+      end
+    end
+  end
+
+  ## returns an undo lambda
+  def actually_toggle_deleted t
+    if t.has_label? :deleted
+      t.remove_label :deleted
+      add_or_unhide t.first
+      UpdateManager.relay self, :undeleted, t.first
+      lambda do
+        t.apply_label :deleted
+        hide_thread t
+        UpdateManager.relay self, :deleted, t.first
+      end
+    else
+      t.apply_label :deleted
+      hide_thread t
+      UpdateManager.relay self, :deleted, t.first
+      lambda do
+        t.remove_label :deleted
+        add_or_unhide t.first
+        UpdateManager.relay self, :undeleted, t.first
+      end
+    end
+  end
+
+  def toggle_archived
+    t = cursor_thread or return
+    undo = actually_toggle_archived t
+    UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos },
+                         lambda { Index.save_thread t }
+    update_text_for_line curpos
+    Index.save_thread t
+  end
+
+  def multi_toggle_archived threads
+    undos = threads.map { |t| actually_toggle_archived t }
+    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text },
+                         lambda { threads.each { |t| Index.save_thread t } }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def toggle_new
+    t = cursor_thread or return
+    t.toggle_label :unread
+    update_text_for_line curpos
+    cursor_down
+    Index.save_thread t
+  end
+
+  def multi_toggle_new threads
+    threads.each { |t| t.toggle_label :unread }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def multi_toggle_tagged threads
+    @mutex.synchronize { @tags.drop_all_tags }
+    regen_text
+  end
+
+  def join_threads
+    ## this command has no non-tagged form. as a convenience, allow this
+    ## command to be applied to tagged threads without hitting ';'.
+    @tags.apply_to_tagged :join_threads
+  end
+
+  def multi_join_threads threads
+    @ts.join_threads threads or return
+    threads.each { |t| Index.save_thread t }
+    @tags.drop_all_tags # otherwise we have tag pointers to invalid threads!
+    update
+  end
+
+  def jump_to_next_new
+    n = @mutex.synchronize do
+      ((curpos + 1) ... lines).find { |i| @threads[i].has_label? :unread } ||
+        (0 ... curpos).find { |i| @threads[i].has_label? :unread }
+    end
+    if n
+      ## jump there if necessary
+      jump_to_line n unless n >= topline && n < botline
+      set_cursor_pos n
+    else
+      BufferManager.flash "No new messages."
+    end
+  end
+
+  def toggle_spam
+    t = cursor_thread or return
+    multi_toggle_spam [t]
+  end
+
+  ## both spam and deleted have the curious characteristic that you
+  ## always want to hide the thread after either applying or removing
+  ## that label. in all thread-index-views except for
+  ## label-search-results-mode, when you mark a message as spam or
+  ## deleted, you want it to disappear immediately; in LSRM, you only
+  ## see deleted or spam emails, and when you undelete or unspam them
+  ## you also want them to disappear immediately.
+  def multi_toggle_spam threads
+    undos = threads.map { |t| actually_toggle_spammed t }
+    threads.each { |t| HookManager.run("mark-as-spam", :thread => t) }
+    UndoManager.register "marking/unmarking  #{threads.size.pluralize 'thread'} as spam",
+                         undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def toggle_deleted
+    t = cursor_thread or return
+    multi_toggle_deleted [t]
+  end
+
+  ## see comment for multi_toggle_spam
+  def multi_toggle_deleted threads
+    undos = threads.map { |t| actually_toggle_deleted t }
+    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
+                         undos, lambda { regen_text }, lambda { threads.each { |t| Index.save_thread t } }
+    regen_text
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def kill
+    t = cursor_thread or return
+    multi_kill [t]
+  end
+
+  def flush_index
+    @flush_id = BufferManager.say "Flushing index..."
+    Index.save_index
+    BufferManager.clear @flush_id
+  end
+
+  ## m-m-m-m-MULTI-KILL
+  def multi_kill threads
+    UndoManager.register "killing/unkilling #{threads.size.pluralize 'threads'}" do
+      threads.each do |t|
+        if t.toggle_label :killed
+          add_or_unhide t.first
+        else
+          hide_thread t
+        end
+      end.each do |t|
+        UpdateManager.relay self, :labeled, t.first
+        Index.save_thread t
+      end
+      regen_text
+    end
+
+    threads.each do |t|
+      if t.toggle_label :killed
+        hide_thread t
+      else
+        add_or_unhide t.first
+      end
+    end.each do |t|
+      # send 'labeled'... this might be more specific
+      UpdateManager.relay self, :labeled, t.first
+      Index.save_thread t
+    end
+
+    killed, unkilled = threads.partition { |t| t.has_label? :killed }.map(&:size)
+    BufferManager.flash "#{killed.pluralize 'thread'} killed, #{unkilled} unkilled"
+    regen_text
+  end
+
+  def cleanup
+    UpdateManager.unregister self
+
+    if @load_thread
+      @load_thread.kill
+      BufferManager.clear @mbid if @mbid
+      sleep 0.1 # TODO: necessary?
+      BufferManager.erase_flash
+    end
+    dirty_threads = @mutex.synchronize { (@threads + @hidden_threads.keys).select { |t| t.dirty? } }
+    fail "dirty threads remain" unless dirty_threads.empty?
+    super
+  end
+
+  def toggle_tagged
+    t = cursor_thread or return
+    @mutex.synchronize { @tags.toggle_tag_for t }
+    update_text_for_line curpos
+    cursor_down
+  end
+
+  def toggle_tagged_all
+    @mutex.synchronize { @threads.each { |t| @tags.toggle_tag_for t } }
+    regen_text
+  end
+
+  def tag_matching
+    query = BufferManager.ask :search, "tag threads matching (regex): "
+    return if query.nil? || query.empty?
+    query = begin
+      /#{query}/i
+    rescue RegexpError => e
+      BufferManager.flash "error interpreting '#{query}': #{e.message}"
+      return
+    end
+    @mutex.synchronize { @threads.each { |t| @tags.tag t if thread_matches?(t, query) } }
+    regen_text
+  end
+
+  def apply_to_tagged; @tags.apply_to_tagged; end
+
+  def edit_labels
+    thread = cursor_thread or return
+    speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
+
+    old_labels = thread.labels
+    pos = curpos
+
+    keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
+
+    user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl.sort_by {|x| x.to_s}, @hidden_labels
+    return unless user_labels
+
+    thread.labels = Set.new(keepl) + user_labels
+    user_labels.each { |l| LabelManager << l }
+    update_text_for_line curpos
+
+    UndoManager.register "labeling thread" do
+      thread.labels = old_labels
+      update_text_for_line pos
+      UpdateManager.relay self, :labeled, thread.first
+      Index.save_thread thread
+    end
+
+    UpdateManager.relay self, :labeled, thread.first
+    Index.save_thread thread
+  end
+
+  def multi_edit_labels threads
+    user_labels = BufferManager.ask_for_labels :labels, "Add/remove labels (use -label to remove): ", [], @hidden_labels
+    return unless user_labels
+
+    user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
+    hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
+    unless hl.empty?
+      BufferManager.flash "'#{hl}' is a reserved label!"
+      return
+    end
+
+    old_labels = threads.map { |t| t.labels.dup }
+
+    threads.each do |t|
+      user_labels.each do |(l, to_remove)|
+        if to_remove
+          t.remove_label l
+        else
+          t.apply_label l
+          LabelManager << l
+        end
+      end
+      UpdateManager.relay self, :labeled, t.first
+    end
+
+    regen_text
+
+    UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
+      threads.zip(old_labels).map do |t, old_labels|
+        t.labels = old_labels
+        UpdateManager.relay self, :labeled, t.first
+        Index.save_thread t
+      end
+      regen_text
+    end
+
+    threads.each { |t| Index.save_thread t }
+  end
+
+  def reply type_arg=nil
+    t = cursor_thread or return
+    m = t.latest_message
+    return if m.nil? # probably won't happen
+    m.load_from_source!
+    mode = ReplyMode.new m, type_arg
+    BufferManager.spawn "Reply to #{m.subj}", mode
+  end
+
+  def reply_all; reply :all; end
+
+  def forward
+    t = cursor_thread or return
+    m = t.latest_message
+    return if m.nil? # probably won't happen
+    m.load_from_source!
+    ForwardMode.spawn_nicely :message => m
+  end
+
+  def load_n_threads_background n=LOAD_MORE_THREAD_NUM, opts={}
+    return if @load_thread # todo: wrap in mutex
+    @load_thread = Redwood::reporting_thread("load threads for thread-index-mode") do
+      num = load_n_threads n, opts
+      opts[:when_done].call(num) if opts[:when_done]
+      @load_thread = nil
+    end
+  end
+
+  ## TODO: figure out @ts_mutex in this method
+  def load_n_threads n=LOAD_MORE_THREAD_NUM, opts={}
+    @interrupt_search = false
+    @mbid = BufferManager.say "Searching for threads..."
+
+    ts_to_load = n
+    ts_to_load = ts_to_load + @ts.size unless n == -1 # -1 means all threads
+
+    orig_size = @ts.size
+    last_update = Time.now
+    @ts.load_n_threads(ts_to_load, opts) do |i|
+      if (Time.now - last_update) >= 0.25
+        BufferManager.say "Loaded #{i.pluralize 'thread'}...", @mbid
+        update
+        BufferManager.draw_screen
+        last_update = Time.now
+      end
+      ::Thread.pass
+      break if @interrupt_search
+    end
+    @ts.threads.each { |th| th.labels.each { |l| LabelManager << l } }
+
+    update
+    BufferManager.clear @mbid
+    @mbid = nil
+    BufferManager.draw_screen
+    @ts.size - orig_size
+  end
+  ignore_concurrent_calls :load_n_threads
+
+  def status
+    if (l = lines) == 0
+      "line 0 of 0"
+    else
+      "line #{curpos + 1} of #{l}"
+    end
+  end
+
+  def cancel_search
+    @interrupt_search = true
+  end
+
+  def load_all_threads
+    load_threads :num => -1
+  end
+
+  def load_threads opts={}
+    if opts[:num].nil?
+      n = ThreadIndexMode::LOAD_MORE_THREAD_NUM
+    else
+      n = opts[:num]
+    end
+
+    myopts = @load_thread_opts.merge({ :when_done => (lambda do |num|
+      opts[:when_done].call(num) if opts[:when_done]
+
+      if num > 0
+        BufferManager.flash "Found #{num.pluralize 'thread'}."
+      else
+        BufferManager.flash "No matches."
+      end
+    end)})
+
+    if opts[:background] || opts[:background].nil?
+      load_n_threads_background n, myopts
+    else
+      load_n_threads n, myopts
+    end
+  end
+  ignore_concurrent_calls :load_threads
+
+  def resize rows, cols
+    regen_text
+    super
+  end
+
+protected
+
+  def add_or_unhide m
+    @ts_mutex.synchronize do
+      if (is_relevant?(m) || @ts.is_relevant?(m)) && !@ts.contains?(m)
+        @ts.load_thread_for_message m, @load_thread_opts
+      end
+
+      @hidden_threads.delete @ts.thread_for(m)
+    end
+
+    update
+  end
+
+  def thread_containing m; @ts_mutex.synchronize { @ts.thread_for m } end
+
+  ## used to tag threads by query. this can be made a lot more sophisticated,
+  ## but for right now we'll do the obvious this.
+  def thread_matches? t, query
+    t.subj =~ query || t.snippet =~ query || t.participants.any? { |x| x.longname =~ query }
+  end
+
+  def size_widget_for_thread t
+    HookManager.run("index-mode-size-widget", :thread => t) || default_size_widget_for(t)
+  end
+
+  def date_widget_for_thread t
+    HookManager.run("index-mode-date-widget", :thread => t) || default_date_widget_for(t)
+  end
+
+  def cursor_thread; @mutex.synchronize { @threads[curpos] }; end
+
+  def drop_all_threads
+    @tags.drop_all_tags
+    initialize_threads
+    update
+  end
+
+  def delete_thread t
+    @mutex.synchronize do
+      i = @threads.index(t) or return
+      @threads.delete_at i
+      @size_widgets.delete_at i
+      @date_widgets.delete_at i
+      @tags.drop_tag_for t
+    end
+  end
+
+  def hide_thread t
+    @mutex.synchronize do
+      i = @threads.index(t) or return
+      raise "already hidden" if @hidden_threads[t]
+      @hidden_threads[t] = true
+      @threads.delete_at i
+      @size_widgets.delete_at i
+      @date_widgets.delete_at i
+      @tags.drop_tag_for t
+    end
+  end
+
+  def update_text_for_line l
+    return unless l # not sure why this happens, but it does, occasionally
+
+    need_update = false
+
+    @mutex.synchronize do
+      @size_widgets[l] = size_widget_for_thread @threads[l]
+      @date_widgets[l] = date_widget_for_thread @threads[l]
+
+      ## if a widget size has increased, we need to redraw everyone
+      need_update =
+        (@size_widgets[l].size > @size_widget_width) or
+        (@date_widgets[l].size > @date_widget_width)
+    end
+
+    if need_update
+      update
+    else
+      @text[l] = text_for_thread_at l
+      buffer.mark_dirty if buffer
+    end
+  end
+
+  def regen_text
+    threads = @mutex.synchronize { @threads }
+    @text = threads.map_with_index { |t, i| text_for_thread_at i }
+    @lines = threads.map_with_index { |t, i| [t, i] }.to_h
+    buffer.mark_dirty if buffer
+  end
+
+  def authors; map { |m, *o| m.from if m }.compact.uniq; end
+
+  ## preserve author order from the thread
+  def author_names_and_newness_for_thread t, limit=nil
+    new = {}
+    seen = {}
+    authors = t.map do |m, *o|
+      next unless m && m.from
+      new[m.from] ||= m.has_label?(:unread)
+      next if seen[m.from]
+      seen[m.from] = true
+      m.from
+    end.compact
+
+    result = []
+    authors.each do |a|
+      break if limit && result.size >= limit
+      name = if AccountManager.is_account?(a)
+        "me"
+      elsif t.authors.size == 1
+        a.mediumname
+      else
+        a.shortname
+      end
+
+      result << [name, new[a]]
+    end
+
+    if result.size == 1 && (author_and_newness = result.assoc("me"))
+      unless (recipients = t.participants - t.authors).empty?
+        result = recipients.collect do |r|
+          break if limit && result.size >= limit
+          name = (recipients.size == 1) ? r.mediumname : r.shortname
+          ["(#{name})", author_and_newness[1]]
+        end
+      end
+    end
+
+    result
+  end
+
+  AUTHOR_LIMIT = 5
+  def text_for_thread_at line
+    t, size_widget, date_widget = @mutex.synchronize do
+      [@threads[line], @size_widgets[line], @date_widgets[line]]
+    end
+
+    starred = t.has_label? :starred
+
+    ## format the from column
+    cur_width = 0
+    ann = author_names_and_newness_for_thread t, AUTHOR_LIMIT
+    from = []
+    ann.each_with_index do |(name, newness), i|
+      break if cur_width >= from_width
+      last = i == ann.length - 1
+
+      abbrev =
+        if cur_width + name.display_length > from_width
+          name[0 ... (from_width - cur_width - 1)] + "."
+        elsif cur_width + name.display_length == from_width
+          name[0 ... (from_width - cur_width)]
+        else
+          if last
+            name[0 ... (from_width - cur_width)]
+          else
+            name[0 ... (from_width - cur_width - 1)] + ","
+          end
+        end
+
+      cur_width += abbrev.display_length
+
+      if last && from_width > cur_width
+        abbrev += " " * (from_width - cur_width)
+      end
+
+      from << [(newness ? :index_new_color : (starred ? :index_starred_color : :index_old_color)), abbrev]
+    end
+
+    is_me = AccountManager.method(:is_account?)
+    directly_participated = t.direct_participants.any?(&is_me)
+    participated = directly_participated || t.participants.any?(&is_me)
+
+    subj_color =
+      if t.has_label?(:draft)
+        :index_draft_color
+      elsif t.has_label?(:unread)
+        :index_new_color
+      elsif starred
+        :index_starred_color
+      elsif Colormap.sym_is_defined(:index_subject_color)
+        :index_subject_color
+      else
+        :index_old_color
+      end
+
+    size_padding = @size_widget_width - size_widget.display_length
+    size_widget_text = sprintf "%#{size_padding}s%s", "", size_widget
+
+    date_padding = @date_widget_width - date_widget.display_length
+    date_widget_text = sprintf "%#{date_padding}s%s", "", date_widget
+
+    [
+      [:tagged_color, @tags.tagged?(t) ? ">" : " "],
+      [:date_color, date_widget_text],
+      [:starred_color, (starred ? "*" : " ")],
+    ] +
+      from +
+      [
+      [:size_widget_color, size_widget_text],
+      [:to_me_color, t.labels.member?(:attachment) ? "@" : " "],
+      [:to_me_color, directly_participated ? ">" : (participated ? '+' : " ")],
+    ] +
+      (t.labels - @hidden_labels).sort_by {|x| x.to_s}.map {
+            |label| [Colormap.sym_is_defined("label_#{label}_color".to_sym) || :label_color, "#{label} "]
+      } +
+      [
+      [subj_color, t.subj + (t.subj.empty? ? "" : " ")],
+      [:snippet_color, t.snippet],
+    ]
+  end
+
+  def dirty?; @mutex.synchronize { (@hidden_threads.keys + @threads).any? { |t| t.dirty? } } end
+
+private
+
+  def default_size_widget_for t
+    case t.size
+    when 1
+      ""
+    else
+      "(#{t.size})"
+    end
+  end
+
+  def default_date_widget_for t
+    t.date.getlocal.to_nice_s
+  end
+
+  def from_width
+    [(buffer.content_width.to_f * 0.2).to_i, MIN_FROM_WIDTH].max
+  end
+
+  def initialize_threads
+    @ts = ThreadSet.new Index.instance, $config[:thread_by_subject]
+    @ts_mutex = Mutex.new
+    @hidden_threads = {}
+  end
+end
+
+end
diff --git a/lib/sup/modes/thread-view-mode.rb b/lib/sup/modes/thread_view_mode.rb
diff --git a/lib/sup/service/label_service.rb b/lib/sup/service/label_service.rb
@@ -0,0 +1,45 @@
+require "sup/index"
+
+module Redwood
+  # Provides label tweaking service to the user.
+  # Working as the backend of ConsoleMode.
+  #
+  # Should become the backend of bin/sup-tweak-labels in the future.
+  class LabelService
+    # @param index [Redwood::Index]
+    def initialize index=Index.instance
+      @index = index
+    end
+
+    def add_labels query, *labels
+      run_on_each_message(query) do |m|
+        labels.each {|l| m.add_label l }
+      end
+    end
+
+    def remove_labels query, *labels
+      run_on_each_message(query) do |m|
+        labels.each {|l| m.remove_label l }
+      end
+    end
+
+
+    private
+    def run_on_each_message query, &operation
+      count = 0
+
+      find_messages(query).each do |m|
+        operation.call(m)
+        @index.update_message_state m
+        count += 1
+      end
+
+      @index.save_index
+      count
+    end
+
+    def find_messages query
+      @index.find_messages(query)
+    end
+  end
+end
diff --git a/lib/sup/util/path.rb b/lib/sup/util/path.rb
@@ -0,0 +1,9 @@
+module Redwood
+  module Util
+    module Path
+      def self.expand(path)
+        ::File.expand_path(path)
+      end
+    end
+  end
+end
diff --git a/lib/sup/util/uri.rb b/lib/sup/util/uri.rb
@@ -0,0 +1,15 @@
+require "uri"
+
+require "sup/util/path"
+
+module Redwood
+  module Util
+    module Uri
+      def self.build(components)
+        components = components.dup
+        components[:path] = Path.expand(components[:path])
+        ::URI::Generic.build(components)
+      end
+    end
+  end
+end
diff --git a/sup.gemspec b/sup.gemspec
@@ -6,7 +6,7 @@ require 'sup/version'
 # Files
 SUP_EXECUTABLES = %w(sup sup-add sup-config sup-dump sup-import-dump
   sup-recover-sources sup-sync sup-sync-back sup-tweak-labels)
-SUP_EXTRA_FILES = %w(CONTRIBUTORS README.txt LICENSE History.txt ReleaseNotes)
+SUP_EXTRA_FILES = %w(CONTRIBUTORS README.md LICENSE History.txt ReleaseNotes)
 SUP_FILES =
   SUP_EXTRA_FILES +
   SUP_EXECUTABLES.map { |f| "bin/#{f}" } +
@@ -21,16 +21,16 @@ module Redwood
     s.authors = ["William Morgan", "Gaute Hope", "Hamish Downer", "Matthieu Rakotojaona"]
     s.email   = "sup-talk@rubyforge.org"
     s.summary = "A console-based email client with the best features of GMail, mutt and Emacs"
-    s.homepage = "https://github.com/sup-heliotrope/sup/wiki"
+    s.homepage = "http://supmua.org"
     s.description = <<-DESC
       Sup is a console-based email client for people with a lot of email.
 
-      - Handling mail from multiple mbox and Maildir sources
-      - GMail-like archiving and tagging
-      - Blazing fast full-text search with a rich query language
-      - Multiple accounts - pick the right one when sending mail
-      - Ruby-programmable hooks
-      - Automatically tracking recent contacts
+      * GMail-like thread-centered archiving, tagging and muting
+      * Handling mail from multiple mbox and Maildir sources
+      * Blazing fast full-text search with a rich query language
+      * Multiple accounts - pick the right one when sending mail
+      * Ruby-programmable hooks
+      * Automatically tracking recent contacts
 DESC
     s.license = 'GPL-2'
     s.files = SUP_FILES
@@ -44,8 +44,11 @@ DESC
     s.add_dependency "lockfile"
     s.add_dependency "mime-types", "~> 1"
     s.add_dependency "gettext"
+    s.add_dependency "chronic", "~> 0.9", ">= 0.9.1"
 
     s.add_development_dependency "bundler", "~> 1.3"
     s.add_development_dependency "rake"
+    s.add_development_dependency "minitest", "~> 4"
+    s.add_development_dependency "rr", "~> 1.0"
   end
 end
diff --git a/test/integration/test_label_service.rb b/test/integration/test_label_service.rb
@@ -0,0 +1,18 @@
+require "test_helper"
+
+require "sup/service/label_service"
+
+require "tmpdir"
+
+describe Redwood::LabelService do
+  let(:tmpdir) { Dir.mktmpdir }
+  after do
+    require "fileutils"
+    FileUtils.remove_entry_secure @tmpdir unless @tmpdir.nil?
+  end
+
+  describe "#add_labels" do
+    # Integration tests are hard to write at this moment :(
+    it "add labels to all messages matching the query"
+  end
+end
diff --git a/test/test_header_parsing.rb b/test/test_header_parsing.rb
@@ -1,12 +1,12 @@
 #!/usr/bin/ruby
 
-require 'test/unit'
+require 'test_helper'
 require 'sup'
 require 'stringio'
 
 include Redwood
 
-class TestMBoxParsing < Test::Unit::TestCase
+class TestMBoxParsing < Minitest::Unit::TestCase
 
   def setup
     @path = Dir.mktmpdir
@@ -150,10 +150,10 @@ To: a dear friend
 Hello again! Would you like to buy my products?
 EOS
     offset = l.next_offset 0
-    assert_not_nil offset
+    refute_nil offset
 
     offset = l.next_offset offset
-    assert_not_nil offset
+    refute_nil offset
 
     offset = l.next_offset offset
     assert_nil offset
diff --git a/test/test_helper.rb b/test/test_helper.rb
@@ -0,0 +1,7 @@
+require "rubygems" rescue nil
+require 'minitest/autorun'
+require "rr"
+
+class Minitest::Unit::TestCase
+  include ::RR::Adapters::MiniTest
+end
diff --git a/test/test_message.rb b/test/test_message.rb
@@ -1,6 +1,6 @@
 #!/usr/bin/ruby
 
-require 'test/unit'
+require 'test_helper'
 require 'sup'
 require 'stringio'
 
@@ -26,7 +26,7 @@ end
 
 module Redwood
 
-class TestMessage < Test::Unit::TestCase
+class TestMessage < ::Minitest::Unit::TestCase
 
   def setup
     @path = Dir.mktmpdir
@@ -289,7 +289,7 @@ EOS
     from = sup_message.from
     # very basic email address check
     assert_match(/\w+@\w+\.\w{2,4}/, from.email)
-    assert_not_nil(from.name)
+    refute_nil(from.name)
 
   end
 
@@ -327,11 +327,7 @@ EOS
 
     # read the message body chunks: no errors should reach this level
 
-    chunks = nil
-
-    assert_nothing_raised() do
-      chunks = sup_message.load_from_source!
-    end
+    chunks = sup_message.load_from_source!
 
     # the chunks list should be empty
 
@@ -426,10 +422,7 @@ EOS
 
     # read the message body chunks
 
-    assert_nothing_raised() do
-      chunks = sup_message.load_from_source!
-    end
-
+    sup_message.load_from_source!
   end
 
   def test_blank_header_lines
@@ -537,4 +530,3 @@ end
 end
 
 # vim:noai:ts=2:sw=2:
-
diff --git a/test/test_yaml_regressions.rb b/test/test_yaml_regressions.rb
@@ -1,4 +1,4 @@
-require 'test/unit'
+require 'test_helper'
 
 # Requiring 'yaml' before 'sup' in 1.9.x would get Psych loaded first
 # and becoming the default yamler.
@@ -6,7 +6,7 @@ require 'yaml'
 require 'sup'
 
 module Redwood
-  class TestYamlRegressions < Test::Unit::TestCase
+  class TestYamlRegressions < ::Minitest::Unit::TestCase
     def test_yamling_hash
       hsh = {:foo => 42}
       reloaded = YAML.load(hsh.to_yaml)
diff --git a/test/unit/service/test_label_service.rb b/test/unit/service/test_label_service.rb
@@ -0,0 +1,19 @@
+require "test_helper"
+
+require "sup/service/label_service"
+
+describe Redwood::LabelService do
+  describe "#add_labels" do
+    it "add labels to all messages matching the query" do
+      q = 'is:starred'
+      label = 'superstarred'
+      message = mock!.add_label(label).subject
+      index = mock!.find_messages(q){ [message] }.subject
+      mock(index).update_message_state(message)
+      mock(index).save_index
+
+      service = Redwood::LabelService.new(index)
+      service.add_labels q, label
+    end
+  end
+end
diff --git a/test/unit/util/uri.rb b/test/unit/util/uri.rb
@@ -0,0 +1,19 @@
+require "test_helper.rb"
+
+require "sup/util/uri"
+
+describe Redwood::Util::Uri do
+  describe ".build" do
+    it "builds uri from hash" do
+      components = {:path => "/var/mail/foo", :scheme => "mbox"}
+      uri = Redwood::Util::Uri.build(components)
+      uri.to_s.must_equal "mbox:/var/mail/foo"
+    end
+
+    it "expands ~ in path" do
+      components = {:path => "~/foo", :scheme => "maildir"}
+      uri = Redwood::Util::Uri.build(components)
+      uri.to_s.must_equal "maildir:#{ENV["HOME"]}/foo"
+    end
+  end
+end