sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 8818a4e2e8a4bf95df1d5d7ed5689bb5a34abb48
parent f09b4815984924d8d5a9b4c7d694994c94a3488f
Author: Whyme Lyu <callme5long@gmail.com>
Date:   Mon, 20 May 2013 07:14:21 -0700

Merge pull request #57 from 5long/develop

More Cleanups
Diffstat:
D doc/NewUserGuide.txt | 258 -------------------------------------------------------------------------------
M lib/sup.rb | 56 ++++++++++++++++++++++++++++----------------------------
R lib/sup/horizontal-selector.rb -> lib/sup/horizontal_selector.rb | 0
M lib/sup/index.rb | 45 ++++++++++++++++++---------------------------
R lib/sup/interactive-lock.rb -> lib/sup/interactive_lock.rb | 0
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
R lib/sup/modes/console-mode.rb -> lib/sup/modes/console_mode.rb | 0
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 | 950 -------------------------------------------------------------------------------
A lib/sup/modes/thread_index_mode.rb | 950 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
R lib/sup/modes/thread-view-mode.rb -> lib/sup/modes/thread_view_mode.rb | 0
M sup.gemspec | 1 +
33 files changed, 997 insertions(+), 1263 deletions(-)
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
@@ -326,11 +326,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"
@@ -338,7 +338,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"
@@ -349,31 +349,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/horizontal-selector.rb b/lib/sup/horizontal_selector.rb
diff --git a/lib/sup/index.rb b/lib/sup/index.rb
@@ -4,14 +4,7 @@ require 'xapian'
 require 'set'
 require 'fileutils'
 require 'monitor'
-
-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
+require 'chronic'
 
 if ([Xapian.major_version, Xapian.minor_version, Xapian.revision] <=> [1,2,1]) < 0
 	fail "Xapian version 1.2.1 or higher required"
@@ -388,27 +381,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/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
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,950 +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_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 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,950 @@
+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_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 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/sup.gemspec b/sup.gemspec
@@ -44,6 +44,7 @@ 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"