about summary refs log tree commit diff
diff options
context:
space:
mode:
authorJune McEnroe <june@causal.agency>2020-02-11 02:45:50 -0500
committerJune McEnroe <june@causal.agency>2020-02-11 02:45:50 -0500
commitd1913a4c63ae1d44b13d530b522eec0e7ebfbfd1 (patch)
tree2747c6ef852ddd8bfbafdac04c760a19c2640fad
parentRemove legacy code (diff)
parentAdd INSTALLING section to README (diff)
downloadcatgirl-d1913a4c63ae1d44b13d530b522eec0e7ebfbfd1.tar.gz
catgirl-d1913a4c63ae1d44b13d530b522eec0e7ebfbfd1.zip
Merge branch 'rewrite'
-rw-r--r--.gitignore4
-rw-r--r--LICENSE674
-rw-r--r--Makefile41
-rw-r--r--README.7126
-rw-r--r--catgirl.1497
-rw-r--r--chat.c259
-rw-r--r--chat.h241
-rw-r--r--command.c280
-rw-r--r--complete.c162
-rw-r--r--config.c137
-rwxr-xr-xconfigure11
-rw-r--r--edit.c207
-rw-r--r--handle.c637
-rw-r--r--irc.c250
-rw-r--r--ui.c974
-rw-r--r--url.c202
-rw-r--r--xdg.c134
17 files changed, 4836 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4cc4220
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+*.o
+catgirl
+config.mk
+tags
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    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 3 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, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b1ffede
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,41 @@
+PREFIX = /usr/local
+MANDIR = ${PREFIX}/share/man
+
+CFLAGS += -std=c11 -Wall -Wextra -Wpedantic
+LDLIBS = -lcrypto -ltls -lncursesw
+
+-include config.mk
+
+OBJS += chat.o
+OBJS += command.o
+OBJS += complete.o
+OBJS += config.o
+OBJS += edit.o
+OBJS += handle.o
+OBJS += irc.o
+OBJS += ui.o
+OBJS += url.o
+OBJS += xdg.o
+
+dev: tags all
+
+all: catgirl
+
+catgirl: ${OBJS}
+	${CC} ${LDFLAGS} ${OBJS} ${LDLIBS} -o $@
+
+${OBJS}: chat.h
+
+tags: *.h *.c
+	ctags -w *.h *.c
+
+clean:
+	rm -f tags catgirl ${OBJS}
+
+install: catgirl catgirl.1
+	install -d ${PREFIX}/bin ${MANDIR}/man1
+	install catgirl ${PREFIX}/bin
+	gzip -c catgirl.1 > ${MANDIR}/man1/catgirl.1.gz
+
+uninstall:
+	rm -f ${PREFIX}/bin/catgirl ${MANDIR}/man1/catgirl.1.gz
diff --git a/README.7 b/README.7
new file mode 100644
index 0000000..1478722
--- /dev/null
+++ b/README.7
@@ -0,0 +1,126 @@
+.Dd February 11, 2020
+.Dt README 7
+.Os "Causal Agency"
+.
+.Sh NAME
+.Nm catgirl
+.Nd IRC client
+.
+.Sh DESCRIPTION
+.Xr catgirl 1
+is a TLS-only terminal IRC client.
+.
+.Ss Notable Features
+.Bl -bullet
+.It
+Tab complete:
+most recently seen or mentioned nicks
+are completed first.
+Commas are inserted between multple nicks.
+.It
+Indicators:
+the prompt clearly shows whether input
+will be interpreted as a command
+or sent as a message.
+An indicator appears when scrolled up
+in the chat history.
+.It
+Nick coloring:
+color generation based on usernames
+remains stable across nick changes.
+Mentions of users in messages are colored.
+.It
+URL detection:
+recent URLs from a particular user
+or matching a substring
+can be opened or copied.
+.It
+History:
+window contents can be saved
+and restored on startup.
+.El
+.
+.Ss Non-features
+.Bl -bullet
+.It
+Dynamic configuration:
+all configuration happens
+in a simple text file
+or on the command line.
+.It
+Multi-network:
+a terminal multiplexer such as
+.Xr screen 1
+or
+.Xr tmux 1
+(or just your regular terminal emulator tabs)
+can be used to connect
+.Nm
+to multiple networks.
+.It
+Reconnection:
+when the connection to the server is lost,
+.Nm
+exits.
+It can be run in a loop
+or connected to a bouncer,
+such as
+.Lk https://git.causal.agency/pounce "pounce" .
+.It
+Cleartext IRC:
+TLS is now ubiquitous
+and certificates are easy to obtain.
+.El
+.
+.Sh INSTALLING
+.Nm
+requires LibreSSL
+.Pq Fl ltls
+and ncurses
+.Pq Fl lncursesw .
+It primarily targets
+.Fx
+and macOS,
+as well as Linux.
+.Bd -literal -offset indent
+\&./configure
+make all
+sudo make install PREFIX=/usr/local
+.Ed
+.
+.Sh FILES
+.Bl -tag -width "complete.c" -compact
+.It Pa chat.h
+global state and declarations
+.It Pa chat.c
+startup and event loop
+.It Pa irc.c
+IRC connection and parsing
+.It Pa ui.c
+curses interface
+.It Pa handle.c
+IRC message handling
+.It Pa command.c
+input command handling
+.It Pa edit.c
+line editing
+.It Pa complete.c
+tab complete
+.It Pa url.c
+URL detection
+.It Pa config.c
+configuration parsing
+.It Pa xdg.c
+XDG base directories
+.El
+.
+.Sh CONTRIBUTING
+The upstream URL of this project is
+.Aq Lk https://git.causal.agency/catgirl .
+I'm happy to receive contributions in any form at
+.Aq Mt june@causal.agency .
+For sending patches by email, see
+.Aq Lk https://git-send-email.io .
+.
+.Sh SEE ALSO
+.Xr catgirl 1
diff --git a/catgirl.1 b/catgirl.1
new file mode 100644
index 0000000..7c51b08
--- /dev/null
+++ b/catgirl.1
@@ -0,0 +1,497 @@
+.Dd February 10, 2020
+.Dt CATGIRL 1
+.Os
+.
+.Sh NAME
+.Nm catgirl
+.Nd IRC client
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl ev
+.Op Fl C Ar copy
+.Op Fl H Ar hash
+.Op Fl O Ar open
+.Op Fl a Ar auth
+.Op Fl c Ar cert
+.Op Fl h Ar host
+.Op Fl j Ar join
+.Op Fl k Ar priv
+.Op Fl n Ar nick
+.Op Fl p Ar port
+.Op Fl r Ar real
+.Op Fl s Ar save
+.Op Fl u Ar user
+.Op Fl w Ar pass
+.Op Ar config ...
+.
+.Sh DESCRIPTION
+The
+.Nm
+program is a TLS-only
+curses IRC client.
+.
+.Pp
+Options can be loaded from files
+listed on the command line.
+Files are searched for in
+.Pa $XDG_CONFIG_DIRS/catgirl
+unless the path starts with
+.Ql /
+or
+.Ql \&. .
+Each option is placed on a line,
+and lines beginning with
+.Ql #
+are ignored.
+The options are listed below
+following their corresponding flags.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl C Ar util , Cm copy = Ar util
+Set the utility used by
+.Ic /copy .
+The default is the first available of
+.Xr pbcopy 1 ,
+.Xr wl-copy 1 ,
+.Xr xclip 1 ,
+.Xr xsel 1 .
+.
+.It Fl H Ar hash , Cm hash = Ar hash
+Set the initial value of
+the nick color hash function.
+.
+.It Fl O Ar util , Cm open = Ar util
+Set the utility used by
+.Ic /open .
+The default is the first available of
+.Xr open 1 ,
+.Xr xdg-open 1 .
+.
+.It Fl a Ar user Ns : Ns Ar pass , Cm sasl-plain = Ar user Ns : Ns Ar pass
+Authenticate as
+.Ar user
+with
+.Ar pass
+using SASL PLAIN.
+Since this requires the account password
+in plain text,
+it is recommended to use SASL EXTERNAL instead with
+.Fl e .
+.
+.It Fl c Ar path , Cm cert = Ar path
+Load the TLS client certificate from
+.Ar path .
+If the private key is in a separate file,
+it is loaded with
+.Fl k .
+With
+.Fl e ,
+authenticate using SASL EXTERNAL.
+.
+.It Fl e , Cm sasl-external
+Authenticate using SASL EXTERNAL,
+also known as CertFP.
+The TLS client certificate is loaded with
+.Fl c .
+.
+.It Fl h Ar host , Cm host = Ar host
+Connect to
+.Ar host .
+.
+.It Fl j Ar join , Cm join = Ar join
+Join the comma-separated list of channels
+.Ar join .
+.
+.It Fl k Ar path , Cm priv = Ar priv
+Load the TLS client private key from
+.Ar path .
+.
+.It Fl n Ar nick , Cm nick = Ar nick
+Set nickname to
+.Ar nick .
+The default nickname is the user's name.
+.
+.It Fl p Ar port , Cm port = Ar port
+Connect to
+.Ar port .
+The default port is 6697.
+.
+.It Fl r Ar real , Cm real = Ar real
+Set realname to
+.Ar real .
+The default realname is the same as the nickname.
+.
+.It Fl s Ar name , Cm save = Ar name
+Load and save the contents of windows from
+.Ar name
+in
+.Pa $XDG_DATA_DIRS/catgirl ,
+or an absolute or relative path if
+.Ar name
+starts with
+.Ql /
+or
+.Ql \&. .
+.
+.It Fl u Ar user , Cm user = Ar user
+Set username to
+.Ar user .
+The default username is the same as the nickname.
+.
+.It Fl v , Cm debug
+Log raw IRC messages to the
+.Sy <debug>
+window
+as well as standard error
+if it is not a terminal.
+.
+.It Fl w Ar pass , Cm pass = Ar pass
+Log in with the server password
+.Ar pass .
+.El
+.
+.Sh COMMANDS
+Any unique prefix can be used to abbreviate a command.
+For example,
+.Ic /join
+can be typed
+.Ic /j .
+.
+.Ss Chat Commands
+.Bl -tag -width Ds
+.It Ic /join Ar channel
+Join a channel.
+.It Ic /me Op Ar action
+Send an action message.
+.It Ic /msg Ar nick message
+Send a private message.
+.It Ic /names
+List users in the channel.
+.It Ic /nick Ar nick
+Change nicknames.
+.It Ic /notice Ar message
+Send a notice.
+.It Ic /part Op Ar message
+Leave the channel.
+.It Ic /query Ar nick
+Start a private conversation.
+.It Ic /quit Op Ar message
+Quit IRC.
+.It Ic /quote Ar command
+Send a raw IRC command.
+.It Ic /topic Op Ar topic
+Show or set the topic of the channel.
+.It Ic /whois Ar nick
+Query information about a user.
+.El
+.
+.Ss UI Commands
+.Bl -tag -width Ds
+.It Ic /close Op Ar name | num
+Close the named, numbered or current window.
+.It Ic /copy Op Ar nick | substring
+Copy the most recent URL from
+.Ar nick
+or matching
+.Ar substring .
+.It Ic /debug
+Toggle logging in the
+.Sy <debug>
+window.
+.It Ic /help Op Ar search
+View this manual.
+Type
+.Ic q
+to return to
+.Nm .
+.It Ic /open Op Ar count
+Open each of
+.Ar count
+most recent URLs.
+.It Ic /open Ar nick | substring
+Open the most recent URL from
+.Ar nick
+or matching
+.Ar substring .
+.It Ic /window Ar name
+Switch to window by name.
+.It Ic /window Ar num , Ic / Ns Ar num
+Switch to window by number.
+.El
+.
+.Sh KEY BINDINGS
+The
+.Nm
+interface provides
+.Xr emacs 1 Ns -like
+line editing
+as well as keys for IRC formatting.
+The prefixes
+.Ic C-
+and
+.Ic M-
+represent the control and meta (alt)
+modifiers, respectively.
+.
+.Ss Line Editing
+.Bl -tag -width Ds -compact
+.It Ic C-a
+Move to beginning of line.
+.It Ic C-b
+Move left.
+.It Ic C-d
+Delete next character.
+.It Ic C-e
+Move to end of line.
+.It Ic C-f
+Move right.
+.It Ic C-k
+Delete to end of line.
+.It Ic C-u
+Delete to beginning of line.
+.It Ic C-w
+Delete previous word.
+.It Ic C-y
+Paste previously deleted text.
+.It Ic M-b
+Move to previous word.
+.It Ic M-d
+Delete next word.
+.It Ic M-f
+Move to next word.
+.It Ic Tab
+Complete nick, channel or command.
+.El
+.
+.Ss Window Keys
+.Bl -tag -width Ds -compact
+.It Ic C-l
+Redraw the UI.
+.It Ic C-n
+Switch to next window.
+.It Ic C-o
+Switch to previously selected window.
+.It Ic C-p
+Switch to previous window.
+.It Ic M-/
+Switch to previously selected window.
+.It Ic M-a
+Cycle through unread windows.
+.It Ic M-l
+List the contents of the window
+without word-wrapping.
+Press
+.Ic Enter
+to return to
+.Nm .
+.It Ic M-m
+Insert a blank line in the window.
+.It Ic M- Ns Ar n
+Switch to window by number 0\(en9.
+.It Ic M-u
+Scroll to first unread line.
+.El
+.
+.Ss IRC Formatting
+.Bl -tag -width Ds -compact
+.It Ic C-z b
+Toggle bold.
+.It Ic C-z c
+Set or reset color.
+.It Ic C-z i
+Toggle italics.
+.It Ic C-z o
+Reset formatting.
+.It Ic C-z r
+Toggle reverse color.
+.It Ic C-z u
+Toggle underline.
+.El
+.
+.Pp
+To set colors, follow
+.Ic C-z c
+by one or two digits for the foreground color,
+optionally followed by a comma
+and one or two digits for the background color.
+To reset color, follow
+.Ic C-z c
+by a non-digit.
+.
+.Pp
+The color numbers are as follows:
+.Pp
+.Bl -column "99" "orange (dark yellow)" "15" "pink (light magenta)"
+.It \ 0 Ta white Ta \ 8 Ta yellow
+.It \ 1 Ta black Ta \ 9 Ta light green
+.It \ 2 Ta blue Ta 10 Ta cyan
+.It \ 3 Ta green Ta 11 Ta light cyan
+.It \ 4 Ta red Ta 12 Ta light blue
+.It \ 5 Ta brown (dark red) Ta 13 Ta pink (light magenta)
+.It \ 6 Ta magenta Ta 14 Ta gray
+.It \ 7 Ta orange (dark yellow) Ta 15 Ta light gray
+.It 99 Ta default
+.El
+.
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa $XDG_CONFIG_DIRS/catgirl
+Configuration files are searched for first in
+.Ev $XDG_CONFIG_HOME ,
+usually
+.Pa ~/.config ,
+followed by the colon-separated list of paths
+.Ev $XDG_CONFIG_DIRS ,
+usually
+.Pa /etc/xdg .
+.It Pa ~/.config/catgirl
+The most likely location of configuration files.
+.
+.It Pa $XDG_DATA_DIRS/catgirl
+Save files are searched for first in
+.Ev $XDG_DATA_HOME ,
+usually
+.Pa ~/.local/share ,
+followed by the colon-separated list of paths
+.Ev $XDG_DATA_DIRS ,
+usually
+.Pa /usr/local/share:/usr/share .
+.It Pa ~/.local/share/catgirl
+The most likely location of save files.
+.El
+.
+.Sh EXAMPLES
+Command line:
+.Bd -literal -offset indent
+catgirl -h chat.freenode.net -j '#ascii.town'
+.Ed
+.Pp
+Configuration file:
+.Bd -literal -offset indent
+host = chat.freenode.net
+join = #ascii.town
+.Ed
+.
+.Sh STANDARDS
+.Bl -item
+.It
+.Rs
+.%A Kiyoshi Aman
+.%T IRCv3.1 extended-join Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/extended-join-3.1
+.Re
+.
+.It
+.Rs
+.%A Waldo Bastian
+.%A Ryan Lortie
+.%A Lennart Poettering
+.%T XDG Base Directory Specification
+.%D November 24, 2010
+.%U https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
+.Re
+.
+.It
+.Rs
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
+.%A James Wheare
+.%T IRCv3.2 server-time Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/server-time-3.2
+.Re
+.
+.It
+.Rs
+.%A Lee Hardy
+.%A Perry Lorier
+.%A Kevin L. Mitchell
+.%A William Pitcock
+.%T IRCv3.1 Client Capability Negotiation
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/core/capability-negotiation-3.1.html
+.Re
+.
+.It
+.Rs
+.%A S. Josefsson
+.%T The Base16, Base32, and Base64 Data Encodings
+.%I IETF
+.%N RFC 4648
+.%D October 2006
+.%U https://tools.ietf.org/html/rfc4648
+.Re
+.
+.It
+.Rs
+.%A C. Kalt
+.%T Internet Relay Chat: Client Protocol
+.%I IETF
+.%N RFC 2812
+.%D April 2000
+.%U https://tools.ietf.org/html/rfc2812
+.Re
+.
+.It
+.Rs
+.%A Mantas Mikul\[u0117]nas
+.%T IRCv3.2 userhost-in-names Extension
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/userhost-in-names-3.2
+.Re
+.
+.It
+.Rs
+.%A Daniel Oaks
+.%T IRC Formatting
+.%I ircdocs
+.%U https://modern.ircdocs.horse/formatting.html
+.Re
+.
+.It
+.Rs
+.%A William Pitcock
+.%A Jilles Tjoelker
+.%T IRCv3.1 SASL Authentication
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/sasl-3.1.html
+.Re
+.
+.It
+.Rs
+.%A Alexey Sokolov
+.%A St\('ephan Kochen
+.%A Kyle Fuller
+.%A Kiyoshi Aman
+.%A James Wheare
+.%T IRCv3 Message Tags
+.%I IRCv3 Working Group
+.%U https://ircv3.net/specs/extensions/message-tags
+.Re
+.
+.It
+.Rs
+.%A K. Zeilenga, Ed.
+.%T The PLAIN Simple Authentication and Security Layer (SASL) Mechanism
+.%I IETF
+.%N RFC 4616
+.%D August 2006
+.%U https://tools.ietf.org/html/rfc4616
+.Re
+.El
+.
+.Sh AUTHORS
+.An June Bug Aq Mt june@causal.agency
+.
+.Sh BUGS
+Send mail to
+.Aq Mt june@causal.agency
+or join
+.Li #ascii.town
+on
+.Li chat.freenode.net .
diff --git a/chat.c b/chat.c
new file mode 100644
index 0000000..f854a33
--- /dev/null
+++ b/chat.c
@@ -0,0 +1,259 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <locale.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+#include "chat.h"
+
+char *idNames[IDCap] = {
+	[None] = "<none>",
+	[Debug] = "<debug>",
+	[Network] = "<network>",
+};
+
+enum Color idColors[IDCap] = {
+	[None] = Black,
+	[Debug] = Green,
+	[Network] = Gray,
+};
+
+size_t idNext = Network + 1;
+
+struct Self self = { .color = Default };
+
+static const char *save;
+static void exitSave(void) {
+	int error = uiSave(save);
+	if (error) {
+		warn("%s", save);
+		_exit(EX_IOERR);
+	}
+}
+
+uint32_t hashInit;
+
+int procPipe[2] = { -1, -1 };
+
+static void pipeRead(void) {
+	char buf[1024];
+	ssize_t len = read(procPipe[0], buf, sizeof(buf) - 1);
+	if (len < 0) err(EX_IOERR, "read");
+	if (!len) return;
+	buf[len - 1] = '\0';
+	char *ptr = buf;
+	while (ptr) {
+		char *line = strsep(&ptr, "\n");
+		uiFormat(Network, Warm, NULL, "%s", line);
+	}
+}
+
+static volatile sig_atomic_t signals[NSIG];
+static void signalHandler(int signal) {
+	signals[signal] = 1;
+}
+
+int main(int argc, char *argv[]) {
+	setlocale(LC_CTYPE, "");
+
+	bool insecure = false;
+	const char *host = NULL;
+	const char *port = "6697";
+	const char *cert = NULL;
+	const char *priv = NULL;
+
+	bool sasl = false;
+	const char *pass = NULL;
+	const char *nick = NULL;
+	const char *user = NULL;
+	const char *real = NULL;
+
+	const char *Opts = "!C:H:O:a:c:eh:j:k:n:p:r:s:u:vw:";
+	const struct option LongOpts[] = {
+		{ "insecure", no_argument, NULL, '!' },
+		{ "copy", required_argument, NULL, 'C' },
+		{ "hash", required_argument, NULL, 'H' },
+		{ "open", required_argument, NULL, 'O' },
+		{ "sasl-plain", required_argument, NULL, 'a' },
+		{ "cert", required_argument, NULL, 'c' },
+		{ "sasl-external", no_argument, NULL, 'e' },
+		{ "host", required_argument, NULL, 'h' },
+		{ "join", required_argument, NULL, 'j' },
+		{ "priv", required_argument, NULL, 'k' },
+		{ "nick", required_argument, NULL, 'n' },
+		{ "port", required_argument, NULL, 'p' },
+		{ "real", required_argument, NULL, 'r' },
+		{ "save", required_argument, NULL, 's' },
+		{ "user", required_argument, NULL, 'u' },
+		{ "debug", no_argument, NULL, 'v' },
+		{ "pass", required_argument, NULL, 'w' },
+		{0},
+	};
+
+	int opt;
+	while (0 < (opt = getopt_config(argc, argv, Opts, LongOpts, NULL))) {
+		switch (opt) {
+			break; case '!': insecure = true;
+			break; case 'C': urlCopyUtil = optarg;
+			break; case 'H': hashInit = strtoul(optarg, NULL, 0);
+			break; case 'O': urlOpenUtil = optarg;
+			break; case 'a': sasl = true; self.plain = optarg;
+			break; case 'c': cert = optarg;
+			break; case 'e': sasl = true;
+			break; case 'h': host = optarg;
+			break; case 'j': self.join = optarg;
+			break; case 'k': priv = optarg;
+			break; case 'n': nick = optarg;
+			break; case 'p': port = optarg;
+			break; case 'r': real = optarg;
+			break; case 's': save = optarg;
+			break; case 'u': user = optarg;
+			break; case 'v': self.debug = true;
+			break; case 'w': pass = optarg;
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (!host) errx(EX_USAGE, "host required");
+
+	if (!nick) nick = getenv("USER");
+	if (!nick) errx(EX_CONFIG, "USER unset");
+	if (!user) user = nick;
+	if (!real) real = nick;
+
+	set(&self.network, host);
+	set(&self.chanTypes, "#&");
+	set(&self.prefixes, "@+");
+	commandComplete();
+
+	FILE *certFile = NULL;
+	FILE *privFile = NULL;
+	if (cert) {
+		certFile = configOpen(cert, "r");
+		if (!certFile) return EX_NOINPUT;
+	}
+	if (priv) {
+		privFile = configOpen(priv, "r");
+		if (!privFile) return EX_NOINPUT;
+	}
+	ircConfig(insecure, certFile, privFile);
+	if (certFile) fclose(certFile);
+	if (privFile) fclose(privFile);
+
+	uiInit();
+	if (save) {
+		uiLoad(save);
+		atexit(exitSave);
+	}
+	uiShowID(Network);
+	uiFormat(Network, Cold, NULL, "Traveling...");
+	uiDraw();
+	
+	int irc = ircConnect(host, port);
+	if (pass) ircFormat("PASS :%s\r\n", pass);
+	if (sasl) ircFormat("CAP REQ :sasl\r\n");
+	ircFormat("CAP LS\r\n");
+	ircFormat("NICK :%s\r\n", nick);
+	ircFormat("USER %s 0 * :%s\r\n", user, real);
+
+	signal(SIGHUP, signalHandler);
+	signal(SIGINT, signalHandler);
+	signal(SIGTERM, signalHandler);
+	signal(SIGCHLD, signalHandler);
+	sig_t cursesWinch = signal(SIGWINCH, signalHandler);
+
+	int error = pipe(procPipe);
+	if (error) err(EX_OSERR, "pipe");
+
+	fcntl(irc, F_SETFD, FD_CLOEXEC);
+	fcntl(procPipe[0], F_SETFD, FD_CLOEXEC);
+	fcntl(procPipe[1], F_SETFD, FD_CLOEXEC);
+
+	struct pollfd fds[3] = {
+		{ .events = POLLIN, .fd = STDIN_FILENO },
+		{ .events = POLLIN, .fd = irc },
+		{ .events = POLLIN, .fd = procPipe[0] },
+	};
+	while (!self.quit) {
+		int nfds = poll(fds, ARRAY_LEN(fds), -1);
+		if (nfds < 0 && errno != EINTR) err(EX_IOERR, "poll");
+		if (nfds > 0) {
+			if (fds[0].revents) uiRead();
+			if (fds[1].revents) ircRecv();
+			if (fds[2].revents) pipeRead();
+		}
+
+		if (signals[SIGHUP]) self.quit = "zzz";
+		if (signals[SIGINT] || signals[SIGTERM]) break;
+
+		if (signals[SIGCHLD]) {
+			signals[SIGCHLD] = 0;
+			int status;
+			while (0 < waitpid(-1, &status, WNOHANG)) {
+				if (WIFEXITED(status) && WEXITSTATUS(status)) {
+					uiFormat(
+						Network, Warm, NULL,
+						"Process exits with status %d", WEXITSTATUS(status)
+					);
+				} else if (WIFSIGNALED(status)) {
+					uiFormat(
+						Network, Warm, NULL,
+						"Process terminates from %s",
+						strsignal(WTERMSIG(status))
+					);
+				}
+			}
+			uiShow();
+		}
+
+		if (signals[SIGWINCH]) {
+			signals[SIGWINCH] = 0;
+			cursesWinch(SIGWINCH);
+			// XXX: For some reason, calling uiDraw() here is the only way to
+			// get uiRead() to properly receive KEY_RESIZE.
+			uiDraw();
+			uiRead();
+		}
+
+		uiDraw();
+	}
+
+	if (self.quit) {
+		ircFormat("QUIT :%s\r\n", self.quit);
+	} else {
+		ircFormat("QUIT\r\n");
+	}
+	struct Message msg = {
+		.nick = self.nick,
+		.user = self.user,
+		.cmd = "QUIT",
+		.params[0] = self.quit,
+	};
+	handle(msg);
+
+	uiHide();
+}
diff --git a/chat.h b/chat.h
new file mode 100644
index 0000000..f47b244
--- /dev/null
+++ b/chat.h
@@ -0,0 +1,241 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <getopt.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <string.h>
+#include <sysexits.h>
+#include <time.h>
+#include <wchar.h>
+
+#define ARRAY_LEN(a) (sizeof(a) / sizeof(a[0]))
+#define BIT(x) x##Bit, x = 1 << x##Bit, x##Bit_ = x##Bit
+
+#define XDG_SUBDIR "catgirl"
+
+typedef unsigned char byte;
+
+int procPipe[2];
+
+enum Color {
+	White, Black, Blue, Green, Red, Brown, Magenta, Orange,
+	Yellow, LightGreen, Cyan, LightCyan, LightBlue, Pink, Gray, LightGray,
+	Default = 99,
+};
+
+enum { None, Debug, Network, IDCap = 256 };
+extern char *idNames[IDCap];
+extern enum Color idColors[IDCap];
+extern size_t idNext;
+
+static inline size_t idFind(const char *name) {
+	for (size_t id = 0; id < idNext; ++id) {
+		if (!strcmp(idNames[id], name)) return id;
+	}
+	return None;
+}
+
+static inline size_t idFor(const char *name) {
+	size_t id = idFind(name);
+	if (id) return id;
+	if (idNext == IDCap) return Network;
+	idNames[idNext] = strdup(name);
+	if (!idNames[idNext]) err(EX_OSERR, "strdup");
+	idColors[idNext] = Default;
+	return idNext++;
+}
+
+#define ENUM_CAP \
+	X("extended-join", CapExtendedJoin) \
+	X("sasl", CapSASL) \
+	X("server-time", CapServerTime) \
+	X("userhost-in-names", CapUserhostInNames)
+
+enum Cap {
+#define X(name, id) BIT(id),
+	ENUM_CAP
+#undef X
+};
+
+extern struct Self {
+	bool debug;
+	char *plain;
+	const char *join;
+	enum Cap caps;
+	char *network;
+	char *chanTypes;
+	char *prefixes;
+	char *nick;
+	char *user;
+	enum Color color;
+	char *quit;
+} self;
+
+static inline void set(char **field, const char *value) {
+	free(*field);
+	*field = strdup(value);
+	if (!*field) err(EX_OSERR, "strdup");
+}
+
+#define ENUM_TAG \
+	X("time", TagTime)
+
+enum Tag {
+#define X(name, id) id,
+	ENUM_TAG
+#undef X
+	TagCap,
+};
+
+enum { ParamCap = 15 };
+struct Message {
+	char *tags[TagCap];
+	char *nick;
+	char *user;
+	char *host;
+	char *cmd;
+	char *params[ParamCap];
+};
+
+void ircConfig(bool insecure, FILE *cert, FILE *priv);
+int ircConnect(const char *host, const char *port);
+void ircRecv(void);
+void ircSend(const char *ptr, size_t len);
+void ircFormat(const char *format, ...)
+	__attribute__((format(printf, 1, 2)));
+
+extern struct Replies {
+	size_t join;
+	size_t topic;
+	size_t names;
+	size_t whois;
+} replies;
+
+void handle(struct Message msg);
+void command(size_t id, char *input);
+const char *commandIsPrivmsg(size_t id, const char *input);
+const char *commandIsNotice(size_t id, const char *input);
+const char *commandIsAction(size_t id, const char *input);
+void commandComplete(void);
+
+enum Heat { Cold, Warm, Hot };
+void uiInit(void);
+void uiShow(void);
+void uiHide(void);
+void uiDraw(void);
+void uiShowID(size_t id);
+void uiShowNum(size_t num);
+void uiCloseID(size_t id);
+void uiCloseNum(size_t id);
+void uiRead(void);
+void uiWrite(size_t id, enum Heat heat, const time_t *time, const char *str);
+void uiFormat(
+	size_t id, enum Heat heat, const time_t *time, const char *format, ...
+) __attribute__((format(printf, 4, 5)));
+void uiLoad(const char *name);
+int uiSave(const char *name);
+
+enum Edit {
+	EditHead,
+	EditTail,
+	EditPrev,
+	EditNext,
+	EditPrevWord,
+	EditNextWord,
+	EditDeleteHead,
+	EditDeleteTail,
+	EditDeletePrev,
+	EditDeleteNext,
+	EditDeletePrevWord,
+	EditDeleteNextWord,
+	EditPaste,
+	EditInsert,
+	EditComplete,
+	EditEnter,
+};
+void edit(size_t id, enum Edit op, wchar_t ch);
+char *editBuffer(size_t *pos);
+
+const char *complete(size_t id, const char *prefix);
+void completeAccept(void);
+void completeReject(void);
+void completeAdd(size_t id, const char *str, enum Color color);
+void completeTouch(size_t id, const char *str, enum Color color);
+void completeReplace(size_t id, const char *old, const char *new);
+void completeRemove(size_t id, const char *str);
+void completeClear(size_t id);
+size_t completeID(const char *str);
+enum Color completeColor(size_t id, const char *str);
+
+extern const char *urlOpenUtil;
+extern const char *urlCopyUtil;
+void urlScan(size_t id, const char *nick, const char *mesg);
+void urlOpenCount(size_t id, size_t count);
+void urlOpenMatch(size_t id, const char *str);
+void urlCopyMatch(size_t id, const char *str);
+
+FILE *configOpen(const char *path, const char *mode);
+FILE *dataOpen(const char *path, const char *mode);
+
+int getopt_config(
+	int argc, char *const *argv,
+	const char *optstring, const struct option *longopts, int *longindex
+);
+
+extern uint32_t hashInit;
+static inline enum Color hash(const char *str) {
+	if (*str == '~') str++;
+	uint32_t hash = hashInit;
+	for (; *str; ++str) {
+		hash = (hash << 5) | (hash >> 27);
+		hash ^= *str;
+		hash *= 0x27220A95;
+	}
+	return 2 + hash % 74;
+}
+
+#define BASE64_SIZE(len) (1 + ((len) + 2) / 3 * 4)
+static const char Base64[64] = {
+	"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+};
+static inline void base64(char *dst, const byte *src, size_t len) {
+	size_t i = 0;
+	while (len > 2) {
+		dst[i++] = Base64[0x3F & (src[0] >> 2)];
+		dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
+		dst[i++] = Base64[0x3F & (src[1] << 2 | src[2] >> 6)];
+		dst[i++] = Base64[0x3F & src[2]];
+		src += 3;
+		len -= 3;
+	}
+	if (len) {
+		dst[i++] = Base64[0x3F & (src[0] >> 2)];
+		if (len > 1) {
+			dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
+			dst[i++] = Base64[0x3F & (src[1] << 2)];
+		} else {
+			dst[i++] = Base64[0x3F & (src[0] << 4)];
+			dst[i++] = '=';
+		}
+		dst[i++] = '=';
+	}
+	dst[i] = '\0';
+}
+
+// Defined in libcrypto if missing from libc:
+void explicit_bzero(void *b, size_t len);
diff --git a/command.c b/command.c
new file mode 100644
index 0000000..5cb43cf
--- /dev/null
+++ b/command.c
@@ -0,0 +1,280 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "chat.h"
+
+typedef void Command(size_t id, char *params);
+
+static void commandDebug(size_t id, char *params) {
+	(void)id;
+	(void)params;
+	self.debug ^= true;
+	uiFormat(
+		Debug, Warm, NULL,
+		"\3%dDebug is %s", Gray, (self.debug ? "on" : "off")
+	);
+}
+
+static void commandQuote(size_t id, char *params) {
+	(void)id;
+	if (params) ircFormat("%s\r\n", params);
+}
+
+static void commandPrivmsg(size_t id, char *params) {
+	if (!params || !params[0]) return;
+	ircFormat("PRIVMSG %s :%s\r\n", idNames[id], params);
+	struct Message msg = {
+		.nick = self.nick,
+		.user = self.user,
+		.cmd = "PRIVMSG",
+		.params[0] = idNames[id],
+		.params[1] = params,
+	};
+	handle(msg);
+}
+
+static void commandNotice(size_t id, char *params) {
+	if (!params || !params[0]) return;
+	ircFormat("NOTICE %s :%s\r\n", idNames[id], params);
+	struct Message msg = {
+		.nick = self.nick,
+		.user = self.user,
+		.cmd = "NOTICE",
+		.params[0] = idNames[id],
+		.params[1] = params,
+	};
+	handle(msg);
+}
+
+static void commandMe(size_t id, char *params) {
+	char buf[512];
+	snprintf(buf, sizeof(buf), "\1ACTION %s\1", (params ? params : ""));
+	commandPrivmsg(id, buf);
+}
+
+static void commandMsg(size_t id, char *params) {
+	(void)id;
+	char *nick = strsep(&params, " ");
+	if (!params) return;
+	commandPrivmsg(idFor(nick), params);
+}
+
+static void commandJoin(size_t id, char *params) {
+	size_t count = 1;
+	if (params) {
+		for (char *ch = params; *ch && *ch != ' '; ++ch) {
+			if (*ch == ',') count++;
+		}
+	}
+	ircFormat("JOIN %s\r\n", (params ? params : idNames[id]));
+	replies.join += count;
+	replies.topic += count;
+	replies.names += count;
+}
+
+static void commandPart(size_t id, char *params) {
+	if (params) {
+		ircFormat("PART %s :%s\r\n", idNames[id], params);
+	} else {
+		ircFormat("PART %s\r\n", idNames[id]);
+	}
+}
+
+static void commandQuit(size_t id, char *params) {
+	(void)id;
+	set(&self.quit, (params ? params : "Goodbye"));
+}
+
+static void commandNick(size_t id, char *params) {
+	(void)id;
+	if (!params) return;
+	ircFormat("NICK :%s\r\n", params);
+}
+
+static void commandTopic(size_t id, char *params) {
+	if (params) {
+		ircFormat("TOPIC %s :%s\r\n", idNames[id], params);
+	} else {
+		ircFormat("TOPIC %s\r\n", idNames[id]);
+		replies.topic++;
+	}
+}
+
+static void commandNames(size_t id, char *params) {
+	(void)params;
+	ircFormat("NAMES :%s\r\n", idNames[id]);
+	replies.names++;
+}
+
+static void commandWhois(size_t id, char *params) {
+	(void)id;
+	if (!params) return;
+	ircFormat("WHOIS :%s\r\n", params);
+	replies.whois++;
+}
+
+static void commandQuery(size_t id, char *params) {
+	if (!params) return;
+	size_t query = idFor(params);
+	idColors[query] = completeColor(id, params);
+	uiShowID(query);
+}
+
+static void commandWindow(size_t id, char *params) {
+	if (!params) return;
+	if (isdigit(params[0])) {
+		uiShowNum(strtoul(params, NULL, 10));
+	} else {
+		id = idFind(params);
+		if (id) uiShowID(id);
+	}
+}
+
+static void commandClose(size_t id, char *params) {
+	if (!params) {
+		uiCloseID(id);
+	} else if (isdigit(params[0])) {
+		uiCloseNum(strtoul(params, NULL, 10));
+	} else {
+		id = idFind(params);
+		if (id) uiCloseID(id);
+	}
+}
+
+static void commandOpen(size_t id, char *params) {
+	if (!params) {
+		urlOpenCount(id, 1);
+	} else if (isdigit(params[0])) {
+		urlOpenCount(id, strtoul(params, NULL, 10));
+	} else {
+		urlOpenMatch(id, params);
+	}
+}
+
+static void commandCopy(size_t id, char *params) {
+	urlCopyMatch(id, params);
+}
+
+static void commandHelp(size_t id, char *params) {
+	(void)id;
+	uiHide();
+
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) return;
+
+	char buf[256];
+	snprintf(buf, sizeof(buf), "ip%s$", (params ? params : "COMMANDS"));
+	setenv("LESS", buf, 1);
+	execlp("man", "man", "1", "catgirl", NULL);
+	dup2(procPipe[1], STDERR_FILENO);
+	warn("man");
+	_exit(EX_UNAVAILABLE);
+}
+
+static const struct Handler {
+	const char *cmd;
+	Command *fn;
+} Commands[] = {
+	{ "/close", commandClose },
+	{ "/copy", commandCopy },
+	{ "/debug", commandDebug },
+	{ "/help", commandHelp },
+	{ "/join", commandJoin },
+	{ "/me", commandMe },
+	{ "/msg", commandMsg },
+	{ "/names", commandNames },
+	{ "/nick", commandNick },
+	{ "/notice", commandNotice },
+	{ "/open", commandOpen },
+	{ "/part", commandPart },
+	{ "/query", commandQuery },
+	{ "/quit", commandQuit },
+	{ "/quote", commandQuote },
+	{ "/topic", commandTopic },
+	{ "/whois", commandWhois },
+	{ "/window", commandWindow },
+};
+
+static int compar(const void *cmd, const void *_handler) {
+	const struct Handler *handler = _handler;
+	return strcmp(cmd, handler->cmd);
+}
+
+const char *commandIsPrivmsg(size_t id, const char *input) {
+	if (id == Network || id == Debug) return NULL;
+	if (input[0] != '/') return input;
+	const char *space = strchr(&input[1], ' ');
+	const char *slash = strchr(&input[1], '/');
+	if (slash && (!space || slash < space)) return input;
+	return NULL;
+}
+
+const char *commandIsNotice(size_t id, const char *input) {
+	if (id == Network || id == Debug) return NULL;
+	if (strncmp(input, "/notice ", 8)) return NULL;
+	return &input[8];
+}
+
+const char *commandIsAction(size_t id, const char *input) {
+	if (id == Network || id == Debug) return NULL;
+	if (strncmp(input, "/me ", 4)) return NULL;
+	return &input[4];
+}
+
+void command(size_t id, char *input) {
+	if (id == Debug && input[0] != '/') {
+		commandQuote(id, input);
+	} else if (commandIsPrivmsg(id, input)) {
+		commandPrivmsg(id, input);
+	} else if (input[0] == '/' && isdigit(input[1])) {
+		commandWindow(id, &input[1]);
+	} else {
+		const char *cmd = strsep(&input, " ");
+		const char *unique = complete(None, cmd);
+		if (unique && !complete(None, cmd)) {
+			cmd = unique;
+			completeReject();
+		}
+		const struct Handler *handler = bsearch(
+			cmd, Commands, ARRAY_LEN(Commands), sizeof(*handler), compar
+		);
+		if (handler) {
+			if (input) {
+				input += strspn(input, " ");
+				size_t len = strlen(input);
+				while (input[len - 1] == ' ') input[--len] = '\0';
+				if (!input[0]) input = NULL;
+			}
+			if (input && !input[0]) input = NULL;
+			handler->fn(id, input);
+		} else {
+			uiFormat(id, Hot, NULL, "No such command %s", cmd);
+		}
+	}
+}
+
+void commandComplete(void) {
+	for (size_t i = 0; i < ARRAY_LEN(Commands); ++i) {
+		completeAdd(None, Commands[i].cmd, Default);
+	}
+}
diff --git a/complete.c b/complete.c
new file mode 100644
index 0000000..2f5275f
--- /dev/null
+++ b/complete.c
@@ -0,0 +1,162 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+
+#include "chat.h"
+
+struct Node {
+	size_t id;
+	char *str;
+	enum Color color;
+	struct Node *prev;
+	struct Node *next;
+};
+
+static struct Node *alloc(size_t id, const char *str, enum Color color) {
+	struct Node *node = malloc(sizeof(*node));
+	if (!node) err(EX_OSERR, "malloc");
+	node->id = id;
+	node->str = strdup(str);
+	if (!node->str) err(EX_OSERR, "strdup");
+	node->color = color;
+	node->prev = NULL;
+	node->next = NULL;
+	return node;
+}
+
+static struct Node *head;
+static struct Node *tail;
+
+static struct Node *detach(struct Node *node) {
+	if (node->prev) node->prev->next = node->next;
+	if (node->next) node->next->prev = node->prev;
+	if (head == node) head = node->next;
+	if (tail == node) tail = node->prev;
+	node->prev = NULL;
+	node->next = NULL;
+	return node;
+}
+
+static struct Node *prepend(struct Node *node) {
+	node->prev = NULL;
+	node->next = head;
+	if (head) head->prev = node;
+	head = node;
+	if (!tail) tail = node;
+	return node;
+}
+
+static struct Node *append(struct Node *node) {
+	node->next = NULL;
+	node->prev = tail;
+	if (tail) tail->next = node;
+	tail = node;
+	if (!head) head = node;
+	return node;
+}
+
+static struct Node *find(size_t id, const char *str) {
+	for (struct Node *node = head; node; node = node->next) {
+		if (node->id == id && !strcmp(node->str, str)) return node;
+	}
+	return NULL;
+}
+
+void completeAdd(size_t id, const char *str, enum Color color) {
+	if (!find(id, str)) append(alloc(id, str, color));
+}
+
+void completeTouch(size_t id, const char *str, enum Color color) {
+	struct Node *node = find(id, str);
+	if (node && node->color != color) node->color = color;
+	prepend(node ? detach(node) : alloc(id, str, color));
+}
+
+enum Color completeColor(size_t id, const char *str) {
+	struct Node *node = find(id, str);
+	return (node ? node->color : Default);
+}
+
+static struct Node *match;
+
+const char *complete(size_t id, const char *prefix) {
+	for (match = (match ? match->next : head); match; match = match->next) {
+		if (match->id && match->id != id) continue;
+		if (strncasecmp(match->str, prefix, strlen(prefix))) continue;
+		return match->str;
+	}
+	return NULL;
+}
+
+void completeAccept(void) {
+	if (match) prepend(detach(match));
+	match = NULL;
+}
+
+void completeReject(void) {
+	match = NULL;
+}
+
+size_t completeID(const char *str) {
+	for (match = (match ? match->next : head); match; match = match->next) {
+		if (match->id && !strcmp(match->str, str)) return match->id;
+	}
+	return None;
+}
+
+void completeReplace(size_t id, const char *old, const char *new) {
+	struct Node *next = NULL;
+	for (struct Node *node = head; node; node = node->next) {
+		next = node->next;
+		if (id && node->id != id) continue;
+		if (strcmp(node->str, old)) continue;
+		if (match == node) match = NULL;
+		free(node->str);
+		node->str = strdup(new);
+		if (!node->str) err(EX_OSERR, "strdup");
+		prepend(detach(node));
+	}
+}
+
+void completeRemove(size_t id, const char *str) {
+	struct Node *next = NULL;
+	for (struct Node *node = head; node; node = next) {
+		next = node->next;
+		if (id && node->id != id) continue;
+		if (strcmp(node->str, str)) continue;
+		if (match == node) match = NULL;
+		detach(node);
+		free(node->str);
+		free(node);
+	}
+}
+
+void completeClear(size_t id) {
+	struct Node *next = NULL;
+	for (struct Node *node = head; node; node = next) {
+		next = node->next;
+		if (node->id != id) continue;
+		if (match == node) match = NULL;
+		detach(node);
+		free(node->str);
+		free(node);
+	}
+}
diff --git a/config.c b/config.c
new file mode 100644
index 0000000..3a87948
--- /dev/null
+++ b/config.c
@@ -0,0 +1,137 @@
+/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <errno.h>
+#include <getopt.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "chat.h"
+
+#define WS "\t "
+
+static const char *path;
+static FILE *file;
+static size_t num;
+static char *line;
+static size_t cap;
+
+static int clean(int opt) {
+	if (file) fclose(file);
+	free(line);
+	line = NULL;
+	cap = 0;
+	return opt;
+}
+
+int getopt_config(
+	int argc, char *const *argv,
+	const char *optstring, const struct option *longopts, int *longindex
+) {
+	static int opt;
+	if (opt >= 0) {
+		opt = getopt_long(argc, argv, optstring, longopts, longindex);
+	}
+	if (opt >= 0) return opt;
+
+	for (;;) {
+		if (!file) {
+			if (optind < argc) {
+				num = 0;
+				path = argv[optind++];
+				file = configOpen(path, "r");
+				if (!file) return clean('?');
+			} else {
+				return clean(-1);
+			}
+		}
+
+		for (;;) {
+			ssize_t llen = getline(&line, &cap, file);
+			if (ferror(file)) {
+				warn("%s", path);
+				return clean('?');
+			}
+			if (llen <= 0) break;
+			if (line[llen - 1] == '\n') line[llen - 1] = '\0';
+			num++;
+
+			char *name = line + strspn(line, WS);
+			size_t len = strcspn(name, WS "=");
+			if (!name[0] || name[0] == '#') continue;
+
+			const struct option *option;
+			for (option = longopts; option->name; ++option) {
+				if (strlen(option->name) != len) continue;
+				if (!strncmp(option->name, name, len)) break;
+			}
+			if (!option->name) {
+				warnx(
+					"%s:%zu: unrecognized option `%.*s'",
+					path, num, (int)len, name
+				);
+				return clean('?');
+			}
+
+			char *equal = &name[len] + strspn(&name[len], WS);
+			if (*equal && *equal != '=') {
+				warnx(
+					"%s:%zu: option `%s' missing equals sign",
+					path, num, option->name
+				);
+				return clean('?');
+			}
+			if (option->has_arg == no_argument && *equal) {
+				warnx(
+					"%s:%zu: option `%s' doesn't allow an argument",
+					path, num, option->name
+				);
+				return clean('?');
+			}
+			if (option->has_arg == required_argument && !*equal) {
+				warnx(
+					"%s:%zu: option `%s' requires an argument",
+					path, num, option->name
+				);
+				return clean(':');
+			}
+
+			optarg = NULL;
+			if (*equal) {
+				char *arg = &equal[1] + strspn(&equal[1], WS);
+				optarg = strdup(arg);
+				if (!optarg) {
+					warn("getopt_config");
+					return clean('?');
+				}
+			}
+
+			if (longindex) *longindex = option - longopts;
+			if (option->flag) {
+				*option->flag = option->val;
+				return 0;
+			} else {
+				return option->val;
+			}
+		}
+
+		fclose(file);
+		file = NULL;
+	}
+}
diff --git a/configure b/configure
new file mode 100755
index 0000000..90e1173
--- /dev/null
+++ b/configure
@@ -0,0 +1,11 @@
+#!/bin/sh
+set -eu
+
+libs='libcrypto libtls ncursesw'
+pkg-config --print-errors $libs
+
+cat >config.mk <<EOF
+CFLAGS += $(pkg-config --cflags $libs)
+LDFLAGS += $(pkg-config --libs-only-L $libs)
+LDLIBS = $(pkg-config --libs-only-l $libs)
+EOF
diff --git a/edit.c b/edit.c
new file mode 100644
index 0000000..d90d558
--- /dev/null
+++ b/edit.c
@@ -0,0 +1,207 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <limits.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include "chat.h"
+
+enum { Cap = 512 };
+static wchar_t buf[Cap];
+static size_t len;
+static size_t pos;
+
+char *editBuffer(size_t *mbsPos) {
+	static char mbs[MB_LEN_MAX * Cap];
+
+	const wchar_t *ptr = buf;
+	size_t mbsLen = wcsnrtombs(mbs, &ptr, pos, sizeof(mbs) - 1, NULL);
+	assert(mbsLen != (size_t)-1);
+	if (mbsPos) *mbsPos = mbsLen;
+
+	ptr = &buf[pos];
+	size_t n = wcsnrtombs(
+		&mbs[mbsLen], &ptr, len - pos, sizeof(mbs) - mbsLen - 1, NULL
+	);
+	assert(n != (size_t)-1);
+	mbsLen += n;
+
+	mbs[mbsLen] = '\0';
+	return mbs;
+}
+
+static struct {
+	wchar_t buf[Cap];
+	size_t len;
+} cut;
+
+static bool reserve(size_t index, size_t count) {
+	if (len + count > Cap) return false;
+	memmove(&buf[index + count], &buf[index], sizeof(*buf) * (len - index));
+	len += count;
+	return true;
+}
+
+static void delete(size_t index, size_t count) {
+	if (index + count > len) return;
+	if (count > 1) {
+		memcpy(cut.buf, &buf[index], sizeof(*buf) * count);
+		cut.len = count;
+	}
+	memmove(
+		&buf[index], &buf[index + count], sizeof(*buf) * (len - index - count)
+	);
+	len -= count;
+}
+
+static struct {
+	size_t pos;
+	size_t pre;
+	size_t len;
+} tab;
+
+static void tabComplete(size_t id) {
+	if (!tab.len) {
+		tab.pos = pos;
+		while (tab.pos && buf[tab.pos - 1] != L' ') tab.pos--;
+		if (tab.pos == pos) return;
+		tab.pre = pos - tab.pos;
+		tab.len = tab.pre;
+	}
+
+	char mbs[MB_LEN_MAX * Cap];
+	const wchar_t *ptr = &buf[tab.pos];
+	size_t n = wcsnrtombs(mbs, &ptr, tab.pre, sizeof(mbs) - 1, NULL);
+	assert(n != (size_t)-1);
+	mbs[n] = '\0';
+
+	const char *comp = complete(id, mbs);
+	if (!comp) comp = complete(id, mbs);
+	if (!comp) {
+		tab.len = 0;
+		return;
+	}
+
+	wchar_t wcs[Cap];
+	n = mbstowcs(wcs, comp, sizeof(wcs));
+	assert(n != (size_t)-1);
+	if (tab.pos + n + 2 > Cap) {
+		completeReject();
+		tab.len = 0;
+		return;
+	}
+
+	delete(tab.pos, tab.len);
+	if (wcs[0] != L'/' && !tab.pos) {
+		tab.len = n + 2;
+		reserve(tab.pos, tab.len);
+		buf[tab.pos + n + 0] = L':';
+		buf[tab.pos + n + 1] = L' ';
+	} else if (
+		tab.pos >= 2 && (buf[tab.pos - 2] == L':' || buf[tab.pos - 2] == L',')
+	) {
+		tab.len = n + 2;
+		reserve(tab.pos, tab.len);
+		buf[tab.pos - 2] = L',';
+		buf[tab.pos + n + 0] = L':';
+		buf[tab.pos + n + 1] = L' ';
+	} else {
+		tab.len = n + 1;
+		reserve(tab.pos, tab.len);
+		buf[tab.pos + n] = L' ';
+	}
+	memcpy(&buf[tab.pos], wcs, sizeof(*wcs) * n);
+	pos = tab.pos + tab.len;
+}
+
+static void tabAccept(void) {
+	completeAccept();
+	tab.len = 0;
+}
+
+static void tabReject(void) {
+	completeReject();
+	tab.len = 0;
+}
+
+void edit(size_t id, enum Edit op, wchar_t ch) {
+	size_t init = pos;
+	switch (op) {
+		break; case EditHead: pos = 0;
+		break; case EditTail: pos = len;
+		break; case EditPrev: if (pos) pos--;
+		break; case EditNext: if (pos < len) pos++;
+		break; case EditPrevWord: {
+			if (pos) pos--;
+			while (pos && !iswspace(buf[pos - 1])) pos--;
+		}
+		break; case EditNextWord: {
+			if (pos < len) pos++;
+			while (pos < len && !iswspace(buf[pos])) pos++;
+		}
+
+		break; case EditDeleteHead: delete(0, pos); pos = 0;
+		break; case EditDeleteTail: delete(pos, len - pos);
+		break; case EditDeletePrev: if (pos) delete(--pos, 1);
+		break; case EditDeleteNext: delete(pos, 1);
+		break; case EditDeletePrevWord: {
+			if (!pos) break;
+			size_t word = pos - 1;
+			while (word && !iswspace(buf[word - 1])) word--;
+			delete(word, pos - word);
+			pos = word;
+		}
+		break; case EditDeleteNextWord: {
+			if (pos == len) break;
+			size_t word = pos + 1;
+			while (word < len && !iswspace(buf[word])) word++;
+			delete(pos, word - pos);
+		}
+		break; case EditPaste: {
+			if (reserve(pos, cut.len)) {
+				memcpy(&buf[pos], cut.buf, sizeof(*buf) * cut.len);
+				pos += cut.len;
+			}
+		}
+
+		break; case EditInsert: {
+			if (reserve(pos, 1)) {
+				buf[pos++] = ch;
+			}
+		}
+		break; case EditComplete: {
+			tabComplete(id);
+			return;
+		}
+		break; case EditEnter: {
+			tabAccept();
+			command(id, editBuffer(NULL));
+			len = pos = 0;
+			return;
+		}
+	}
+
+	if (pos < init) {
+		tabReject();
+	} else {
+		tabAccept();
+	}
+}
diff --git a/handle.c b/handle.c
new file mode 100644
index 0000000..ce56a51
--- /dev/null
+++ b/handle.c
@@ -0,0 +1,637 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <ctype.h>
+#include <err.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+
+#include "chat.h"
+
+struct Replies replies;
+
+static const char *CapNames[] = {
+#define X(name, id) [id##Bit] = name,
+	ENUM_CAP
+#undef X
+};
+
+static enum Cap capParse(const char *list) {
+	enum Cap caps = 0;
+	while (*list) {
+		enum Cap cap = 0;
+		size_t len = strcspn(list, " ");
+		for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
+			if (len != strlen(CapNames[i])) continue;
+			if (strncmp(list, CapNames[i], len)) continue;
+			cap = 1 << i;
+			break;
+		}
+		caps |= cap;
+		list += len;
+		if (*list) list++;
+	}
+	return caps;
+}
+
+static const char *capList(enum Cap caps) {
+	static char buf[1024];
+	buf[0] = '\0';
+	for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
+		if (caps & (1 << i)) {
+			if (buf[0]) strlcat(buf, " ", sizeof(buf));
+			strlcat(buf, CapNames[i], sizeof(buf));
+		}
+	}
+	return buf;
+}
+
+static void require(struct Message *msg, bool origin, size_t len) {
+	if (origin) {
+		if (!msg->nick) errx(EX_PROTOCOL, "%s missing origin", msg->cmd);
+		if (!msg->user) msg->user = msg->nick;
+		if (!msg->host) msg->host = msg->user;
+	}
+	for (size_t i = 0; i < len; ++i) {
+		if (msg->params[i]) continue;
+		errx(EX_PROTOCOL, "%s missing parameter %zu", msg->cmd, 1 + i);
+	}
+}
+
+static const time_t *tagTime(const struct Message *msg) {
+	static time_t time;
+	struct tm tm;
+	if (!msg->tags[TagTime]) return NULL;
+	if (!strptime(msg->tags[TagTime], "%FT%T", &tm)) return NULL;
+	time = timegm(&tm);
+	return &time;
+}
+
+typedef void Handler(struct Message *msg);
+
+static void handleErrorNicknameInUse(struct Message *msg) {
+	if (self.nick) return;
+	require(msg, false, 2);
+	ircFormat("NICK :%s_\r\n", msg->params[1]);
+}
+
+static void handleErrorErroneousNickname(struct Message *msg) {
+	require(msg, false, 3);
+	errx(EX_CONFIG, "%s: %s", msg->params[1], msg->params[2]);
+}
+
+static void handleCap(struct Message *msg) {
+	require(msg, false, 3);
+	enum Cap caps = capParse(msg->params[2]);
+	if (!strcmp(msg->params[1], "LS")) {
+		caps &= ~CapSASL;
+		if (caps) {
+			ircFormat("CAP REQ :%s\r\n", capList(caps));
+		} else {
+			if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
+		}
+	} else if (!strcmp(msg->params[1], "ACK")) {
+		self.caps |= caps;
+		if (caps & CapSASL) {
+			ircFormat("AUTHENTICATE %s\r\n", (self.plain ? "PLAIN" : "EXTERNAL"));
+		}
+		if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
+	} else if (!strcmp(msg->params[1], "NAK")) {
+		errx(EX_CONFIG, "server does not support %s", msg->params[2]);
+	}
+}
+
+static void handleAuthenticate(struct Message *msg) {
+	(void)msg;
+	if (!self.plain) {
+		ircFormat("AUTHENTICATE +\r\n");
+		return;
+	}
+
+	byte buf[299];
+	size_t len = 1 + strlen(self.plain);
+	if (sizeof(buf) < len) errx(EX_CONFIG, "SASL PLAIN is too long");
+	buf[0] = 0;
+	for (size_t i = 0; self.plain[i]; ++i) {
+		buf[1 + i] = (self.plain[i] == ':' ? 0 : self.plain[i]);
+	}
+
+	char b64[BASE64_SIZE(sizeof(buf))];
+	base64(b64, buf, len);
+	ircFormat("AUTHENTICATE ");
+	ircSend(b64, BASE64_SIZE(len));
+	ircFormat("\r\n");
+
+	explicit_bzero(b64, sizeof(b64));
+	explicit_bzero(buf, sizeof(buf));
+	explicit_bzero(self.plain, strlen(self.plain));
+}
+
+static void handleReplyLoggedIn(struct Message *msg) {
+	(void)msg;
+	ircFormat("CAP END\r\n");
+}
+
+static void handleErrorSASLFail(struct Message *msg) {
+	require(msg, false, 2);
+	errx(EX_CONFIG, "%s", msg->params[1]);
+}
+
+static void handleReplyWelcome(struct Message *msg) {
+	require(msg, false, 1);
+	set(&self.nick, msg->params[0]);
+	completeTouch(Network, self.nick, Default);
+	if (self.join) {
+		size_t count = 1;
+		for (const char *ch = self.join; *ch && *ch != ' '; ++ch) {
+			if (*ch == ',') count++;
+		}
+		ircFormat("JOIN %s\r\n", self.join);
+		replies.join += count;
+		replies.topic += count;
+		replies.names += count;
+	}
+}
+
+static void handleReplyISupport(struct Message *msg) {
+	for (size_t i = 1; i < ParamCap; ++i) {
+		if (!msg->params[i]) break;
+		char *key = strsep(&msg->params[i], "=");
+		if (!msg->params[i]) continue;
+		if (!strcmp(key, "NETWORK")) {
+			set(&self.network, msg->params[i]);
+			uiFormat(
+				Network, Cold, tagTime(msg),
+				"You arrive in %s", msg->params[i]
+			);
+		} else if (!strcmp(key, "CHANTYPES")) {
+			set(&self.chanTypes, msg->params[i]);
+		} else if (!strcmp(key, "PREFIX")) {
+			strsep(&msg->params[i], ")");
+			if (!msg->params[i]) continue;
+			set(&self.prefixes, msg->params[i]);
+		}
+	}
+}
+
+static void handleReplyMOTD(struct Message *msg) {
+	require(msg, false, 2);
+	char *line = msg->params[1];
+	urlScan(Network, msg->nick, line);
+	if (!strncmp(line, "- ", 2)) {
+		uiFormat(Network, Cold, tagTime(msg), "\3%d-\3\t%s", Gray, &line[2]);
+	} else {
+		uiFormat(Network, Cold, tagTime(msg), "%s", line);
+	}
+}
+
+static void handleJoin(struct Message *msg) {
+	require(msg, true, 1);
+	size_t id = idFor(msg->params[0]);
+	if (self.nick && !strcmp(msg->nick, self.nick)) {
+		if (!self.user) {
+			set(&self.user, msg->user);
+			self.color = hash(msg->user);
+		}
+		idColors[id] = hash(msg->params[0]);
+		completeTouch(None, msg->params[0], idColors[id]);
+		if (replies.join) {
+			uiShowID(id);
+			replies.join--;
+		}
+	}
+	completeTouch(id, msg->nick, hash(msg->user));
+	if (msg->params[2] && !strcasecmp(msg->params[2], msg->nick)) {
+		msg->params[2] = NULL;
+	}
+	uiFormat(
+		id, Cold, tagTime(msg),
+		"\3%02d%s\3\t%s%s%sarrives in \3%02d%s\3",
+		hash(msg->user), msg->nick,
+		(msg->params[2] ? "(" : ""),
+		(msg->params[2] ? msg->params[2] : ""),
+		(msg->params[2] ? ") " : ""),
+		hash(msg->params[0]), msg->params[0]
+	);
+}
+
+static void handlePart(struct Message *msg) {
+	require(msg, true, 1);
+	size_t id = idFor(msg->params[0]);
+	if (self.nick && !strcmp(msg->nick, self.nick)) {
+		completeClear(id);
+	}
+	completeRemove(id, msg->nick);
+	urlScan(id, msg->nick, msg->params[1]);
+	uiFormat(
+		id, Cold, tagTime(msg),
+		"\3%02d%s\3\tleaves \3%02d%s\3%s%s",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+		(msg->params[1] ? ": " : ""),
+		(msg->params[1] ? msg->params[1] : "")
+	);
+}
+
+static void handleKick(struct Message *msg) {
+	require(msg, true, 2);
+	size_t id = idFor(msg->params[0]);
+	bool kicked = self.nick && !strcmp(msg->params[1], self.nick);
+	completeTouch(id, msg->nick, hash(msg->user));
+	urlScan(id, msg->nick, msg->params[2]);
+	uiFormat(
+		id, (kicked ? Hot : Cold), tagTime(msg),
+		"%s\3%02d%s\17\tkicks \3%02d%s\3 out of \3%02d%s\3%s%s",
+		(kicked ? "\26" : ""),
+		hash(msg->user), msg->nick,
+		completeColor(id, msg->params[1]), msg->params[1],
+		hash(msg->params[0]), msg->params[0],
+		(msg->params[2] ? ": " : ""),
+		(msg->params[2] ? msg->params[2] : "")
+	);
+	completeRemove(id, msg->params[1]);
+	if (kicked) completeClear(id);
+}
+
+static void handleNick(struct Message *msg) {
+	require(msg, true, 1);
+	if (self.nick && !strcmp(msg->nick, self.nick)) {
+		set(&self.nick, msg->params[0]);
+		uiRead(); // Update prompt.
+	}
+	size_t id;
+	while (None != (id = completeID(msg->nick))) {
+		uiFormat(
+			id, Cold, tagTime(msg),
+			"\3%02d%s\3\tis now known as \3%02d%s\3",
+			hash(msg->user), msg->nick, hash(msg->user), msg->params[0]
+		);
+	}
+	completeReplace(None, msg->nick, msg->params[0]);
+}
+
+static void handleQuit(struct Message *msg) {
+	require(msg, true, 0);
+	size_t id;
+	while (None != (id = completeID(msg->nick))) {
+		urlScan(id, msg->nick, msg->params[0]);
+		uiFormat(
+			id, Cold, tagTime(msg),
+			"\3%02d%s\3\tleaves%s%s",
+			hash(msg->user), msg->nick,
+			(msg->params[0] ? ": " : ""),
+			(msg->params[0] ? msg->params[0] : "")
+		);
+	}
+	completeRemove(None, msg->nick);
+}
+
+static void handleReplyNames(struct Message *msg) {
+	require(msg, false, 4);
+	size_t id = idFor(msg->params[2]);
+	char buf[1024];
+	size_t len = 0;
+	while (msg->params[3]) {
+		char *name = strsep(&msg->params[3], " ");
+		name += strspn(name, self.prefixes);
+		char *nick = strsep(&name, "!");
+		char *user = strsep(&name, "@");
+		enum Color color = (user ? hash(user) : Default);
+		completeAdd(id, nick, color);
+		if (!replies.names) continue;
+		int n = snprintf(
+			&buf[len], sizeof(buf) - len,
+			"%s\3%02d%s\3", (len ? ", " : ""), color, nick
+		);
+		assert(n > 0 && len + n < sizeof(buf));
+		len += n;
+	}
+	if (!replies.names) return;
+	uiFormat(
+		id, Cold, tagTime(msg),
+		"In \3%02d%s\3 are %s",
+		hash(msg->params[2]), msg->params[2], buf
+	);
+}
+
+static void handleReplyEndOfNames(struct Message *msg) {
+	(void)msg;
+	if (replies.names) replies.names--;
+}
+
+static void handleReplyNoTopic(struct Message *msg) {
+	require(msg, false, 2);
+	if (!replies.topic) return;
+	replies.topic--;
+	uiFormat(
+		idFor(msg->params[1]), Cold, tagTime(msg),
+		"There is no sign in \3%02d%s\3",
+		hash(msg->params[1]), msg->params[1]
+	);
+}
+
+static void handleReplyTopic(struct Message *msg) {
+	require(msg, false, 3);
+	if (!replies.topic) return;
+	replies.topic--;
+	size_t id = idFor(msg->params[1]);
+	urlScan(id, NULL, msg->params[2]);
+	uiFormat(
+		id, Cold, tagTime(msg),
+		"The sign in \3%02d%s\3 reads: %s",
+		hash(msg->params[1]), msg->params[1], msg->params[2]
+	);
+}
+
+static void handleTopic(struct Message *msg) {
+	require(msg, true, 2);
+	size_t id = idFor(msg->params[0]);
+	if (msg->params[1][0]) {
+		urlScan(id, msg->nick, msg->params[1]);
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"\3%02d%s\3\tplaces a new sign in \3%02d%s\3: %s",
+			hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+			msg->params[1]
+		);
+	} else {
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"\3%02d%s\3\tremoves the sign in \3%02d%s\3",
+			hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
+		);
+	}
+}
+
+static void handleReplyWhoisUser(struct Message *msg) {
+	require(msg, false, 6);
+	if (!replies.whois) return;
+	completeTouch(Network, msg->params[1], hash(msg->params[2]));
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis %s!%s@%s (%s)",
+		hash(msg->params[2]), msg->params[1],
+		msg->params[1], msg->params[2], msg->params[3], msg->params[5]
+	);
+}
+
+static void handleReplyWhoisServer(struct Message *msg) {
+	require(msg, false, 4);
+	if (!replies.whois) return;
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis connected to %s (%s)",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		msg->params[2], msg->params[3]
+	);
+}
+
+static void handleReplyWhoisIdle(struct Message *msg) {
+	require(msg, false, 3);
+	if (!replies.whois) return;
+	unsigned long idle = strtoul(msg->params[2], NULL, 10);
+	const char *unit = "second";
+	if (idle / 60) { idle /= 60; unit = "minute"; }
+	if (idle / 60) { idle /= 60; unit = "hour"; }
+	if (idle / 24) { idle /= 24; unit = "day"; }
+	time_t signon = (msg->params[3] ? strtoul(msg->params[3], NULL, 10) : 0);
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis idle for %lu %s%s%s%.*s",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		idle, unit, (idle != 1 ? "s" : ""),
+		(signon ? ", signed on " : ""),
+		24, (signon ? ctime(&signon) : "")
+	);
+}
+
+static void handleReplyWhoisChannels(struct Message *msg) {
+	require(msg, false, 3);
+	if (!replies.whois) return;
+	char buf[1024];
+	size_t len = 0;
+	while (msg->params[2]) {
+		char *channel = strsep(&msg->params[2], " ");
+		channel += strspn(channel, self.prefixes);
+		int n = snprintf(
+			&buf[len], sizeof(buf) - len,
+			"%s\3%02d%s\3", (len ? ", " : ""), hash(channel), channel
+		);
+		assert(n > 0 && len + n < sizeof(buf));
+		len += n;
+	}
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis in %s",
+		completeColor(Network, msg->params[1]), msg->params[1], buf
+	);
+}
+
+static void handleReplyWhoisGeneric(struct Message *msg) {
+	require(msg, false, 3);
+	if (!replies.whois) return;
+	if (msg->params[3]) {
+		msg->params[0] = msg->params[2];
+		msg->params[2] = msg->params[3];
+		msg->params[3] = msg->params[0];
+	}
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\t%s%s%s",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		msg->params[2],
+		(msg->params[3] ? " " : ""),
+		(msg->params[3] ? msg->params[3] : "")
+	);
+}
+
+static void handleReplyEndOfWhois(struct Message *msg) {
+	require(msg, false, 2);
+	if (!replies.whois) return;
+	if (!self.nick || strcmp(msg->params[1], self.nick)) {
+		completeRemove(Network, msg->params[1]);
+	}
+	replies.whois--;
+}
+
+static bool isAction(struct Message *msg) {
+	if (strncmp(msg->params[1], "\1ACTION ", 8)) return false;
+	msg->params[1] += 8;
+	size_t len = strlen(msg->params[1]);
+	if (msg->params[1][len - 1] == '\1') msg->params[1][len - 1] = '\0';
+	return true;
+}
+
+static bool isMention(const struct Message *msg) {
+	if (!self.nick) return false;
+	size_t len = strlen(self.nick);
+	const char *match = msg->params[1];
+	while (NULL != (match = strcasestr(match, self.nick))) {
+		char a = (match > msg->params[1] ? match[-1] : ' ');
+		char b = (match[len] ? match[len] : ' ');
+		if ((isspace(a) || ispunct(a)) && (isspace(b) || ispunct(b))) {
+			return true;
+		}
+		match = &match[len];
+	}
+	return false;
+}
+
+static const char *colorMentions(size_t id, struct Message *msg) {
+	char *split = strchr(msg->params[1], ':');
+	if (!split) split = strchr(msg->params[1], ' ');
+	if (!split) split = &msg->params[1][strlen(msg->params[1])];
+	for (char *ch = msg->params[1]; ch < split; ++ch) {
+		if (iscntrl(*ch)) return "";
+	}
+	char delimit = *split;
+	char *mention = msg->params[1];
+	msg->params[1] = (delimit ? &split[1] : split);
+	*split = '\0';
+
+	static char buf[1024];
+	FILE *str = fmemopen(buf, sizeof(buf), "w");
+	if (!str) err(EX_OSERR, "fmemopen");
+
+	while (*mention) {
+		size_t skip = strspn(mention, ",<> ");
+		fwrite(mention, skip, 1, str);
+		mention += skip;
+
+		size_t len = strcspn(mention, ",<> ");
+		char punct = mention[len];
+		mention[len] = '\0';
+		fprintf(str, "\3%02d%s\3", completeColor(id, mention), mention);
+		mention[len] = punct;
+		mention += len;
+	}
+	fputc(delimit, str);
+
+	fclose(str);
+	buf[sizeof(buf) - 1] = '\0';
+	return buf;
+}
+
+static void handlePrivmsg(struct Message *msg) {
+	require(msg, true, 2);
+	bool query = !strchr(self.chanTypes, msg->params[0][0]);
+	bool network = strchr(msg->nick, '.');
+	bool mine = self.nick && !strcmp(msg->nick, self.nick);
+	size_t id;
+	if (query && network) {
+		id = Network;
+	} else if (query && !mine) {
+		id = idFor(msg->nick);
+		idColors[id] = hash(msg->user);
+	} else {
+		id = idFor(msg->params[0]);
+	}
+
+	bool notice = (msg->cmd[0] == 'N');
+	bool action = isAction(msg);
+	bool mention = !mine && isMention(msg);
+	if (!notice && !mine) completeTouch(id, msg->nick, hash(msg->user));
+	urlScan(id, msg->nick, msg->params[1]);
+	if (notice) {
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"%s\3%d-%s-\17\3%d\t%s",
+			(mention ? "\26" : ""), hash(msg->user), msg->nick,
+			LightGray, msg->params[1]
+		);
+	} else if (action) {
+		uiFormat(
+			id, (mention || query ? Hot : Warm), tagTime(msg),
+			"%s\35\3%d* %s\17\35\t%s",
+			(mention ? "\26" : ""), hash(msg->user), msg->nick, msg->params[1]
+		);
+	} else {
+		const char *mentions = colorMentions(id, msg);
+		uiFormat(
+			id, (mention || query ? Hot : Warm), tagTime(msg),
+			"%s\3%d<%s>\17\t%s%s",
+			(mention ? "\26" : ""), hash(msg->user), msg->nick,
+			mentions, msg->params[1]
+		);
+	}
+}
+
+static void handlePing(struct Message *msg) {
+	require(msg, false, 1);
+	ircFormat("PONG :%s\r\n", msg->params[0]);
+}
+
+static void handleError(struct Message *msg) {
+	require(msg, false, 1);
+	errx(EX_UNAVAILABLE, "%s", msg->params[0]);
+}
+
+static const struct Handler {
+	const char *cmd;
+	Handler *fn;
+} Handlers[] = {
+	{ "001", handleReplyWelcome },
+	{ "005", handleReplyISupport },
+	{ "276", handleReplyWhoisGeneric },
+	{ "307", handleReplyWhoisGeneric },
+	{ "311", handleReplyWhoisUser },
+	{ "312", handleReplyWhoisServer },
+	{ "313", handleReplyWhoisGeneric },
+	{ "317", handleReplyWhoisIdle },
+	{ "318", handleReplyEndOfWhois },
+	{ "319", handleReplyWhoisChannels },
+	{ "330", handleReplyWhoisGeneric },
+	{ "331", handleReplyNoTopic },
+	{ "332", handleReplyTopic },
+	{ "353", handleReplyNames },
+	{ "366", handleReplyEndOfNames },
+	{ "372", handleReplyMOTD },
+	{ "432", handleErrorErroneousNickname },
+	{ "433", handleErrorNicknameInUse },
+	{ "671", handleReplyWhoisGeneric },
+	{ "900", handleReplyLoggedIn },
+	{ "904", handleErrorSASLFail },
+	{ "905", handleErrorSASLFail },
+	{ "906", handleErrorSASLFail },
+	{ "AUTHENTICATE", handleAuthenticate },
+	{ "CAP", handleCap },
+	{ "ERROR", handleError },
+	{ "JOIN", handleJoin },
+	{ "KICK", handleKick },
+	{ "NICK", handleNick },
+	{ "NOTICE", handlePrivmsg },
+	{ "PART", handlePart },
+	{ "PING", handlePing },
+	{ "PRIVMSG", handlePrivmsg },
+	{ "QUIT", handleQuit },
+	{ "TOPIC", handleTopic },
+};
+
+static int compar(const void *cmd, const void *_handler) {
+	const struct Handler *handler = _handler;
+	return strcmp(cmd, handler->cmd);
+}
+
+void handle(struct Message msg) {
+	if (!msg.cmd) return;
+	const struct Handler *handler = bsearch(
+		msg.cmd, Handlers, ARRAY_LEN(Handlers), sizeof(*handler), compar
+	);
+	if (handler) handler->fn(&msg);
+}
diff --git a/irc.c b/irc.c
new file mode 100644
index 0000000..05f8f9d
--- /dev/null
+++ b/irc.c
@@ -0,0 +1,250 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <err.h>
+#include <netdb.h>
+#include <netinet/in.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sysexits.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "chat.h"
+
+struct tls *client;
+
+static byte *readFile(size_t *len, FILE *file) {
+	struct stat stat;
+	int error = fstat(fileno(file), &stat);
+	if (error) err(EX_IOERR, "fstat");
+
+	byte *buf = malloc(stat.st_size);
+	if (!buf) err(EX_OSERR, "malloc");
+
+	rewind(file);
+	*len = fread(buf, 1, stat.st_size, file);
+	if (ferror(file)) err(EX_IOERR, "fread");
+
+	return buf;
+}
+
+void ircConfig(bool insecure, FILE *cert, FILE *priv) {
+	struct tls_config *config = tls_config_new();
+	if (!config) errx(EX_SOFTWARE, "tls_config_new");
+
+	int error = tls_config_set_ciphers(config, "compat");
+	if (error) {
+		errx(
+			EX_SOFTWARE, "tls_config_set_ciphers: %s",
+			tls_config_error(config)
+		);
+	}
+
+	if (insecure) {
+		tls_config_insecure_noverifycert(config);
+		tls_config_insecure_noverifyname(config);
+	}
+
+	if (cert) {
+		size_t len;
+		byte *buf = readFile(&len, cert);
+		error = tls_config_set_cert_mem(config, buf, len);
+		if (error) {
+			errx(
+				EX_CONFIG, "tls_config_set_cert_mem: %s",
+				tls_config_error(config)
+			);
+		}
+		if (priv) {
+			free(buf);
+			buf = readFile(&len, priv);
+		}
+		error = tls_config_set_key_mem(config, buf, len);
+		if (error) {
+			errx(
+				EX_CONFIG, "tls_config_set_key_mem: %s",
+				tls_config_error(config)
+			);
+		}
+		explicit_bzero(buf, len);
+		free(buf);
+	}
+
+	client = tls_client();
+	if (!client) errx(EX_SOFTWARE, "tls_client");
+
+	error = tls_configure(client, config);
+	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+	tls_config_free(config);
+}
+
+int ircConnect(const char *host, const char *port) {
+	assert(client);
+
+	struct addrinfo *head;
+	struct addrinfo hints = {
+		.ai_family = AF_UNSPEC,
+		.ai_socktype = SOCK_STREAM,
+		.ai_protocol = IPPROTO_TCP,
+	};
+	int error = getaddrinfo(host, port, &hints, &head);
+	if (error) errx(EX_NOHOST, "%s:%s: %s", host, port, gai_strerror(error));
+
+	int sock = -1;
+	for (struct addrinfo *ai = head; ai; ai = ai->ai_next) {
+		sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+		if (sock < 0) err(EX_OSERR, "socket");
+
+		error = connect(sock, ai->ai_addr, ai->ai_addrlen);
+		if (!error) break;
+
+		close(sock);
+		sock = -1;
+	}
+	if (sock < 0) err(EX_UNAVAILABLE, "%s:%s", host, port);
+	freeaddrinfo(head);
+
+	error = tls_connect_socket(client, sock, host);
+	if (error) errx(EX_PROTOCOL, "tls_connect: %s", tls_error(client));
+
+	error = tls_handshake(client);
+	if (error) errx(EX_PROTOCOL, "tls_handshake: %s", tls_error(client));
+
+	return sock;
+}
+
+static void debug(char dir, const char *line) {
+	if (!self.debug) return;
+	size_t len = strcspn(line, "\r\n");
+	uiFormat(
+		Debug, Cold, NULL, "\3%d%c%c\3\t%.*s",
+		Gray, dir, dir, (int)len, line
+	);
+	if (!isatty(STDERR_FILENO)) {
+		fprintf(stderr, "%c%c %.*s\n", dir, dir, (int)len, line);
+	}
+}
+
+void ircSend(const char *ptr, size_t len) {
+	assert(client);
+	while (len) {
+		ssize_t ret = tls_write(client, ptr, len);
+		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
+		if (ret < 0) errx(EX_IOERR, "tls_write: %s", tls_error(client));
+		ptr += ret;
+		len -= ret;
+	}
+}
+
+void ircFormat(const char *format, ...) {
+	char buf[1024];
+	va_list ap;
+	va_start(ap, format);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
+	va_end(ap);
+	assert((size_t)len < sizeof(buf));
+	debug('<', buf);
+	ircSend(buf, len);
+}
+
+static const char *TagNames[TagCap] = {
+#define X(name, id) [id] = name,
+	ENUM_TAG
+#undef X
+};
+
+static void unescape(char *tag) {
+	for (;;) {
+		tag = strchr(tag, '\\');
+		if (!tag) break;
+		switch (tag[1]) {
+			break; case ':': tag[1] = ';';
+			break; case 's': tag[1] = ' ';
+			break; case 'r': tag[1] = '\r';
+			break; case 'n': tag[1] = '\n';
+		}
+		memmove(tag, &tag[1], strlen(&tag[1]) + 1);
+		if (tag[0]) tag = &tag[1];
+	}
+}
+
+static struct Message parse(char *line) {
+	struct Message msg = { .cmd = NULL };
+
+	if (line[0] == '@') {
+		char *tags = 1 + strsep(&line, " ");
+		while (tags) {
+			char *tag = strsep(&tags, ";");
+			char *key = strsep(&tag, "=");
+			for (size_t i = 0; i < TagCap; ++i) {
+				if (strcmp(key, TagNames[i])) continue;
+				unescape(tag);
+				msg.tags[i] = tag;
+				break;
+			}
+		}
+	}
+
+	if (line[0] == ':') {
+		char *origin = 1 + strsep(&line, " ");
+		msg.nick = strsep(&origin, "!");
+		msg.user = strsep(&origin, "@");
+		msg.host = origin;
+	}
+
+	msg.cmd = strsep(&line, " ");
+	for (size_t i = 0; line && i < ParamCap; ++i) {
+		if (line[0] == ':') {
+			msg.params[i] = &line[1];
+			break;
+		}
+		msg.params[i] = strsep(&line, " ");
+	}
+
+	return msg;
+}
+
+void ircRecv(void) {
+	static char buf[8191 + 512];
+	static size_t len = 0;
+
+	assert(client);
+	ssize_t ret = tls_read(client, &buf[len], sizeof(buf) - len);
+	if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) return;
+	if (ret < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
+	if (!ret) errx(EX_PROTOCOL, "server closed connection");
+	len += ret;
+
+	char *crlf;
+	char *line = buf;
+	for (;;) {
+		crlf = memmem(line, &buf[len] - line, "\r\n", 2);
+		if (!crlf) break;
+		*crlf = '\0';
+		debug('>', line);
+		handle(parse(line));
+		line = crlf + 2;
+	}
+
+	len -= line - buf;
+	memmove(buf, line, len);
+}
diff --git a/ui.c b/ui.c
new file mode 100644
index 0000000..6c9606d
--- /dev/null
+++ b/ui.c
@@ -0,0 +1,974 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#define _XOPEN_SOURCE_EXTENDED
+
+#include <assert.h>
+#include <ctype.h>
+#include <curses.h>
+#include <err.h>
+#include <errno.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <term.h>
+#include <termios.h>
+#include <time.h>
+#include <unistd.h>
+#include <wchar.h>
+#include <wctype.h>
+
+#include "chat.h"
+
+// Annoying stuff from <term.h>:
+#undef lines
+#undef tab
+
+#ifndef A_ITALIC
+#define A_ITALIC A_NORMAL
+#endif
+
+#define BOTTOM (LINES - 1)
+#define RIGHT (COLS - 1)
+#define PAGE_LINES (LINES - 2)
+
+static WINDOW *status;
+static WINDOW *marker;
+static WINDOW *input;
+
+enum { BufferCap = 512 };
+struct Buffer {
+	time_t times[BufferCap];
+	char *lines[BufferCap];
+	size_t len;
+};
+static_assert(!(BufferCap & (BufferCap - 1)), "BufferCap is power of two");
+
+static void bufferPush(struct Buffer *buffer, time_t time, const char *line) {
+	size_t i = buffer->len++ % BufferCap;
+	free(buffer->lines[i]);
+	buffer->times[i] = time;
+	buffer->lines[i] = strdup(line);
+	if (!buffer->lines[i]) err(EX_OSERR, "strdup");
+}
+
+static time_t bufferTime(const struct Buffer *buffer, size_t i) {
+	return buffer->times[(buffer->len + i) % BufferCap];
+}
+static const char *bufferLine(const struct Buffer *buffer, size_t i) {
+	return buffer->lines[(buffer->len + i) % BufferCap];
+}
+
+enum { WindowLines = BufferCap };
+struct Window {
+	size_t id;
+	struct Buffer buffer;
+	WINDOW *pad;
+	int scroll;
+	bool mark;
+	enum Heat heat;
+	int unreadCount;
+	int unreadLines;
+	struct Window *prev;
+	struct Window *next;
+};
+
+static struct {
+	struct Window *active;
+	struct Window *other;
+	struct Window *head;
+	struct Window *tail;
+} windows;
+
+static void windowAdd(struct Window *window) {
+	if (windows.tail) windows.tail->next = window;
+	window->prev = windows.tail;
+	window->next = NULL;
+	windows.tail = window;
+	if (!windows.head) windows.head = window;
+}
+
+static void windowRemove(struct Window *window) {
+	if (window->prev) window->prev->next = window->next;
+	if (window->next) window->next->prev = window->prev;
+	if (windows.head == window) windows.head = window->next;
+	if (windows.tail == window) windows.tail = window->prev;
+}
+
+static struct Window *windowFor(size_t id) {
+	struct Window *window;
+	for (window = windows.head; window; window = window->next) {
+		if (window->id == id) return window;
+	}
+	window = calloc(1, sizeof(*window));
+	if (!window) err(EX_OSERR, "malloc");
+
+	window->id = id;
+	window->pad = newpad(WindowLines, COLS);
+	if (!window->pad) err(EX_OSERR, "newpad");
+	scrollok(window->pad, true);
+	wmove(window->pad, WindowLines - 1, 0);
+	window->mark = true;
+
+	windowAdd(window);
+	return window;
+}
+
+static short colorPairs;
+
+static void colorInit(void) {
+	start_color();
+	use_default_colors();
+	for (short pair = 0; pair < 16; ++pair) {
+		init_pair(1 + pair, pair % COLORS, -1);
+	}
+	colorPairs = 17;
+}
+
+static attr_t colorAttr(short fg) {
+	if (fg != COLOR_BLACK && fg % COLORS == COLOR_BLACK) return A_BOLD;
+	if (COLORS > 8) return A_NORMAL;
+	return (fg / COLORS & 1 ? A_BOLD : A_NORMAL);
+}
+
+static short colorPair(short fg, short bg) {
+	fg %= COLORS;
+	bg %= COLORS;
+	if (bg == -1 && fg < 16) return 1 + fg;
+	for (short pair = 17; pair < colorPairs; ++pair) {
+		short f, b;
+		pair_content(pair, &f, &b);
+		if (f == fg && b == bg) return pair;
+	}
+	init_pair(colorPairs, fg, bg);
+	return colorPairs++;
+}
+
+// XXX: Assuming terminals will be fine with these even if they're unsupported,
+// since they're "private" modes.
+static const char *EnterFocusMode = "\33[?1004h";
+static const char *ExitFocusMode  = "\33[?1004l";
+static const char *EnterPasteMode = "\33[?2004h";
+static const char *ExitPasteMode  = "\33[?2004l";
+
+// Gain use of C-q, C-s, C-c, C-z, C-y, C-o.
+static void acquireKeys(void) {
+	struct termios term;
+	int error = tcgetattr(STDOUT_FILENO, &term);
+	if (error) err(EX_OSERR, "tcgetattr");
+	term.c_iflag &= ~IXON;
+	term.c_cc[VINTR] = _POSIX_VDISABLE;
+	term.c_cc[VSUSP] = _POSIX_VDISABLE;
+	term.c_cc[VDSUSP] = _POSIX_VDISABLE;
+	term.c_cc[VDISCARD] = _POSIX_VDISABLE;
+	error = tcsetattr(STDOUT_FILENO, TCSADRAIN, &term);
+	if (error) err(EX_OSERR, "tcsetattr");
+}
+
+static void errExit(void) {
+	reset_shell_mode();
+}
+
+#define ENUM_KEY \
+	X(KeyMeta0, "\0330") \
+	X(KeyMeta1, "\0331") \
+	X(KeyMeta2, "\0332") \
+	X(KeyMeta3, "\0333") \
+	X(KeyMeta4, "\0334") \
+	X(KeyMeta5, "\0335") \
+	X(KeyMeta6, "\0336") \
+	X(KeyMeta7, "\0337") \
+	X(KeyMeta8, "\0338") \
+	X(KeyMeta9, "\0339") \
+	X(KeyMetaA, "\33a") \
+	X(KeyMetaB, "\33b") \
+	X(KeyMetaD, "\33d") \
+	X(KeyMetaF, "\33f") \
+	X(KeyMetaL, "\33l") \
+	X(KeyMetaM, "\33m") \
+	X(KeyMetaU, "\33u") \
+	X(KeyMetaSlash, "\33/") \
+	X(KeyFocusIn, "\33[I") \
+	X(KeyFocusOut, "\33[O") \
+	X(KeyPasteOn, "\33[200~") \
+	X(KeyPasteOff, "\33[201~")
+
+enum {
+	KeyMax = KEY_MAX,
+#define X(id, seq) id,
+	ENUM_KEY
+#undef X
+};
+
+void uiInit(void) {
+	initscr();
+	cbreak();
+	noecho();
+	acquireKeys();
+	def_prog_mode();
+	atexit(errExit);
+	colorInit();
+
+	if (!to_status_line && !strncmp(termname(), "xterm", 5)) {
+		to_status_line = "\33]2;";
+		from_status_line = "\7";
+	}
+
+#define X(id, seq) define_key(seq, id);
+	ENUM_KEY
+#undef X
+
+	status = newwin(1, COLS, 0, 0);
+	if (!status) err(EX_OSERR, "newwin");
+
+	marker = newwin(1, COLS, LINES - 2, 0);
+	short fg = 8 + COLOR_BLACK;
+	wbkgd(marker, '~' | colorAttr(fg) | COLOR_PAIR(colorPair(fg, -1)));
+
+	input = newpad(1, 512);
+	if (!input) err(EX_OSERR, "newpad");
+	keypad(input, true);
+	nodelay(input, true);
+
+	windows.active = windowFor(Network);
+	uiShow();
+}
+
+static bool hidden;
+static bool waiting;
+
+static char title[256];
+static char prevTitle[sizeof(title)];
+
+void uiDraw(void) {
+	if (hidden) return;
+	wnoutrefresh(status);
+	struct Window *window = windows.active;
+	pnoutrefresh(
+		window->pad,
+		WindowLines - window->scroll - PAGE_LINES + !!window->scroll, 0,
+		1, 0,
+		BOTTOM - 1 - !!window->scroll, RIGHT
+	);
+	if (window->scroll) {
+		touchwin(marker);
+		wnoutrefresh(marker);
+	}
+	int y, x;
+	getyx(input, y, x);
+	pnoutrefresh(
+		input,
+		0, (x + 1 > RIGHT ? x + 1 - RIGHT : 0),
+		BOTTOM, 0,
+		BOTTOM, RIGHT
+	);
+	doupdate();
+
+	if (!to_status_line) return;
+	if (!strcmp(title, prevTitle)) return;
+	strcpy(prevTitle, title);
+	putp(to_status_line);
+	putp(title);
+	putp(from_status_line);
+	fflush(stdout);
+}
+
+void uiShow(void) {
+	prevTitle[0] = '\0';
+	putp(EnterFocusMode);
+	putp(EnterPasteMode);
+	fflush(stdout);
+	hidden = false;
+}
+
+void uiHide(void) {
+	hidden = true;
+	putp(ExitFocusMode);
+	putp(ExitPasteMode);
+	endwin();
+}
+
+struct Style {
+	attr_t attr;
+	enum Color fg, bg;
+};
+static const struct Style Reset = { A_NORMAL, Default, Default };
+
+static const short Colors[100] = {
+	[Default]    = -1,
+	[White]      = 8 + COLOR_WHITE,
+	[Black]      = 0 + COLOR_BLACK,
+	[Blue]       = 0 + COLOR_BLUE,
+	[Green]      = 0 + COLOR_GREEN,
+	[Red]        = 8 + COLOR_RED,
+	[Brown]      = 0 + COLOR_RED,
+	[Magenta]    = 0 + COLOR_MAGENTA,
+	[Orange]     = 0 + COLOR_YELLOW,
+	[Yellow]     = 8 + COLOR_YELLOW,
+	[LightGreen] = 8 + COLOR_GREEN,
+	[Cyan]       = 0 + COLOR_CYAN,
+	[LightCyan]  = 8 + COLOR_CYAN,
+	[LightBlue]  = 8 + COLOR_BLUE,
+	[Pink]       = 8 + COLOR_MAGENTA,
+	[Gray]       = 8 + COLOR_BLACK,
+	[LightGray]  = 0 + COLOR_WHITE,
+	52, 94, 100, 58, 22, 29, 23, 24, 17, 54, 53, 89,
+	88, 130, 142, 64, 28, 35, 30, 25, 18, 91, 90, 125,
+	124, 166, 184, 106, 34, 49, 37, 33, 19, 129, 127, 161,
+	196, 208, 226, 154, 46, 86, 51, 75, 21, 171, 201, 198,
+	203, 215, 227, 191, 83, 122, 87, 111, 63, 177, 207, 205,
+	217, 223, 229, 193, 157, 158, 159, 153, 147, 183, 219, 212,
+	16, 233, 235, 237, 239, 241, 244, 247, 250, 254, 231,
+};
+
+enum { B = '\2', C = '\3', O = '\17', R = '\26', I = '\35', U = '\37' };
+
+static void styleParse(struct Style *style, const char **str, size_t *len) {
+	switch (**str) {
+		break; case B: (*str)++; style->attr ^= A_BOLD;
+		break; case O: (*str)++; *style = Reset;
+		break; case R: (*str)++; style->attr ^= A_REVERSE;
+		break; case I: (*str)++; style->attr ^= A_ITALIC;
+		break; case U: (*str)++; style->attr ^= A_UNDERLINE;
+		break; case C: {
+			(*str)++;
+			if (!isdigit(**str)) {
+				style->fg = Default;
+				style->bg = Default;
+				break;
+			}
+			style->fg = *(*str)++ - '0';
+			if (isdigit(**str)) style->fg = style->fg * 10 + *(*str)++ - '0';
+			if ((*str)[0] != ',' || !isdigit((*str)[1])) break;
+			(*str)++;
+			style->bg = *(*str)++ - '0';
+			if (isdigit(**str)) style->bg = style->bg * 10 + *(*str)++ - '0';
+		}
+	}
+	*len = strcspn(*str, (const char[]) { B, C, O, R, I, U, '\0' });
+}
+
+static void statusAdd(const char *str) {
+	size_t len;
+	struct Style style = Reset;
+	while (*str) {
+		styleParse(&style, &str, &len);
+		wattr_set(
+			status,
+			style.attr | colorAttr(Colors[style.fg]),
+			colorPair(Colors[style.fg], Colors[style.bg]),
+			NULL
+		);
+		waddnstr(status, str, len);
+		str += len;
+	}
+}
+
+static void statusUpdate(void) {
+	int otherUnread = 0;
+	enum Heat otherHeat = Cold;
+	wmove(status, 0, 0);
+
+	int num;
+	const struct Window *window;
+	for (num = 0, window = windows.head; window; ++num, window = window->next) {
+		if (!window->heat && window != windows.active) continue;
+		if (window != windows.active) {
+			otherUnread += window->unreadCount;
+			if (window->heat > otherHeat) otherHeat = window->heat;
+		}
+		int trunc;
+		char buf[256];
+		snprintf(
+			buf, sizeof(buf), "\3%d%s %d %s %n(\3%02d%d\3%d) ",
+			idColors[window->id], (window == windows.active ? "\26" : ""),
+			num, idNames[window->id],
+			&trunc, (window->heat > Warm ? White : idColors[window->id]),
+			window->unreadCount,
+			idColors[window->id]
+		);
+		if (!window->mark || !window->unreadCount) buf[trunc] = '\0';
+		statusAdd(buf);
+	}
+	wclrtoeol(status);
+
+	window = windows.active;
+	snprintf(title, sizeof(title), "%s %s", self.network, idNames[window->id]);
+	if (window->mark && window->unreadCount) {
+		snprintf(
+			&title[strlen(title)], sizeof(title) - strlen(title),
+			" (%d%s)", window->unreadCount, (window->heat > Warm ? "!" : "")
+		);
+	}
+	if (otherUnread) {
+		snprintf(
+			&title[strlen(title)], sizeof(title) - strlen(title),
+			" (+%d%s)", otherUnread, (otherHeat > Warm ? "!" : "")
+		);
+	}
+}
+
+static void mark(struct Window *window) {
+	if (window->scroll) return;
+	window->mark = true;
+	window->unreadCount = 0;
+	window->unreadLines = 0;
+}
+
+static void unmark(struct Window *window) {
+	if (!window->scroll) {
+		window->mark = false;
+		window->heat = Cold;
+	}
+	statusUpdate();
+}
+
+static void windowScroll(struct Window *window, int n) {
+	mark(window);
+	window->scroll += n;
+	if (window->scroll > WindowLines - PAGE_LINES) {
+		window->scroll = WindowLines - PAGE_LINES;
+	}
+	if (window->scroll < 0) window->scroll = 0;
+	unmark(window);
+}
+
+static void windowScrollUnread(struct Window *window) {
+	window->scroll = 0;
+	windowScroll(window, window->unreadLines - PAGE_LINES + 1);
+}
+
+static int wordWidth(const char *str) {
+	size_t len = strcspn(str, " ");
+	int width = 0;
+	while (len) {
+		wchar_t wc;
+		int n = mbtowc(&wc, str, len);
+		if (n < 1) return width + len;
+		width += (iswprint(wc) ? wcwidth(wc) : 0);
+		str += n;
+		len -= n;
+	}
+	return width;
+}
+
+static int wordWrap(WINDOW *win, const char *str) {
+	int y, x, width;
+	getmaxyx(win, y, width);
+
+	size_t len;
+	int lines = 0;
+	int align = 0;
+	struct Style style = Reset;
+	while (*str) {
+		if (*str == '\t') {
+			if (align) {
+				waddch(win, '\t');
+				str++;
+			} else {
+				waddch(win, ' ');
+				getyx(win, y, align);
+				str++;
+			}
+		} else if (*str == ' ') {
+			getyx(win, y, x);
+			const char *word = &str[strspn(str, " ")];
+			if (width - x - 1 <= wordWidth(word)) {
+				lines += 1 + (align + wordWidth(word)) / width;
+				waddch(win, '\n');
+				getyx(win, y, x);
+				wmove(win, y, align);
+				str = word;
+			} else {
+				waddch(win, ' ');
+				str++;
+			}
+		}
+
+		styleParse(&style, &str, &len);
+		size_t ws = strcspn(str, "\t ");
+		if (ws < len) len = ws;
+
+		wattr_set(
+			win,
+			style.attr | colorAttr(Colors[style.fg]),
+			colorPair(Colors[style.fg], Colors[style.bg]),
+			NULL
+		);
+		waddnstr(win, str, len);
+		str += len;
+	}
+	return lines;
+}
+
+void uiWrite(size_t id, enum Heat heat, const time_t *src, const char *str) {
+	struct Window *window = windowFor(id);
+	time_t clock = (src ? *src : time(NULL));
+	bufferPush(&window->buffer, clock, str);
+
+	int lines = 1;
+	waddch(window->pad, '\n');
+	if (window->mark && heat > Cold) {
+		if (!window->unreadCount++) {
+			lines++;
+			waddch(window->pad, '\n');
+		}
+		if (window->heat < heat) window->heat = heat;
+		statusUpdate();
+	}
+	lines += wordWrap(window->pad, str);
+	window->unreadLines += lines;
+	if (window->scroll) windowScroll(window, lines);
+	if (heat > Warm) beep();
+}
+
+void uiFormat(
+	size_t id, enum Heat heat, const time_t *time, const char *format, ...
+) {
+	char buf[1024];
+	va_list ap;
+	va_start(ap, format);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
+	va_end(ap);
+	assert((size_t)len < sizeof(buf));
+	uiWrite(id, heat, time, buf);
+}
+
+static void reflow(struct Window *window) {
+	werase(window->pad);
+	wmove(window->pad, WindowLines - 1, 0);
+	window->unreadLines = 0;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		const char *line = bufferLine(&window->buffer, i);
+		if (!line) continue;
+		waddch(window->pad, '\n');
+		if (i >= (size_t)(BufferCap - window->unreadCount)) {
+			window->unreadLines += 1 + wordWrap(window->pad, line);
+		} else {
+			wordWrap(window->pad, line);
+		}
+	}
+}
+
+static void resize(void) {
+	mvwin(marker, LINES - 2, 0);
+	int height, width;
+	getmaxyx(windows.active->pad, height, width);
+	if (width == COLS) return;
+	for (struct Window *window = windows.head; window; window = window->next) {
+		wresize(window->pad, BufferCap, COLS);
+		reflow(window);
+	}
+	statusUpdate();
+}
+
+static void bufferList(const struct Buffer *buffer) {
+	uiHide();
+	waiting = true;
+	for (size_t i = 0; i < BufferCap; ++i) {
+		time_t time = bufferTime(buffer, i);
+		const char *line = bufferLine(buffer, i);
+		if (!line) continue;
+
+		struct tm *tm = localtime(&time);
+		if (!tm) continue;
+		char buf[sizeof("[00:00:00]")];
+		strftime(buf, sizeof(buf), "[%T]", tm);
+		vid_attr(colorAttr(Colors[Gray]), colorPair(Colors[Gray], -1), NULL);
+		printf("%s ", buf);
+
+		size_t len;
+		bool align = false;
+		struct Style style = Reset;
+		while (*line) {
+			if (*line == '\t') {
+				printf("%c", (align ? '\t' : ' '));
+				align = true;
+				line++;
+			}
+			styleParse(&style, &line, &len);
+			size_t tab = strcspn(line, "\t");
+			if (tab < len) len = tab;
+			vid_attr(
+				style.attr | colorAttr(Colors[style.fg]),
+				colorPair(Colors[style.fg], Colors[style.bg]),
+				NULL
+			);
+			if (len) printf("%.*s", (int)len, line);
+			line += len;
+		}
+		printf("\n");
+	}
+}
+
+static void inputAdd(struct Style *style, const char *str) {
+	size_t len;
+	while (*str) {
+		const char *code = str;
+		styleParse(style, &str, &len);
+		wattr_set(input, A_BOLD | A_REVERSE, 0, NULL);
+		switch (*code) {
+			break; case B: waddch(input, 'B');
+			break; case C: waddch(input, 'C');
+			break; case O: waddch(input, 'O');
+			break; case R: waddch(input, 'R');
+			break; case I: waddch(input, 'I');
+			break; case U: waddch(input, 'U');
+		}
+		if (str - code > 1) waddnstr(input, &code[1], str - &code[1]);
+		wattr_set(
+			input,
+			style->attr | colorAttr(Colors[style->fg]),
+			colorPair(Colors[style->fg], Colors[style->bg]),
+			NULL
+		);
+		waddnstr(input, str, len);
+		str += len;
+	}
+}
+
+static void inputUpdate(void) {
+	size_t id = windows.active->id;
+	size_t pos;
+	char *buf = editBuffer(&pos);
+
+	const char *skip = NULL;
+	struct Style init = { .fg = self.color, .bg = Default };
+	struct Style rest = Reset;
+	const char *prefix = "";
+	const char *prompt = (self.nick ? self.nick : "");
+	const char *suffix = "";
+	if (NULL != (skip = commandIsPrivmsg(id, buf))) {
+		prefix = "<"; suffix = "> ";
+	} else if (NULL != (skip = commandIsNotice(id, buf))) {
+		prefix = "-"; suffix = "- ";
+		rest.fg = LightGray;
+	} else if (NULL != (skip = commandIsAction(id, buf))) {
+		init.attr |= A_ITALIC;
+		prefix = "* "; suffix = " ";
+		rest.attr |= A_ITALIC;
+	} else if (id == Debug) {
+		skip = buf;
+		init.fg = Gray;
+		prompt = "<< ";
+	} else {
+		prompt = "";
+	}
+	if (skip && skip > &buf[pos]) {
+		skip = NULL;
+		prefix = prompt = suffix = "";
+	}
+
+	int y, x;
+	wmove(input, 0, 0);
+	wattr_set(
+		input,
+		init.attr | colorAttr(Colors[init.fg]),
+		colorPair(Colors[init.fg], Colors[init.bg]),
+		NULL
+	);
+	waddstr(input, prefix);
+	waddstr(input, prompt);
+	waddstr(input, suffix);
+	struct Style style = rest;
+	char p = buf[pos];
+	buf[pos] = '\0';
+	inputAdd(&style, (skip ? skip : buf));
+	getyx(input, y, x);
+	buf[pos] = p;
+	inputAdd(&style, &buf[pos]);
+	wclrtoeol(input);
+	wmove(input, y, x);
+}
+
+static void windowShow(struct Window *window) {
+	if (!window) return;
+	touchwin(window->pad);
+	windows.other = windows.active;
+	windows.active = window;
+	mark(windows.other);
+	unmark(windows.active);
+	inputUpdate();
+}
+
+void uiShowID(size_t id) {
+	windowShow(windowFor(id));
+}
+
+void uiShowNum(size_t num) {
+	struct Window *window = windows.head;
+	for (size_t i = 0; i < num; ++i) {
+		window = window->next;
+		if (!window) return;
+	}
+	windowShow(window);
+}
+
+static void windowClose(struct Window *window) {
+	if (window->id == Network) return;
+	if (windows.active == window) {
+		if (windows.other && windows.other != window) {
+			windowShow(windows.other);
+		} else {
+			windowShow(window->prev ? window->prev : window->next);
+		}
+	}
+	if (windows.other == window) windows.other = NULL;
+	windowRemove(window);
+	for (size_t i = 0; i < BufferCap; ++i) {
+		free(window->buffer.lines[i]);
+	}
+	delwin(window->pad);
+	free(window);
+	statusUpdate();
+}
+
+void uiCloseID(size_t id) {
+	windowClose(windowFor(id));
+}
+
+void uiCloseNum(size_t num) {
+	struct Window *window = windows.head;
+	for (size_t i = 0; i < num; ++i) {
+		window = window->next;
+		if (!window) return;
+	}
+	windowClose(window);
+}
+
+static void showAuto(void) {
+	static struct Window *other;
+	if (windows.other != other) {
+		other = windows.active;
+	}
+	for (struct Window *window = windows.head; window; window = window->next) {
+		if (window->heat < Hot) continue;
+		windowShow(window);
+		windows.other = other;
+		return;
+	}
+	for (struct Window *window = windows.head; window; window = window->next) {
+		if (window->heat < Warm) continue;
+		windowShow(window);
+		windows.other = other;
+		return;
+	}
+	windowShow(windows.other);
+}
+
+static void keyCode(int code) {
+	struct Window *window = windows.active;
+	size_t id = window->id;
+	switch (code) {
+		break; case KEY_RESIZE:  resize();
+		break; case KeyFocusIn:  unmark(window);
+		break; case KeyFocusOut: mark(window);
+		break; case KeyPasteOn:; // TODO
+		break; case KeyPasteOff:; // TODO
+
+		break; case KeyMetaSlash: windowShow(windows.other);
+
+		break; case KeyMetaA: showAuto();
+		break; case KeyMetaB: edit(id, EditPrevWord, 0);
+		break; case KeyMetaD: edit(id, EditDeleteNextWord, 0);
+		break; case KeyMetaF: edit(id, EditNextWord, 0);
+		break; case KeyMetaL: bufferList(&window->buffer);
+		break; case KeyMetaM: waddch(window->pad, '\n');
+		break; case KeyMetaU: windowScrollUnread(window);
+
+		break; case KEY_BACKSPACE: edit(id, EditDeletePrev, 0);
+		break; case KEY_DC: edit(id, EditDeleteNext, 0);
+		break; case KEY_DOWN: windowScroll(window, -1);
+		break; case KEY_END: edit(id, EditTail, 0);
+		break; case KEY_ENTER: edit(id, EditEnter, 0);
+		break; case KEY_HOME: edit(id, EditHead, 0);
+		break; case KEY_LEFT: edit(id, EditPrev, 0);
+		break; case KEY_NPAGE: windowScroll(window, -(PAGE_LINES - 2));
+		break; case KEY_PPAGE: windowScroll(window, +(PAGE_LINES - 2));
+		break; case KEY_RIGHT: edit(id, EditNext, 0);
+		break; case KEY_UP: windowScroll(window, +1);
+		
+		break; default: {
+			if (code >= KeyMeta0 && code <= KeyMeta9) {
+				uiShowNum(code - KeyMeta0);
+			}
+		}
+	}
+}
+
+static void keyCtrl(wchar_t ch) {
+	size_t id = windows.active->id;
+	switch (ch ^ L'@') {
+		break; case L'?': edit(id, EditDeletePrev, 0);
+		break; case L'A': edit(id, EditHead, 0);
+		break; case L'B': edit(id, EditPrev, 0);
+		break; case L'C': raise(SIGINT);
+		break; case L'D': edit(id, EditDeleteNext, 0);
+		break; case L'E': edit(id, EditTail, 0);
+		break; case L'F': edit(id, EditNext, 0);
+		break; case L'H': edit(id, EditDeletePrev, 0);
+		break; case L'I': edit(id, EditComplete, 0);
+		break; case L'J': edit(id, EditEnter, 0);
+		break; case L'K': edit(id, EditDeleteTail, 0);
+		break; case L'L': clearok(curscr, true);
+		break; case L'N': windowShow(windows.active->next);
+		break; case L'O': windowShow(windows.other);
+		break; case L'P': windowShow(windows.active->prev);
+		break; case L'U': edit(id, EditDeleteHead, 0);
+		break; case L'W': edit(id, EditDeletePrevWord, 0);
+		break; case L'Y': edit(id, EditPaste, 0);
+	}
+}
+
+static void keyStyle(wchar_t ch) {
+	size_t id = windows.active->id;
+	switch (iswcntrl(ch) ? ch ^ L'@' : towupper(ch)) {
+		break; case L'B': edit(id, EditInsert, B);
+		break; case L'C': edit(id, EditInsert, C);
+		break; case L'I': edit(id, EditInsert, I);
+		break; case L'O': edit(id, EditInsert, O);
+		break; case L'R': edit(id, EditInsert, R);
+		break; case L'U': edit(id, EditInsert, U);
+	}
+}
+
+void uiRead(void) {
+	if (hidden) {
+		if (waiting) {
+			uiShow();
+			flushinp();
+			waiting = false;
+		} else {
+			return;
+		}
+	}
+
+	int ret;
+	wint_t ch;
+	static bool style;
+	while (ERR != (ret = wget_wch(input, &ch))) {
+		if (ret == KEY_CODE_YES) {
+			keyCode(ch);
+		} else if (ch == (L'Z' ^ L'@')) {
+			style = true;
+			continue;
+		} else if (style) {
+			keyStyle(ch);
+		} else if (iswcntrl(ch)) {
+			keyCtrl(ch);
+		} else {
+			edit(windows.active->id, EditInsert, ch);
+		}
+		style = false;
+	}
+	inputUpdate();
+}
+
+static const size_t Signatures[] = {
+	0x6C72696774616301,
+};
+
+static size_t signatureVersion(size_t signature) {
+	for (size_t i = 0; i < ARRAY_LEN(Signatures); ++i) {
+		if (signature == Signatures[i]) return i;
+	}
+	err(EX_DATAERR, "unknown file signature %zX", signature);
+}
+
+static int writeSize(FILE *file, size_t value) {
+	return (fwrite(&value, sizeof(value), 1, file) ? 0 : -1);
+}
+static int writeTime(FILE *file, time_t time) {
+	return (fwrite(&time, sizeof(time), 1, file) ? 0 : -1);
+}
+static int writeString(FILE *file, const char *str) {
+	return (fwrite(str, strlen(str) + 1, 1, file) ? 0 : -1);
+}
+
+int uiSave(const char *name) {
+	FILE *file = dataOpen(name, "w");
+	if (!file) return -1;
+
+	if (writeSize(file, Signatures[0])) return -1;
+	const struct Window *window;
+	for (window = windows.head; window; window = window->next) {
+		if (writeString(file, idNames[window->id])) return -1;
+		for (size_t i = 0; i < BufferCap; ++i) {
+			time_t time = bufferTime(&window->buffer, i);
+			const char *line = bufferLine(&window->buffer, i);
+			if (!line) continue;
+			if (writeTime(file, time)) return -1;
+			if (writeString(file, line)) return -1;
+		}
+		if (writeTime(file, 0)) return -1;
+	}
+	return fclose(file);
+}
+
+static size_t readSize(FILE *file) {
+	size_t value;
+	fread(&value, sizeof(value), 1, file);
+	if (ferror(file)) err(EX_IOERR, "fread");
+	if (feof(file)) errx(EX_DATAERR, "unexpected eof");
+	return value;
+}
+static time_t readTime(FILE *file) {
+	time_t time;
+	fread(&time, sizeof(time), 1, file);
+	if (ferror(file)) err(EX_IOERR, "fread");
+	if (feof(file)) errx(EX_DATAERR, "unexpected eof");
+	return time;
+}
+static ssize_t readString(FILE *file, char **buf, size_t *cap) {
+	ssize_t len = getdelim(buf, cap, '\0', file);
+	if (len < 0 && !feof(file)) err(EX_IOERR, "getdelim");
+	return len;
+}
+
+void uiLoad(const char *name) {
+	FILE *file = dataOpen(name, "r");
+	if (!file) {
+		if (errno != ENOENT) exit(EX_NOINPUT);
+		file = dataOpen(name, "w");
+		if (!file) exit(EX_CANTCREAT);
+		fclose(file);
+		return;
+	}
+
+	size_t signature = readSize(file);
+	signatureVersion(signature);
+
+	char *buf = NULL;
+	size_t cap = 0;
+	while (0 < readString(file, &buf, &cap)) {
+		struct Window *window = windowFor(idFor(buf));
+		for (;;) {
+			time_t time = readTime(file);
+			if (!time) break;
+			readString(file, &buf, &cap);
+			bufferPush(&window->buffer, time, buf);
+		}
+		reflow(window);
+		waddch(window->pad, '\n');
+	}
+
+	free(buf);
+	fclose(file);
+}
diff --git a/url.c b/url.c
new file mode 100644
index 0000000..1ccc206
--- /dev/null
+++ b/url.c
@@ -0,0 +1,202 @@
+/* Copyright (C) 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <err.h>
+#include <errno.h>
+#include <regex.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <unistd.h>
+
+#include "chat.h"
+
+static const char *Pattern = {
+	"("
+	"cvs|"
+	"ftp|"
+	"git|"
+	"gopher|"
+	"http|"
+	"https|"
+	"irc|"
+	"ircs|"
+	"magnet|"
+	"sftp|"
+	"ssh|"
+	"svn|"
+	"telnet|"
+	"vnc"
+	")"
+	":([^[:space:]>\"()]|[(][^)]*[)])+"
+};
+static regex_t Regex;
+
+static void compile(void) {
+	static bool compiled;
+	if (compiled) return;
+	compiled = true;
+	int error = regcomp(&Regex, Pattern, REG_EXTENDED);
+	if (!error) return;
+	char buf[256];
+	regerror(error, &Regex, buf, sizeof(buf));
+	errx(EX_SOFTWARE, "regcomp: %s: %s", buf, Pattern);
+}
+
+struct URL {
+	size_t id;
+	char *nick;
+	char *url;
+};
+
+enum { Cap = 32 };
+static struct {
+	struct URL urls[Cap];
+	size_t len;
+} ring;
+static_assert(!(Cap & (Cap - 1)), "Cap is power of two");
+
+static void push(size_t id, const char *nick, const char *str, size_t len) {
+	struct URL *url = &ring.urls[ring.len++ % Cap];
+	free(url->nick);
+	free(url->url);
+	url->id = id;
+	url->nick = NULL;
+	if (nick) {
+		url->nick = strdup(nick);
+		if (!url->nick) err(EX_OSERR, "strdup");
+	}
+	url->url = strndup(str, len);
+	if (!url->url) err(EX_OSERR, "strndup");
+}
+
+void urlScan(size_t id, const char *nick, const char *mesg) {
+	if (!mesg) return;
+	compile();
+	regmatch_t match = {0};
+	for (const char *ptr = mesg; *ptr; ptr += match.rm_eo) {
+		if (regexec(&Regex, ptr, 1, &match, 0)) break;
+		push(id, nick, &ptr[match.rm_so], match.rm_eo - match.rm_so);
+	}
+}
+
+const char *urlOpenUtil;
+static const char *OpenUtils[] = { "open", "xdg-open" };
+
+static void urlOpen(const char *url) {
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) return;
+
+	close(STDIN_FILENO);
+	dup2(procPipe[1], STDOUT_FILENO);
+	dup2(procPipe[1], STDERR_FILENO);
+	if (urlOpenUtil) {
+		execlp(urlOpenUtil, urlOpenUtil, url, NULL);
+		warn("%s", urlOpenUtil);
+		_exit(EX_CONFIG);
+	}
+	for (size_t i = 0; i < ARRAY_LEN(OpenUtils); ++i) {
+		execlp(OpenUtils[i], OpenUtils[i], url, NULL);
+		if (errno != ENOENT) {
+			warn("%s", OpenUtils[i]);
+			_exit(EX_CONFIG);
+		}
+	}
+	warnx("no open utility found");
+	_exit(EX_CONFIG);
+}
+
+const char *urlCopyUtil;
+static const char *CopyUtils[] = { "pbcopy", "wl-copy", "xclip", "xsel" };
+
+static void urlCopy(const char *url) {
+	int rw[2];
+	int error = pipe(rw);
+	if (error) err(EX_OSERR, "pipe");
+
+	ssize_t len = write(rw[1], url, strlen(url));
+	if (len < 0) err(EX_IOERR, "write");
+
+	error = close(rw[1]);
+	if (error) err(EX_IOERR, "close");
+
+	pid_t pid = fork();
+	if (pid < 0) err(EX_OSERR, "fork");
+	if (pid) {
+		close(rw[0]);
+		return;
+	}
+
+	dup2(rw[0], STDIN_FILENO);
+	dup2(procPipe[1], STDOUT_FILENO);
+	dup2(procPipe[1], STDERR_FILENO);
+	close(rw[0]);
+	if (urlCopyUtil) {
+		execlp(urlCopyUtil, urlCopyUtil, NULL);
+		warn("%s", urlCopyUtil);
+		_exit(EX_CONFIG);
+	}
+	for (size_t i = 0; i < ARRAY_LEN(CopyUtils); ++i) {
+		execlp(CopyUtils[i], CopyUtils[i], NULL);
+		if (errno != ENOENT) {
+			warn("%s", CopyUtils[i]);
+			_exit(EX_CONFIG);
+		}
+	}
+	warnx("no copy utility found");
+	_exit(EX_CONFIG);
+}
+
+void urlOpenCount(size_t id, size_t count) {
+	for (size_t i = 1; i <= Cap; ++i) {
+		const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+		if (!url->url) break;
+		if (url->id != id) continue;
+		urlOpen(url->url);
+		if (!--count) break;
+	}
+}
+
+void urlOpenMatch(size_t id, const char *str) {
+	for (size_t i = 1; i <= Cap; ++i) {
+		const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+		if (!url->url) break;
+		if (url->id != id) continue;
+		if ((url->nick && !strcmp(url->nick, str)) || strstr(url->url, str)) {
+			urlOpen(url->url);
+			break;
+		}
+	}
+}
+
+void urlCopyMatch(size_t id, const char *str) {
+	for (size_t i = 1; i <= Cap; ++i) {
+		const struct URL *url = &ring.urls[(ring.len - i) % Cap];
+		if (!url->url) break;
+		if (url->id != id) continue;
+		if (
+			!str
+			|| (url->nick && !strcmp(url->nick, str))
+			|| strstr(url->url, str)
+		) {
+			urlCopy(url->url);
+			break;
+		}
+	}
+}
diff --git a/xdg.c b/xdg.c
new file mode 100644
index 0000000..6e33210
--- /dev/null
+++ b/xdg.c
@@ -0,0 +1,134 @@
+/* Copyright (C) 2019, 2020  C. McEnroe <june@causal.agency>
+ *
+ * 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 3 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, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <err.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+
+#include "chat.h"
+
+FILE *configOpen(const char *path, const char *mode) {
+	if (path[0] == '/' || path[0] == '.') goto local;
+
+	const char *home = getenv("HOME");
+	const char *configHome = getenv("XDG_CONFIG_HOME");
+	const char *configDirs = getenv("XDG_CONFIG_DIRS");
+
+	char buf[PATH_MAX];
+	if (configHome) {
+		snprintf(buf, sizeof(buf), "%s/" XDG_SUBDIR "/%s", configHome, path);
+	} else {
+		if (!home) goto local;
+		snprintf(buf, sizeof(buf), "%s/.config/" XDG_SUBDIR "/%s", home, path);
+	}
+	FILE *file = fopen(buf, mode);
+	if (file) return file;
+	if (errno != ENOENT) {
+		warn("%s", buf);
+		return NULL;
+	}
+
+	if (!configDirs) configDirs = "/etc/xdg";
+	while (*configDirs) {
+		size_t len = strcspn(configDirs, ":");
+		snprintf(
+			buf, sizeof(buf), "%.*s/" XDG_SUBDIR "/%s",
+			(int)len, configDirs, path
+		);
+		file = fopen(buf, mode);
+		if (file) return file;
+		if (errno != ENOENT) {
+			warn("%s", buf);
+			return NULL;
+		}
+		configDirs += len;
+		if (*configDirs) configDirs++;
+	}
+
+local:
+	file = fopen(path, mode);
+	if (!file) warn("%s", path);
+	return file;
+}
+
+FILE *dataOpen(const char *path, const char *mode) {
+	if (path[0] == '/' || path[0] == '.') goto local;
+
+	const char *home = getenv("HOME");
+	const char *dataHome = getenv("XDG_DATA_HOME");
+	const char *dataDirs = getenv("XDG_DATA_DIRS");
+
+	char homePath[PATH_MAX];
+	if (dataHome) {
+		snprintf(
+			homePath, sizeof(homePath),
+			"%s/" XDG_SUBDIR "/%s", dataHome, path
+		);
+	} else {
+		if (!home) goto local;
+		snprintf(
+			homePath, sizeof(homePath),
+			"%s/.local/share/" XDG_SUBDIR "/%s", home, path
+		);
+	}
+	FILE *file = fopen(homePath, mode);
+	if (file) return file;
+	if (errno != ENOENT) {
+		warn("%s", homePath);
+		return NULL;
+	}
+
+	char buf[PATH_MAX];
+	if (!dataDirs) dataDirs = "/usr/local/share:/usr/share";
+	while (*dataDirs) {
+		size_t len = strcspn(dataDirs, ":");
+		snprintf(
+			buf, sizeof(buf), "%.*s/" XDG_SUBDIR "/%s",
+			(int)len, dataDirs, path
+		);
+		file = fopen(buf, mode);
+		if (file) return file;
+		if (errno != ENOENT) {
+			warn("%s", buf);
+			return NULL;
+		}
+		dataDirs += len;
+		if (*dataDirs) dataDirs++;
+	}
+
+	if (mode[0] != 'r') {
+		char *base = strrchr(homePath, '/');
+		*base = '\0';
+		int error = mkdir(homePath, S_IRWXU);
+		if (error && errno != EEXIST) {
+			warn("%s", homePath);
+			return NULL;
+		}
+		*base = '/';
+		file = fopen(homePath, mode);
+		if (!file) warn("%s", homePath);
+		return file;
+	}
+
+local:
+	file = fopen(path, mode);
+	if (!file) warn("%s", path);
+	return file;
+}