commit e91396f

Hubert Hirtz  ·  2020-08-25 21:16:54 +0000 UTC
parent 7814ff1
General refactor yay

- Centralize message formatting
- Make line formatting more flexible
- Provide more information in irc events
- Refactor command handling
- Add a /help command
- Make /me reply to last query if from home
- Enforce argument for /me
- Make BufferList and Editor public
- Batch processing of IRC events
11 files changed,  +879, -548
M app.go
M app.go
+200, -207
  1@@ -3,7 +3,7 @@ package senpai
  2 import (
  3 	"crypto/tls"
  4 	"fmt"
  5-	"log"
  6+	"hash/fnv"
  7 	"os/exec"
  8 	"strings"
  9 	"time"
 10@@ -46,10 +46,17 @@ func NewApp(cfg Config) (app *App, err error) {
 11 	}
 12 
 13 	var conn *tls.Conn
 14-	app.win.AddLine(ui.Home, ui.NewLineNow("--", fmt.Sprintf("Connecting to %s...", cfg.Addr)))
 15+	app.addLineNow(ui.Home, ui.Line{
 16+		Head: "--",
 17+		Body: fmt.Sprintf("Connecting to %s...", cfg.Addr),
 18+	})
 19 	conn, err = tls.Dial("tcp", cfg.Addr, nil)
 20 	if err != nil {
 21-		app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", "Connection failed"))
 22+		app.addLineNow(ui.Home, ui.Line{
 23+			Head:      "!!",
 24+			HeadColor: ui.ColorRed,
 25+			Body:      "Connection failed",
 26+		})
 27 		err = nil
 28 		return
 29 	}
 30@@ -66,7 +73,11 @@ func NewApp(cfg Config) (app *App, err error) {
 31 		Debug:    cfg.Debug,
 32 	})
 33 	if err != nil {
 34-		app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", "Registration failed"))
 35+		app.addLineNow(ui.Home, ui.Line{
 36+			Head:      "!!",
 37+			HeadColor: ui.ColorRed,
 38+			Body:      "Registration failed",
 39+		})
 40 	}
 41 
 42 	return
 43@@ -84,7 +95,17 @@ func (app *App) Run() {
 44 		if app.s != nil {
 45 			select {
 46 			case ev := <-app.s.Poll():
 47-				app.handleIRCEvent(ev)
 48+				evs := []irc.Event{ev}
 49+			Batch:
 50+				for i := 0; i < 64; i++ {
 51+					select {
 52+					case ev := <-app.s.Poll():
 53+						evs = append(evs, ev)
 54+					default:
 55+						break Batch
 56+					}
 57+				}
 58+				app.handleIRCEvents(evs)
 59 			case ev := <-app.win.Events:
 60 				app.handleUIEvent(ev)
 61 			}
 62@@ -95,99 +116,114 @@ func (app *App) Run() {
 63 	}
 64 }
 65 
 66+func (app *App) handleIRCEvents(evs []irc.Event) {
 67+	for _, ev := range evs {
 68+		app.handleIRCEvent(ev)
 69+	}
 70+	app.draw()
 71+}
 72+
 73 func (app *App) handleIRCEvent(ev irc.Event) {
 74 	switch ev := ev.(type) {
 75 	case irc.RawMessageEvent:
 76-		head := "DEBUG  IN --"
 77+		head := "IN --"
 78 		if ev.Outgoing {
 79-			head = "DEBUG OUT --"
 80+			head = "OUT --"
 81 		} else if !ev.IsValid {
 82-			head = "DEBUG  IN ??"
 83+			head = "IN ??"
 84 		}
 85-		app.win.AddLine(ui.Home, ui.NewLineNow(head, ev.Message))
 86+		app.win.AddLine(ui.Home, false, ui.Line{
 87+			At:   time.Now(),
 88+			Head: head,
 89+			Body: ev.Message,
 90+		})
 91 	case irc.RegisteredEvent:
 92-		line := "Connected to the server"
 93+		body := "Connected to the server"
 94 		if app.s.Nick() != app.cfg.Nick {
 95-			line += " as " + app.s.Nick()
 96+			body += " as " + app.s.Nick()
 97 		}
 98-		app.win.AddLine(ui.Home, ui.NewLineNow("--", line))
 99+		app.win.AddLine(ui.Home, false, ui.Line{
100+			At:   time.Now(),
101+			Head: "--",
102+			Body: body,
103+		})
104 	case irc.SelfNickEvent:
105-		line := fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.NewNick)
106-		app.win.AddLine(ui.Home, ui.NewLine(ev.Time, "--", line, true, true))
107+		app.win.AddLine(app.win.CurrentBuffer(), true, ui.Line{
108+			At:        ev.Time,
109+			Head:      "--",
110+			Body:      fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, app.s.Nick()),
111+			Highlight: true,
112+		})
113 	case irc.UserNickEvent:
114-		line := fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.NewNick)
115-		app.win.AddLine(ui.Home, ui.NewLine(ev.Time, "--", line, true, false))
116+		for _, c := range app.s.ChannelsSharedWith(ev.User.Name) {
117+			app.win.AddLine(c, false, ui.Line{
118+				At:        ev.Time,
119+				Head:      "--",
120+				Body:      fmt.Sprintf("\x0314%s\x03\u2192\x0314%s\x03", ev.FormerNick, ev.User.Name),
121+				Mergeable: true,
122+			})
123+		}
124 	case irc.SelfJoinEvent:
125 		app.win.AddBuffer(ev.Channel)
126 		app.s.RequestHistory(ev.Channel, time.Now())
127 	case irc.UserJoinEvent:
128-		line := fmt.Sprintf("\x033+\x0314%s\x03", ev.Nick)
129-		app.win.AddLine(ev.Channel, ui.NewLine(ev.Time, "--", line, true, false))
130+		app.win.AddLine(ev.Channel, false, ui.Line{
131+			At:        time.Now(),
132+			Head:      "--",
133+			Body:      fmt.Sprintf("\x033+\x0314%s\x03", ev.User.Name),
134+			Mergeable: true,
135+		})
136 	case irc.SelfPartEvent:
137 		app.win.RemoveBuffer(ev.Channel)
138 	case irc.UserPartEvent:
139-		line := fmt.Sprintf("\x034-\x0314%s\x03", ev.Nick)
140-		for _, channel := range ev.Channels {
141-			app.win.AddLine(channel, ui.NewLine(ev.Time, "--", line, true, false))
142+		app.win.AddLine(ev.Channel, false, ui.Line{
143+			At:        ev.Time,
144+			Head:      "--",
145+			Body:      fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name),
146+			Mergeable: true,
147+		})
148+	case irc.UserQuitEvent:
149+		for _, c := range ev.Channels {
150+			app.win.AddLine(c, false, ui.Line{
151+				At:        ev.Time,
152+				Head:      "--",
153+				Body:      fmt.Sprintf("\x034-\x0314%s\x03", ev.User.Name),
154+				Mergeable: true,
155+			})
156 		}
157-	case irc.QueryMessageEvent:
158-		if ev.Command == "PRIVMSG" {
159-			isHighlight := true
160-			head := ev.Nick
161-			if app.s.NickCf() == strings.ToLower(ev.Nick) {
162-				isHighlight = false
163-				head = "\u2192 " + ev.Target
164-			} else {
165-				app.lastQuery = ev.Nick
166-			}
167-			l := ui.LineFromIRCMessage(ev.Time, head, ev.Content, false, false)
168-			app.win.AddLine(ui.Home, l)
169-			app.win.TypingStop(ui.Home, ev.Nick)
170-			if isHighlight {
171-				app.notifyHighlight(ui.Home, ev.Nick, ev.Content)
172-			}
173-		} else if ev.Command == "NOTICE" {
174-			l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, true, false)
175-			app.win.AddLine("", l)
176-			app.win.TypingStop("", ev.Nick)
177-		} else {
178-			log.Panicf("received unknown command for query event: %q\n", ev.Command)
179+	case irc.MessageEvent:
180+		buffer, line, hlNotification := app.formatMessage(ev)
181+		app.win.AddLine(buffer, hlNotification, line)
182+		if hlNotification {
183+			app.notifyHighlight(buffer, ev.User.Name, ev.Content)
184 		}
185-	case irc.ChannelMessageEvent:
186-		isHighlight := app.isHighlight(ev.Nick, ev.Content)
187-		l := ui.LineFromIRCMessage(ev.Time, ev.Nick, ev.Content, ev.Command == "NOTICE", isHighlight)
188-		app.win.AddLine(ev.Channel, l)
189-		app.win.TypingStop(ev.Channel, ev.Nick)
190-		if isHighlight {
191-			app.notifyHighlight(ev.Channel, ev.Nick, ev.Content)
192+		app.win.TypingStop(buffer, ev.User.Name)
193+		if !ev.TargetIsChannel && app.s.NickCf() != app.s.Casemap(ev.User.Name) {
194+			app.lastQuery = ev.User.Name
195 		}
196-	case irc.QueryTagEvent:
197-		if ev.Typing == irc.TypingActive || ev.Typing == irc.TypingPaused {
198-			app.win.TypingStart(ui.Home, ev.Nick)
199-		} else if ev.Typing == irc.TypingDone {
200-			app.win.TypingStop(ui.Home, ev.Nick)
201+	case irc.TagEvent:
202+		buffer := ev.Target
203+		if !ev.TargetIsChannel {
204+			buffer = ui.Home
205 		}
206-	case irc.ChannelTagEvent:
207 		if ev.Typing == irc.TypingActive || ev.Typing == irc.TypingPaused {
208-			app.win.TypingStart(ev.Channel, ev.Nick)
209+			app.win.TypingStart(buffer, ev.User.Name)
210 		} else if ev.Typing == irc.TypingDone {
211-			app.win.TypingStop(ev.Channel, ev.Nick)
212+			app.win.TypingStop(buffer, ev.User.Name)
213 		}
214 	case irc.HistoryEvent:
215 		var lines []ui.Line
216 		for _, m := range ev.Messages {
217 			switch m := m.(type) {
218-			case irc.ChannelMessageEvent:
219-				isHighlight := app.isHighlight(m.Nick, m.Content)
220-				l := ui.LineFromIRCMessage(m.Time, m.Nick, m.Content, m.Command == "NOTICE", isHighlight)
221-				lines = append(lines, l)
222+			case irc.MessageEvent:
223+				_, line, _ := app.formatMessage(m)
224+				lines = append(lines, line)
225 			default:
226-				panic("TODO")
227 			}
228 		}
229 		app.win.AddLines(ev.Target, lines)
230 	case error:
231-		log.Panicln(ev)
232+		panic(ev)
233 	}
234 }
235 
236@@ -258,18 +294,28 @@ func (app *App) handleUIEvent(ev tcell.Event) {
237 		case tcell.KeyCR, tcell.KeyLF:
238 			buffer := app.win.CurrentBuffer()
239 			input := app.win.InputEnter()
240-			app.handleInput(buffer, input)
241+			err := app.handleInput(buffer, input)
242+			if err != nil {
243+				app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
244+					At:        time.Now(),
245+					Head:      "!!",
246+					HeadColor: ui.ColorRed,
247+					Body:      fmt.Sprintf("%q: %s", input, err),
248+				})
249+			}
250 		case tcell.KeyRune:
251 			app.win.InputRune(ev.Rune())
252 			app.typing()
253+		default:
254+			return
255 		}
256+	default:
257+		return
258 	}
259+	app.draw()
260 }
261 
262-func (app *App) isHighlight(nick, content string) bool {
263-	if app.s.NickCf() == strings.ToLower(nick) {
264-		return false
265-	}
266+func (app *App) isHighlight(content string) bool {
267 	contentCf := strings.ToLower(content)
268 	if app.highlights == nil {
269 		return strings.Contains(contentCf, app.s.NickCf())
270@@ -300,8 +346,12 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
271 	command := r.Replace(app.cfg.OnHighlight)
272 	err = exec.Command(sh, "-c", command).Run()
273 	if err != nil {
274-		line := fmt.Sprintf("Failed to invoke on-highlight command: %v", err)
275-		app.win.AddLine(ui.Home, ui.NewLineNow("ERROR --", line))
276+		app.win.AddLine(ui.Home, false, ui.Line{
277+			At:        time.Now(),
278+			Head:      "ERROR --",
279+			HeadColor: ui.ColorRed,
280+			Body:      fmt.Sprintf("Failed to invoke on-highlight command: %v", err),
281+		})
282 	}
283 }
284 
285@@ -320,139 +370,6 @@ func (app *App) typing() {
286 	}
287 }
288 
289-func parseCommand(s string) (command, args string) {
290-	if s == "" {
291-		return
292-	}
293-
294-	if s[0] != '/' {
295-		args = s
296-		return
297-	}
298-
299-	i := strings.IndexByte(s, ' ')
300-	if i < 0 {
301-		i = len(s)
302-	}
303-
304-	command = strings.ToUpper(s[1:i])
305-	args = strings.TrimLeft(s[i:], " ")
306-
307-	return
308-}
309-
310-func (app *App) handleInput(buffer, content string) {
311-	cmd, args := parseCommand(content)
312-
313-	switch cmd {
314-	case "":
315-		if buffer == ui.Home || len(strings.TrimSpace(args)) == 0 {
316-			return
317-		}
318-
319-		app.s.PrivMsg(buffer, args)
320-		if !app.s.HasCapability("echo-message") {
321-			app.win.AddLine(buffer, ui.NewLineNow(app.s.Nick(), args))
322-		}
323-	case "QUOTE":
324-		app.s.SendRaw(args)
325-	case "J", "JOIN":
326-		app.s.Join(args)
327-	case "PART":
328-		channel := buffer
329-		reason := args
330-		spl := strings.SplitN(args, " ", 2)
331-		if 0 < len(spl) && app.s.IsChannel(spl[0]) {
332-			channel = spl[0]
333-			if 1 < len(spl) {
334-				reason = spl[1]
335-			} else {
336-				reason = ""
337-			}
338-		}
339-
340-		if channel != ui.Home {
341-			app.s.Part(channel, reason)
342-		}
343-	case "NAMES":
344-		if buffer == ui.Home {
345-			return
346-		}
347-
348-		var sb strings.Builder
349-		sb.WriteString("\x0314Names: ")
350-		for _, name := range app.s.Names(buffer) {
351-			if name.PowerLevel != "" {
352-				sb.WriteString("\x033")
353-				sb.WriteString(name.PowerLevel)
354-				sb.WriteString("\x0314")
355-			}
356-			sb.WriteString(name.Nick)
357-			sb.WriteRune(' ')
358-		}
359-		line := sb.String()
360-		app.win.AddLine(buffer, ui.NewLineNow("--", line[:len(line)-1]))
361-	case "TOPIC":
362-		if buffer == ui.Home {
363-			return
364-		}
365-
366-		if args == "" {
367-			var line string
368-
369-			topic, who, at := app.s.Topic(buffer)
370-			if who == "" {
371-				line = fmt.Sprintf("\x0314Topic: %s", topic)
372-			} else {
373-				line = fmt.Sprintf("\x0314Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic)
374-			}
375-			app.win.AddLine(buffer, ui.NewLineNow("--", line))
376-		} else {
377-			app.s.SetTopic(buffer, args)
378-		}
379-	case "ME":
380-		if buffer == ui.Home {
381-			return
382-		}
383-
384-		args := fmt.Sprintf("\x01ACTION %s\x01", args)
385-		app.s.PrivMsg(buffer, args)
386-		if !app.s.HasCapability("echo-message") {
387-			line := ui.LineFromIRCMessage(time.Now(), app.s.Nick(), args, false, false)
388-			app.win.AddLine(buffer, line)
389-		}
390-	case "MSG":
391-		split := strings.SplitN(args, " ", 2)
392-		if len(split) < 2 {
393-			return
394-		}
395-
396-		target := split[0]
397-		content := split[1]
398-		app.s.PrivMsg(target, content)
399-		if !app.s.HasCapability("echo-message") {
400-			if app.s.IsChannel(target) {
401-				buffer = ui.Home
402-			} else {
403-				buffer = target
404-			}
405-			line := ui.LineFromIRCMessage(time.Now(), app.s.Nick(), content, false, false)
406-			app.win.AddLine(buffer, line)
407-		}
408-	case "R":
409-		if buffer != ui.Home {
410-			return
411-		}
412-
413-		app.s.PrivMsg(app.lastQuery, args)
414-		if !app.s.HasCapability("echo-message") {
415-			head := "\u2192 " + app.lastQuery
416-			line := ui.LineFromIRCMessage(time.Now(), head, args, false, false)
417-			app.win.AddLine(ui.Home, line)
418-		}
419-	}
420-}
421-
422 func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
423 	var cs []ui.Completion
424 
425@@ -468,10 +385,10 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
426 	}
427 	start++
428 	word := string(text[start:cursorIdx])
429-	wordCf := strings.ToLower(word)
430+	wordCf := app.s.Casemap(word)
431 	for _, name := range app.s.Names(app.win.CurrentBuffer()) {
432-		if strings.HasPrefix(strings.ToLower(name.Nick), wordCf) {
433-			nickComp := []rune(name.Nick)
434+		if strings.HasPrefix(app.s.Casemap(name.Name.Name), wordCf) {
435+			nickComp := []rune(name.Name.Name)
436 			if start == 0 {
437 				nickComp = append(nickComp, ':')
438 			}
439@@ -499,6 +416,82 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
440 	return cs
441 }
442 
443+func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) {
444+	isFromSelf := app.s.NickCf() == app.s.Casemap(ev.User.Name)
445+	isHighlight := app.isHighlight(ev.Content)
446+	isAction := strings.HasPrefix(ev.Content, "\x01ACTION")
447+	isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG"
448+	isNotice := ev.Command == "NOTICE"
449+
450+	if !ev.TargetIsChannel && isNotice {
451+		buffer = app.win.CurrentBuffer()
452+	} else if !ev.TargetIsChannel {
453+		buffer = ui.Home
454+	} else {
455+		buffer = ev.Target
456+	}
457+
458+	hlLine := ev.TargetIsChannel && isHighlight && !isFromSelf
459+	hlNotification = (isHighlight || isQuery) && !isFromSelf
460+
461+	head := ev.User.Name
462+	headColor := ui.ColorWhite
463+	if isFromSelf && isQuery {
464+		head = "\u2192 " + ev.Target
465+		headColor = app.identColor(ev.Target)
466+	} else if isAction || isNotice {
467+		head = "*"
468+	} else {
469+		headColor = app.identColor(head)
470+	}
471+
472+	body := strings.TrimSuffix(ev.Content, "\x01")
473+	if isNotice && isAction {
474+		c := ircColorSequence(app.identColor(ev.User.Name))
475+		body = fmt.Sprintf("(%s%s\x0F:%s)", c, ev.User.Name, body[7:])
476+	} else if isAction {
477+		c := ircColorSequence(app.identColor(ev.User.Name))
478+		body = fmt.Sprintf("%s%s\x0F%s", c, ev.User.Name, body[7:])
479+	} else if isNotice {
480+		c := ircColorSequence(app.identColor(ev.User.Name))
481+		body = fmt.Sprintf("(%s%s\x0F: %s)", c, ev.User.Name, body)
482+	}
483+
484+	line = ui.Line{
485+		At:        ev.Time,
486+		Head:      head,
487+		Body:      body,
488+		HeadColor: headColor,
489+		Highlight: hlLine,
490+	}
491+	return
492+}
493+
494+func ircColorSequence(code int) string {
495+	var c [3]rune
496+	c[0] = 0x03
497+	c[1] = rune(code/10) + '0'
498+	c[2] = rune(code%10) + '0'
499+	return string(c[:])
500+}
501+
502+// see <https://modern.ircdocs.horse/formatting.html>
503+var identColorBlacklist = []int{1, 8, 16, 27, 28, 88, 89, 90, 91}
504+
505+func (app *App) identColor(s string) (code int) {
506+	h := fnv.New32()
507+	_, _ = h.Write([]byte(s))
508+
509+	code = int(h.Sum32()) % (99 - len(identColorBlacklist))
510+	for _, c := range identColorBlacklist {
511+		if c <= code {
512+			code++
513+		}
514+	}
515+
516+	return
517+}
518+
519 func cleanMessage(s string) string {
520 	var res strings.Builder
521 	var sb ui.StyleBuffer
+339, -0
  1@@ -0,0 +1,339 @@
  2+package senpai
  3+
  4+import (
  5+	"fmt"
  6+	"strings"
  7+	"time"
  8+
  9+	"git.sr.ht/~taiite/senpai/irc"
 10+	"git.sr.ht/~taiite/senpai/ui"
 11+)
 12+
 13+type command struct {
 14+	MinArgs   int
 15+	AllowHome bool
 16+	Usage     string
 17+	Desc      string
 18+	Handle    func(app *App, buffer string, args []string) error
 19+}
 20+
 21+type commandSet map[string]*command
 22+
 23+var commands commandSet
 24+
 25+func init() {
 26+	commands = commandSet{
 27+		"": {
 28+			MinArgs: 1,
 29+			Handle:  commandDo,
 30+		},
 31+		"HELP": {
 32+			AllowHome: true,
 33+			Usage:     "[command]",
 34+			Desc:      "show the list of commands, or how to use the given one",
 35+			Handle:    commandDoHelp,
 36+		},
 37+		"JOIN": {
 38+			MinArgs:   1,
 39+			AllowHome: true,
 40+			Usage:     "<channels> [keys]",
 41+			Desc:      "join a channel",
 42+			Handle:    commandDoJoin,
 43+		},
 44+		"ME": {
 45+			AllowHome: true,
 46+			MinArgs:   1,
 47+			Usage:     "<message>",
 48+			Desc:      "send an action (reply to last query if sent from home)",
 49+			Handle:    commandDoMe,
 50+		},
 51+		"MSG": {
 52+			AllowHome: true,
 53+			MinArgs:   2,
 54+			Usage:     "<target> <message>",
 55+			Desc:      "send a message to the given target",
 56+			Handle:    commandDoMsg,
 57+		},
 58+		"NAMES": {
 59+			Desc:   "show the member list of the current channel",
 60+			Handle: commandDoNames,
 61+		},
 62+		"PART": {
 63+			AllowHome: true,
 64+			Usage:     "[channel] [reason]",
 65+			Desc:      "part a channel",
 66+			Handle:    commandDoPart,
 67+		},
 68+		"QUOTE": {
 69+			MinArgs:   1,
 70+			AllowHome: true,
 71+			Usage:     "<raw message>",
 72+			Desc:      "send raw protocol data",
 73+			Handle:    commandDoQuote,
 74+		},
 75+		"R": {
 76+			AllowHome: true,
 77+			MinArgs:   1,
 78+			Usage:     "<message>",
 79+			Desc:      "reply to the last query",
 80+			Handle:    commandDoR,
 81+		},
 82+		"TOPIC": {
 83+			Usage:  "[topic]",
 84+			Desc:   "show or set the topic of the current channel",
 85+			Handle: commandDoTopic,
 86+		},
 87+	}
 88+}
 89+
 90+func commandDo(app *App, buffer string, args []string) (err error) {
 91+	app.s.PrivMsg(buffer, args[0])
 92+	if !app.s.HasCapability("echo-message") {
 93+		buffer, line, _ := app.formatMessage(irc.MessageEvent{
 94+			User:            &irc.Prefix{Name: app.s.Nick()},
 95+			Target:          buffer,
 96+			TargetIsChannel: true,
 97+			Command:         "PRIVMSG",
 98+			Content:         args[0],
 99+			Time:            time.Now(),
100+		})
101+		app.win.AddLine(buffer, false, line)
102+	}
103+	return
104+}
105+
106+func commandDoHelp(app *App, buffer string, args []string) (err error) {
107+	// TODO
108+	t := time.Now()
109+	if len(args) == 0 {
110+		app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
111+			At:   t,
112+			Head: "--",
113+			Body: "Available commands:",
114+		})
115+		for cmdName, cmd := range commands {
116+			if cmd.Desc == "" {
117+				continue
118+			}
119+			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
120+				At:   t,
121+				Body: fmt.Sprintf("  \x02%s\x02 %s", cmdName, cmd.Usage),
122+			})
123+			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
124+				At:   t,
125+				Body: fmt.Sprintf("    %s", cmd.Desc),
126+			})
127+			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
128+				At: t,
129+			})
130+		}
131+	} else {
132+		search := strings.ToUpper(args[0])
133+		found := false
134+		app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
135+			At:   t,
136+			Head: "--",
137+			Body: fmt.Sprintf("Commands that match \"%s\":", search),
138+		})
139+		for cmdName, cmd := range commands {
140+			if !strings.Contains(cmdName, search) {
141+				continue
142+			}
143+			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
144+				At:   t,
145+				Body: fmt.Sprintf("\x02%s\x02 %s", cmdName, cmd.Usage),
146+			})
147+			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
148+				At:   t,
149+				Body: fmt.Sprintf("  %s", cmd.Desc),
150+			})
151+			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
152+				At: t,
153+			})
154+			found = true
155+		}
156+		if !found {
157+			app.win.AddLine(app.win.CurrentBuffer(), false, ui.Line{
158+				At:   t,
159+				Body: fmt.Sprintf("  no command matches %q", args[0]),
160+			})
161+		}
162+	}
163+	return
164+}
165+
166+func commandDoJoin(app *App, buffer string, args []string) (err error) {
167+	app.s.Join(args[0])
168+	return
169+}
170+
171+func commandDoMe(app *App, buffer string, args []string) (err error) {
172+	if buffer == ui.Home {
173+		buffer = app.lastQuery
174+	}
175+	content := fmt.Sprintf("\x01ACTION %s\x01", args[0])
176+	app.s.PrivMsg(buffer, content)
177+	if !app.s.HasCapability("echo-message") {
178+		buffer, line, _ := app.formatMessage(irc.MessageEvent{
179+			User:            &irc.Prefix{Name: app.s.Nick()},
180+			Target:          buffer,
181+			TargetIsChannel: true,
182+			Command:         "PRIVMSG",
183+			Content:         content,
184+			Time:            time.Now(),
185+		})
186+		app.win.AddLine(buffer, false, line)
187+	}
188+	return
189+}
190+
191+func commandDoMsg(app *App, buffer string, args []string) (err error) {
192+	target := args[0]
193+	content := args[1]
194+	app.s.PrivMsg(target, content)
195+	if !app.s.HasCapability("echo-message") {
196+		buffer, line, _ := app.formatMessage(irc.MessageEvent{
197+			User:            &irc.Prefix{Name: app.s.Nick()},
198+			Target:          target,
199+			TargetIsChannel: true,
200+			Command:         "PRIVMSG",
201+			Content:         content,
202+			Time:            time.Now(),
203+		})
204+		app.win.AddLine(buffer, false, line)
205+	}
206+	return
207+}
208+
209+func commandDoNames(app *App, buffer string, args []string) (err error) {
210+	var sb strings.Builder
211+	sb.WriteString("\x0314Names: ")
212+	for _, name := range app.s.Names(buffer) {
213+		if name.PowerLevel != "" {
214+			sb.WriteString("\x033")
215+			sb.WriteString(name.PowerLevel)
216+			sb.WriteString("\x0314")
217+		}
218+		sb.WriteString(name.Name.Name)
219+		sb.WriteRune(' ')
220+	}
221+	body := sb.String()
222+	app.win.AddLine(buffer, false, ui.Line{
223+		At:   time.Now(),
224+		Head: "--",
225+		Body: body[:len(body)-1],
226+	})
227+	return
228+}
229+
230+func commandDoPart(app *App, buffer string, args []string) (err error) {
231+	channel := buffer
232+	reason := ""
233+	if 0 < len(args) {
234+		if app.s.IsChannel(args[0]) {
235+			channel = args[0]
236+			if 1 < len(args) {
237+				reason = args[1]
238+			}
239+		} else {
240+			reason = args[0]
241+		}
242+	}
243+
244+	if channel != ui.Home {
245+		app.s.Part(channel, reason)
246+	} else {
247+		err = fmt.Errorf("cannot part home!")
248+	}
249+	return
250+}
251+
252+func commandDoQuote(app *App, buffer string, args []string) (err error) {
253+	app.s.SendRaw(args[0])
254+	return
255+}
256+
257+func commandDoR(app *App, buffer string, args []string) (err error) {
258+	app.s.PrivMsg(app.lastQuery, args[0])
259+	if !app.s.HasCapability("echo-message") {
260+		buffer, line, _ := app.formatMessage(irc.MessageEvent{
261+			User:            &irc.Prefix{Name: app.s.Nick()},
262+			Target:          app.lastQuery,
263+			TargetIsChannel: true,
264+			Command:         "PRIVMSG",
265+			Content:         args[0],
266+			Time:            time.Now(),
267+		})
268+		app.win.AddLine(buffer, false, line)
269+	}
270+	return
271+}
272+
273+func commandDoTopic(app *App, buffer string, args []string) (err error) {
274+	if len(args) == 0 {
275+		var body string
276+
277+		topic, who, at := app.s.Topic(buffer)
278+		if who == nil {
279+			body = fmt.Sprintf("\x0314Topic: %s", topic)
280+		} else {
281+			body = fmt.Sprintf("\x0314Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic)
282+		}
283+		app.win.AddLine(buffer, false, ui.Line{
284+			At:   time.Now(),
285+			Head: "--",
286+			Body: body,
287+		})
288+	} else {
289+		app.s.SetTopic(buffer, args[0])
290+	}
291+	return
292+}
293+
294+func parseCommand(s string) (command, args string) {
295+	if s == "" {
296+		return
297+	}
298+
299+	if s[0] != '/' {
300+		args = s
301+		return
302+	}
303+
304+	i := strings.IndexByte(s, ' ')
305+	if i < 0 {
306+		i = len(s)
307+	}
308+
309+	command = strings.ToUpper(s[1:i])
310+	args = strings.TrimLeft(s[i:], " ")
311+
312+	return
313+}
314+
315+func (app *App) handleInput(buffer, content string) error {
316+	cmdName, rawArgs := parseCommand(content)
317+
318+	cmd, ok := commands[cmdName]
319+	if !ok {
320+		return fmt.Errorf("command %q doesn't exist", cmdName)
321+	}
322+
323+	var args []string
324+	if rawArgs == "" {
325+		args = nil
326+	} else if cmd.MinArgs == 0 {
327+		args = []string{rawArgs}
328+	} else {
329+		args = strings.SplitN(rawArgs, " ", cmd.MinArgs)
330+	}
331+
332+	if len(args) < cmd.MinArgs {
333+		return fmt.Errorf("usage: %s %s", cmdName, cmd.Usage)
334+	}
335+	if buffer == ui.Home && !cmd.AllowHome {
336+		return fmt.Errorf("command %q cannot be executed from home", cmdName)
337+	}
338+
339+	return cmd.Handle(app, buffer, args)
340+}
+3, -0
 1@@ -100,6 +100,9 @@ be interpreted as a command:
 2 
 3 _name_ is matched case-insensitively.  It can be one of the following:
 4 
 5+*HELP* [search]
 6+    Show the list of command (or a commands that match the given search terms).
 7+
 8 *J*, *JOIN* <channel>
 9 	Join the given channel.
10 
+21, -28
 1@@ -16,13 +16,12 @@ type RegisteredEvent struct{}
 2 
 3 type SelfNickEvent struct {
 4 	FormerNick string
 5-	NewNick    string
 6 	Time       time.Time
 7 }
 8 
 9 type UserNickEvent struct {
10+	User       *Prefix
11 	FormerNick string
12-	NewNick    string
13 	Time       time.Time
14 }
15 
16@@ -31,7 +30,7 @@ type SelfJoinEvent struct {
17 }
18 
19 type UserJoinEvent struct {
20-	Nick    string
21+	User    *Prefix
22 	Channel string
23 	Time    time.Time
24 }
25@@ -41,38 +40,32 @@ type SelfPartEvent struct {
26 }
27 
28 type UserPartEvent struct {
29-	Nick     string
30-	Channels []string
31-	Time     time.Time
32-}
33-
34-type QueryMessageEvent struct {
35-	Nick    string
36-	Target  string
37-	Command string
38-	Content string
39+	User    *Prefix
40+	Channel string
41 	Time    time.Time
42 }
43 
44-type ChannelMessageEvent struct {
45-	Nick    string
46-	Channel string
47-	Command string
48-	Content string
49-	Time    time.Time
50+type UserQuitEvent struct {
51+	User     *Prefix
52+	Channels []string
53+	Time     time.Time
54 }
55 
56-type QueryTagEvent struct {
57-	Nick   string
58-	Typing int
59-	Time   time.Time
60+type MessageEvent struct {
61+	User            *Prefix
62+	Target          string
63+	TargetIsChannel bool
64+	Command         string
65+	Content         string
66+	Time            time.Time
67 }
68 
69-type ChannelTagEvent struct {
70-	Nick    string
71-	Channel string
72-	Typing  int
73-	Time    time.Time
74+type TagEvent struct {
75+	User            *Prefix
76+	Target          string
77+	TargetIsChannel bool
78+	Typing          int
79+	Time            time.Time
80 }
81 
82 type HistoryEvent struct {
+106, -101
  1@@ -106,7 +106,7 @@ type (
  2 )
  3 
  4 type User struct {
  5-	Nick    string
  6+	Name    *Prefix
  7 	AwayMsg string
  8 }
  9 
 10@@ -114,7 +114,7 @@ type Channel struct {
 11 	Name      string
 12 	Members   map[*User]string
 13 	Topic     string
 14-	TopicWho  string
 15+	TopicWho  *Prefix
 16 	TopicTime time.Time
 17 	Secret    bool
 18 }
 19@@ -161,13 +161,13 @@ type Session struct {
 20 func NewSession(conn io.ReadWriteCloser, params SessionParams) (*Session, error) {
 21 	s := &Session{
 22 		conn:          conn,
 23-		msgs:          make(chan Message, 16),
 24-		acts:          make(chan action, 16),
 25-		evts:          make(chan Event, 16),
 26+		msgs:          make(chan Message, 64),
 27+		acts:          make(chan action, 64),
 28+		evts:          make(chan Event, 64),
 29 		debug:         params.Debug,
 30 		typingStamps:  map[string]time.Time{},
 31 		nick:          params.Nickname,
 32-		nickCf:        strings.ToLower(params.Nickname),
 33+		nickCf:        CasemapASCII(params.Nickname),
 34 		user:          params.Username,
 35 		real:          params.RealName,
 36 		auth:          params.Auth,
 37@@ -191,7 +191,7 @@ func NewSession(conn io.ReadWriteCloser, params SessionParams) (*Session, error)
 38 
 39 		for r.Scan() {
 40 			line := r.Text()
 41-			msg, err := Tokenize(line)
 42+			msg, err := ParseMessage(line)
 43 			if err != nil {
 44 				continue
 45 			}
 46@@ -248,22 +248,43 @@ func (s *Session) IsChannel(name string) bool {
 47 	return strings.IndexAny(name, "#&") == 0 // TODO compute CHANTYPES
 48 }
 49 
 50-func (s *Session) Names(channel string) []Name {
 51-	var names []Name
 52-	if c, ok := s.channels[strings.ToLower(channel)]; ok {
 53-		names = make([]Name, 0, len(c.Members))
 54+func (s *Session) Casemap(name string) string {
 55+	// TODO use CASEMAPPING
 56+	return CasemapASCII(name)
 57+}
 58+
 59+func (s *Session) Names(channel string) []Member {
 60+	var names []Member
 61+	if c, ok := s.channels[s.Casemap(channel)]; ok {
 62+		names = make([]Member, 0, len(c.Members))
 63 		for u, pl := range c.Members {
 64-			names = append(names, Name{
 65+			names = append(names, Member{
 66 				PowerLevel: pl,
 67-				Nick:       u.Nick,
 68+				Name:       u.Name.Copy(),
 69 			})
 70 		}
 71 	}
 72 	return names
 73 }
 74 
 75-func (s *Session) Topic(channel string) (topic string, who string, at time.Time) {
 76-	channelCf := strings.ToLower(channel)
 77+func (s *Session) ChannelsSharedWith(name string) []string {
 78+	var user *User
 79+	if u, ok := s.users[s.Casemap(name)]; ok {
 80+		user = u
 81+	} else {
 82+		return nil
 83+	}
 84+	var channels []string
 85+	for _, c := range s.channels {
 86+		if _, ok := c.Members[user]; ok {
 87+			channels = append(channels, c.Name)
 88+		}
 89+	}
 90+	return channels
 91+}
 92+
 93+func (s *Session) Topic(channel string) (topic string, who *Prefix, at time.Time) {
 94+	channelCf := s.Casemap(channel)
 95 	if c, ok := s.channels[channelCf]; ok {
 96 		topic = c.Topic
 97 		who = c.TopicWho
 98@@ -326,7 +347,7 @@ func (s *Session) typing(act actionTyping) (err error) {
 99 		return
100 	}
101 
102-	to := strings.ToLower(act.Channel)
103+	to := s.Casemap(act.Channel)
104 	now := time.Now()
105 
106 	if t, ok := s.typingStamps[to]; ok && now.Sub(t).Seconds() < 3.0 {
107@@ -429,7 +450,7 @@ func (s *Session) handleStart(msg Message) (err error) {
108 		}
109 
110 		s.acct = msg.Params[2]
111-		_, _, s.host = FullMask(msg.Params[1])
112+		s.host = ParsePrefix(msg.Params[1]).Host
113 	case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs:
114 		err = s.send("CAP END\r\n")
115 		if err != nil {
116@@ -449,7 +470,7 @@ func (s *Session) handleStart(msg Message) (err error) {
117 				ls = msg.Params[2]
118 			}
119 
120-			for _, c := range TokenizeCaps(ls) {
121+			for _, c := range ParseCaps(ls) {
122 				if c.Enable {
123 					s.availableCaps[c.Name] = c.Value
124 				} else {
125@@ -507,9 +528,11 @@ func (s *Session) handle(msg Message) (err error) {
126 	switch msg.Command {
127 	case rplWelcome:
128 		s.nick = msg.Params[0]
129-		s.nickCf = strings.ToLower(s.nick)
130+		s.nickCf = s.Casemap(s.nick)
131 		s.registered = true
132-		s.users[s.nickCf] = &User{Nick: s.nick}
133+		s.users[s.nickCf] = &User{Name: &Prefix{
134+			Name: s.nick, User: s.user, Host: s.host,
135+		}}
136 		s.evts <- RegisteredEvent{}
137 
138 		if s.host == "" {
139@@ -521,7 +544,7 @@ func (s *Session) handle(msg Message) (err error) {
140 	case rplIsupport:
141 		s.updateFeatures(msg.Params[1 : len(msg.Params)-1])
142 	case rplWhoreply:
143-		if s.nickCf == strings.ToLower(msg.Params[5]) {
144+		if s.nickCf == s.Casemap(msg.Params[5]) {
145 			s.host = msg.Params[3]
146 		}
147 	case "CAP":
148@@ -556,7 +579,7 @@ func (s *Session) handle(msg Message) (err error) {
149 				delete(s.enabledCaps, c)
150 			}
151 		case "NEW":
152-			diff := TokenizeCaps(msg.Params[2])
153+			diff := ParseCaps(msg.Params[2])
154 
155 			for _, c := range diff {
156 				if c.Enable {
157@@ -587,7 +610,7 @@ func (s *Session) handle(msg Message) (err error) {
158 				return
159 			}
160 		case "DEL":
161-			diff := TokenizeCaps(msg.Params[2])
162+			diff := ParseCaps(msg.Params[2])
163 
164 			for i := range diff {
165 				diff[i].Enable = !diff[i].Enable
166@@ -623,9 +646,8 @@ func (s *Session) handle(msg Message) (err error) {
167 			}
168 		}
169 	case "JOIN":
170-		nick, _, _ := FullMask(msg.Prefix)
171-		nickCf := strings.ToLower(nick)
172-		channelCf := strings.ToLower(msg.Params[0])
173+		nickCf := s.Casemap(msg.Prefix.Name)
174+		channelCf := s.Casemap(msg.Params[0])
175 
176 		if nickCf == s.nickCf {
177 			s.channels[channelCf] = Channel{
178@@ -635,21 +657,20 @@ func (s *Session) handle(msg Message) (err error) {
179 			s.evts <- SelfJoinEvent{Channel: msg.Params[0]}
180 		} else if c, ok := s.channels[channelCf]; ok {
181 			if _, ok := s.users[nickCf]; !ok {
182-				s.users[nickCf] = &User{Nick: nick}
183+				s.users[nickCf] = &User{Name: msg.Prefix.Copy()}
184 			}
185 			c.Members[s.users[nickCf]] = ""
186 			t := msg.TimeOrNow()
187 
188 			s.evts <- UserJoinEvent{
189+				User:    msg.Prefix.Copy(),
190 				Channel: c.Name,
191-				Nick:    nick,
192 				Time:    t,
193 			}
194 		}
195 	case "PART":
196-		nick, _, _ := FullMask(msg.Prefix)
197-		nickCf := strings.ToLower(nick)
198-		channelCf := strings.ToLower(msg.Params[0])
199+		nickCf := s.Casemap(msg.Prefix.Name)
200+		channelCf := s.Casemap(msg.Params[0])
201 
202 		if nickCf == s.nickCf {
203 			if c, ok := s.channels[channelCf]; ok {
204@@ -666,15 +687,14 @@ func (s *Session) handle(msg Message) (err error) {
205 				t := msg.TimeOrNow()
206 
207 				s.evts <- UserPartEvent{
208-					Channels: []string{c.Name},
209-					Nick:     nick,
210-					Time:     t,
211+					User:    msg.Prefix.Copy(),
212+					Channel: c.Name,
213+					Time:    t,
214 				}
215 			}
216 		}
217 	case "QUIT":
218-		nick, _, _ := FullMask(msg.Prefix)
219-		nickCf := strings.ToLower(nick)
220+		nickCf := s.Casemap(msg.Prefix.Name)
221 
222 		if u, ok := s.users[nickCf]; ok {
223 			t := msg.TimeOrNow()
224@@ -687,24 +707,24 @@ func (s *Session) handle(msg Message) (err error) {
225 				}
226 			}
227 
228-			s.evts <- UserPartEvent{
229+			s.evts <- UserQuitEvent{
230+				User:     msg.Prefix.Copy(),
231 				Channels: channels,
232-				Nick:     nick,
233 				Time:     t,
234 			}
235 		}
236 	case rplNamreply:
237-		channelCf := strings.ToLower(msg.Params[2])
238+		channelCf := s.Casemap(msg.Params[2])
239 
240 		if c, ok := s.channels[channelCf]; ok {
241 			c.Secret = msg.Params[1] == "@"
242 
243-			for _, name := range TokenizeNames(msg.Params[3], "~&@%+") {
244-				nick := name.Nick
245-				nickCf := strings.ToLower(nick)
246+			// TODO compute CHANTYPES
247+			for _, name := range ParseNameReply(msg.Params[3], "~&@%+") {
248+				nickCf := s.Casemap(name.Name.Name)
249 
250 				if _, ok := s.users[nickCf]; !ok {
251-					s.users[nickCf] = &User{Nick: nick}
252+					s.users[nickCf] = &User{Name: name.Name.Copy()}
253 				}
254 				c.Members[s.users[nickCf]] = name.PowerLevel
255 			}
256@@ -712,40 +732,38 @@ func (s *Session) handle(msg Message) (err error) {
257 			s.channels[channelCf] = c
258 		}
259 	case rplTopic:
260-		channelCf := strings.ToLower(msg.Params[1])
261+		channelCf := s.Casemap(msg.Params[1])
262 		if c, ok := s.channels[channelCf]; ok {
263 			c.Topic = msg.Params[2]
264 			s.channels[channelCf] = c
265 		}
266 	case rplTopicwhotime:
267-		channelCf := strings.ToLower(msg.Params[1])
268+		channelCf := s.Casemap(msg.Params[1])
269 		t, _ := strconv.ParseInt(msg.Params[3], 10, 64)
270 		if c, ok := s.channels[channelCf]; ok {
271-			c.TopicWho, _, _ = FullMask(msg.Params[2])
272+			c.TopicWho = ParsePrefix(msg.Params[2])
273 			c.TopicTime = time.Unix(t, 0)
274 			s.channels[channelCf] = c
275 		}
276 	case rplNotopic:
277-		channelCf := strings.ToLower(msg.Params[1])
278+		channelCf := s.Casemap(msg.Params[1])
279 		if c, ok := s.channels[channelCf]; ok {
280 			c.Topic = ""
281 			s.channels[channelCf] = c
282 		}
283 	case "TOPIC":
284-		nick, _, _ := FullMask(msg.Prefix)
285-		channelCf := strings.ToLower(msg.Params[0])
286+		channelCf := s.Casemap(msg.Params[0])
287 		if c, ok := s.channels[channelCf]; ok {
288 			c.Topic = msg.Params[1]
289-			c.TopicWho = nick
290+			c.TopicWho = msg.Prefix.Copy()
291 			c.TopicTime = msg.TimeOrNow()
292 			s.channels[channelCf] = c
293 		}
294 	case "PRIVMSG", "NOTICE":
295 		s.evts <- s.privmsgToEvent(msg)
296 	case "TAGMSG":
297-		nick, _, _ := FullMask(msg.Prefix)
298-		nickCf := strings.ToLower(nick)
299-		targetCf := strings.ToLower(msg.Params[0])
300+		nickCf := s.Casemap(msg.Prefix.Name)
301+		targetCf := s.Casemap(msg.Params[0])
302 
303 		if nickCf == s.nickCf {
304 			// TAGMSG from self
305@@ -765,23 +783,17 @@ func (s *Session) handle(msg Message) (err error) {
306 			break
307 		}
308 
309-		t := msg.TimeOrNow()
310-		if targetCf == s.nickCf {
311-			// TAGMSG to self
312-			s.evts <- QueryTagEvent{
313-				Nick:   nick,
314-				Typing: typing,
315-				Time:   t,
316-			}
317-		} else if c, ok := s.channels[targetCf]; ok {
318-			// TAGMSG to channelCf
319-			s.evts <- ChannelTagEvent{
320-				Nick:    nick,
321-				Channel: c.Name,
322-				Typing:  typing,
323-				Time:    t,
324-			}
325+		ev := TagEvent{
326+			User:   msg.Prefix.Copy(), // TODO correctly casemap
327+			Target: msg.Params[0],     // TODO correctly casemap
328+			Typing: typing,
329+			Time:   msg.TimeOrNow(),
330+		}
331+		if c, ok := s.channels[targetCf]; ok {
332+			ev.Target = c.Name
333+			ev.TargetIsChannel = true
334 		}
335+		s.evts <- ev
336 	case "BATCH":
337 		batchStart := msg.Params[0][0] == '+'
338 		id := msg.Params[0][1:]
339@@ -793,35 +805,38 @@ func (s *Session) handle(msg Message) (err error) {
340 			delete(s.chBatches, id)
341 		}
342 	case "NICK":
343-		nick, _, _ := FullMask(msg.Prefix)
344-		nickCf := strings.ToLower(nick)
345+		nickCf := s.Casemap(msg.Prefix.Name)
346 		newNick := msg.Params[0]
347-		newNickCf := strings.ToLower(newNick)
348+		newNickCf := s.Casemap(newNick)
349 		t := msg.TimeOrNow()
350 
351+		var u *Prefix
352 		if formerUser, ok := s.users[nickCf]; ok {
353-			formerUser.Nick = newNick
354+			formerUser.Name.Name = newNick
355 			delete(s.users, nickCf)
356 			s.users[newNickCf] = formerUser
357+			u = formerUser.Name.Copy()
358 		}
359 
360 		if nickCf == s.nickCf {
361 			s.evts <- SelfNickEvent{
362 				FormerNick: s.nick,
363-				NewNick:    newNick,
364 				Time:       t,
365 			}
366 			s.nick = newNick
367 			s.nickCf = newNickCf
368 		} else {
369 			s.evts <- UserNickEvent{
370-				FormerNick: nick,
371-				NewNick:    newNick,
372+				User:       u,
373+				FormerNick: msg.Prefix.Name,
374 				Time:       t,
375 			}
376 		}
377 	case "FAIL":
378-		fmt.Println("FAIL", msg.Params)
379+		s.evts <- RawMessageEvent{
380+			Message: msg.String(),
381+			IsValid: true,
382+		}
383 	case "PING":
384 		err = s.send("PONG :%s\r\n", msg.Params[0])
385 		if err != nil {
386@@ -839,29 +854,19 @@ func (s *Session) handle(msg Message) (err error) {
387 	return
388 }
389 
390-func (s *Session) privmsgToEvent(msg Message) (ev Event) {
391-	nick, _, _ := FullMask(msg.Prefix)
392-	targetCf := strings.ToLower(msg.Params[0])
393-	t := msg.TimeOrNow()
394-
395-	if !s.IsChannel(targetCf) {
396-		// PRIVMSG to self
397-		ev = QueryMessageEvent{
398-			Nick:    nick,
399-			Target:  msg.Params[0],
400-			Command: msg.Command,
401-			Content: msg.Params[1],
402-			Time:    t,
403-		}
404-	} else if c, ok := s.channels[targetCf]; ok {
405-		// PRIVMSG to channel
406-		ev = ChannelMessageEvent{
407-			Nick:    nick,
408-			Channel: c.Name,
409-			Command: msg.Command,
410-			Content: msg.Params[1],
411-			Time:    t,
412-		}
413+func (s *Session) privmsgToEvent(msg Message) (ev MessageEvent) {
414+	targetCf := s.Casemap(msg.Params[0])
415+
416+	ev = MessageEvent{
417+		User:    msg.Prefix.Copy(), // TODO correctly casemap
418+		Target:  msg.Params[0],     // TODO correctly casemap
419+		Command: msg.Command,
420+		Content: msg.Params[1],
421+		Time:    msg.TimeOrNow(),
422+	}
423+	if c, ok := s.channels[targetCf]; ok {
424+		ev.Target = c.Name
425+		ev.TargetIsChannel = true
426 	}
427 
428 	return
429@@ -873,7 +878,7 @@ func (s *Session) cleanUser(parted *User) {
430 			return
431 		}
432 	}
433-	delete(s.users, strings.ToLower(parted.Nick))
434+	delete(s.users, s.Casemap(parted.Name.Name))
435 }
436 
437 func (s *Session) updateFeatures(features []string) {
+84, -43
  1@@ -8,6 +8,18 @@ import (
  2 	"time"
  3 )
  4 
  5+func CasemapASCII(name string) string {
  6+	var sb strings.Builder
  7+	sb.Grow(len(name))
  8+	for _, r := range name {
  9+		if 'A' <= r && r <= 'Z' {
 10+			r += 'a' - 'A'
 11+		}
 12+		sb.WriteRune(r)
 13+	}
 14+	return sb.String()
 15+}
 16+
 17 func word(s string) (w, rest string) {
 18 	split := strings.SplitN(s, " ", 2)
 19 
 20@@ -125,14 +137,67 @@ var (
 21 	errUnknownCommand  = errors.New("unknown command")
 22 )
 23 
 24+type Prefix struct {
 25+	Name string
 26+	User string
 27+	Host string
 28+}
 29+
 30+func ParsePrefix(s string) (p *Prefix) {
 31+	if s == "" {
 32+		return
 33+	}
 34+
 35+	p = &Prefix{}
 36+
 37+	spl0 := strings.Split(s, "@")
 38+	if 1 < len(spl0) {
 39+		p.Host = spl0[1]
 40+	}
 41+
 42+	spl1 := strings.Split(spl0[0], "!")
 43+	if 1 < len(spl1) {
 44+		p.User = spl1[1]
 45+	}
 46+
 47+	p.Name = spl1[0]
 48+
 49+	return
 50+}
 51+
 52+func (p *Prefix) Copy() *Prefix {
 53+	if p == nil {
 54+		return nil
 55+	}
 56+	res := &Prefix{}
 57+	*res = *p
 58+	return res
 59+}
 60+
 61+func (p *Prefix) String() string {
 62+	if p == nil {
 63+		return ""
 64+	}
 65+
 66+	if p.User != "" && p.Host != "" {
 67+		return p.Name + "!" + p.User + "@" + p.Host
 68+	} else if p.User != "" {
 69+		return p.Name + "!" + p.User
 70+	} else if p.Host != "" {
 71+		return p.Name + "@" + p.Host
 72+	} else {
 73+		return p.Name
 74+	}
 75+}
 76+
 77 type Message struct {
 78 	Tags    map[string]string
 79-	Prefix  string
 80+	Prefix  *Prefix
 81 	Command string
 82 	Params  []string
 83 }
 84 
 85-func Tokenize(line string) (msg Message, err error) {
 86+func ParseMessage(line string) (msg Message, err error) {
 87 	line = strings.TrimLeft(line, " ")
 88 	if line == "" {
 89 		err = errEmptyMessage
 90@@ -156,7 +221,7 @@ func Tokenize(line string) (msg Message, err error) {
 91 		var prefix string
 92 
 93 		prefix, line = word(line)
 94-		msg.Prefix = prefix[1:]
 95+		msg.Prefix = ParsePrefix(prefix[1:])
 96 	}
 97 
 98 	line = strings.TrimLeft(line, " ")
 99@@ -211,9 +276,9 @@ func (msg *Message) String() string {
100 		sb.WriteRune(' ')
101 	}
102 
103-	if msg.Prefix != "" {
104+	if msg.Prefix != nil {
105 		sb.WriteRune(':')
106-		sb.WriteString(msg.Prefix)
107+		sb.WriteString(msg.Prefix.String())
108 		sb.WriteRune(' ')
109 	}
110 
111@@ -245,11 +310,11 @@ func (msg *Message) IsValid() bool {
112 	case rplWhoreply:
113 		return 8 <= len(msg.Params)
114 	case "JOIN", "NICK", "PART", "TAGMSG":
115-		return 1 <= len(msg.Params) && msg.Prefix != ""
116+		return 1 <= len(msg.Params) && msg.Prefix != nil
117 	case "PRIVMSG", "NOTICE", "TOPIC":
118-		return 2 <= len(msg.Params) && msg.Prefix != ""
119+		return 2 <= len(msg.Params) && msg.Prefix != nil
120 	case "QUIT":
121-		return msg.Prefix != ""
122+		return msg.Prefix != nil
123 	case "CAP":
124 		return 3 <= len(msg.Params) &&
125 			(msg.Params[1] == "LS" ||
126@@ -317,33 +382,13 @@ func (msg *Message) TimeOrNow() time.Time {
127 	return time.Now()
128 }
129 
130-func FullMask(s string) (nick, user, host string) {
131-	if s == "" {
132-		return
133-	}
134-
135-	spl0 := strings.Split(s, "@")
136-	if 1 < len(spl0) {
137-		host = spl0[1]
138-	}
139-
140-	spl1 := strings.Split(spl0[0], "!")
141-	if 1 < len(spl1) {
142-		user = spl1[1]
143-	}
144-
145-	nick = spl1[0]
146-
147-	return
148-}
149-
150 type Cap struct {
151 	Name   string
152 	Value  string
153 	Enable bool
154 }
155 
156-func TokenizeCaps(caps string) (diff []Cap) {
157+func ParseCaps(caps string) (diff []Cap) {
158 	for _, c := range strings.Split(caps, " ") {
159 		if c == "" || c == "-" || c == "=" || c == "-=" {
160 			continue
161@@ -370,26 +415,22 @@ func TokenizeCaps(caps string) (diff []Cap) {
162 	return
163 }
164 
165-type Name struct {
166+type Member struct {
167 	PowerLevel string
168-	Nick       string
169-	User       string
170-	Host       string
171+	Name       *Prefix
172 }
173 
174-func TokenizeNames(trailing string, prefixes string) (names []Name) {
175-	for _, name := range strings.Split(trailing, " ") {
176-		if name == "" {
177+func ParseNameReply(trailing string, prefixes string) (names []Member) {
178+	for _, word := range strings.Split(trailing, " ") {
179+		if word == "" {
180 			continue
181 		}
182 
183-		var item Name
184-
185-		mask := strings.TrimLeft(name, prefixes)
186-		item.Nick, item.User, item.Host = FullMask(mask)
187-		item.PowerLevel = name[:len(name)-len(mask)]
188-
189-		names = append(names, item)
190+		name := strings.TrimLeft(word, prefixes)
191+		names = append(names, Member{
192+			PowerLevel: word[:len(word)-len(name)],
193+			Name:       ParsePrefix(name),
194+		})
195 	}
196 
197 	return
+57, -96
  1@@ -2,7 +2,6 @@ package ui
  2 
  3 import (
  4 	"fmt"
  5-	"hash/fnv"
  6 	"math"
  7 	"strings"
  8 	"time"
  9@@ -31,55 +30,28 @@ type point struct {
 10 }
 11 
 12 type Line struct {
 13-	at   time.Time
 14-	head string
 15-	body string
 16-
 17-	isStatus    bool
 18-	isHighlight bool
 19+	At        time.Time
 20+	Head      string
 21+	Body      string
 22+	HeadColor int
 23+	Highlight bool
 24+	Mergeable bool
 25 
 26 	splitPoints []point
 27 	width       int
 28 	newLines    []int
 29 }
 30 
 31-func NewLine(at time.Time, head string, body string, isStatus bool, isHighlight bool) Line {
 32-	l := Line{
 33-		at:          at,
 34-		head:        head,
 35-		body:        body,
 36-		isStatus:    isStatus,
 37-		isHighlight: isHighlight,
 38-		splitPoints: []point{},
 39-		newLines:    []int{},
 40-	}
 41-	l.computeSplitPoints()
 42-	return l
 43-}
 44-
 45-func NewLineNow(head, body string) Line {
 46-	return NewLine(time.Now(), head, body, false, false)
 47-}
 48-
 49-func LineFromIRCMessage(at time.Time, nick string, content string, isNotice bool, isHighlight bool) Line {
 50-	if strings.HasPrefix(content, "\x01ACTION") {
 51-		c := ircColorCode(identColor(nick))
 52-		content = fmt.Sprintf("%s%s\x0F%s", c, nick, content[7:])
 53-		nick = "*"
 54-	} else if isNotice {
 55-		c := ircColorCode(identColor(nick))
 56-		content = fmt.Sprintf("(%s%s\x0F: %s)", c, nick, content)
 57-		nick = "*"
 58+func (l *Line) computeSplitPoints() {
 59+	if l.splitPoints == nil {
 60+		l.splitPoints = []point{}
 61 	}
 62-	return NewLine(at, nick, content, false, isHighlight)
 63-}
 64 
 65-func (l *Line) computeSplitPoints() {
 66 	var wb widthBuffer
 67 	lastWasSplit := false
 68 	l.splitPoints = l.splitPoints[:0]
 69 
 70-	for i, r := range l.body {
 71+	for i, r := range l.Body {
 72 		curIsSplit := IsSplitRune(r)
 73 
 74 		if i == 0 || lastWasSplit != curIsSplit {
 75@@ -97,7 +69,7 @@ func (l *Line) computeSplitPoints() {
 76 	if !lastWasSplit {
 77 		l.splitPoints = append(l.splitPoints, point{
 78 			X:     wb.Width(),
 79-			I:     len(l.body),
 80+			I:     len(l.Body),
 81 			Split: true,
 82 		})
 83 	}
 84@@ -113,6 +85,9 @@ func (l *Line) NewLines(width int) []int {
 85 	if l.width == width {
 86 		return l.newLines
 87 	}
 88+	if l.newLines == nil {
 89+		l.newLines = []int{}
 90+	}
 91 	l.newLines = l.newLines[:0]
 92 	l.width = width
 93 
 94@@ -158,7 +133,7 @@ func (l *Line) NewLines(width int) []int {
 95 			// the word.
 96 			var wb widthBuffer
 97 			h := 1
 98-			for j, r := range l.body[sp1.I:sp2.I] {
 99+			for j, r := range l.Body[sp1.I:sp2.I] {
100 				wb.WriteRune(r)
101 				if h*width < x+wb.Width() {
102 					l.newLines = append(l.newLines, sp1.I+j)
103@@ -185,7 +160,7 @@ func (l *Line) NewLines(width int) []int {
104 		}
105 	}
106 
107-	if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.body) {
108+	if 0 < len(l.newLines) && l.newLines[len(l.newLines)-1] == len(l.Body) {
109 		// DROP any newline that is placed at the end of the string because we
110 		// don't care about those.
111 		l.newLines = l.newLines[:len(l.newLines)-1]
112@@ -229,20 +204,19 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int)
113 			continue
114 		}
115 
116-		if i == 0 || b.lines[i-1].at.Truncate(time.Minute) != line.at.Truncate(time.Minute) {
117-			printTime(screen, 0, y0, st.Bold(true), line.at.Local())
118+		if i == 0 || b.lines[i-1].At.Truncate(time.Minute) != line.At.Truncate(time.Minute) {
119+			printTime(screen, 0, y0, st.Bold(true), line.At.Local())
120 		}
121 
122-		head := truncate(line.head, nickColWidth, "\u2026")
123+		head := truncate(line.Head, nickColWidth, "\u2026")
124 		x := 7 + nickColWidth - StringWidth(head)
125-		c := identColor(line.head)
126-		st = st.Foreground(colorFromCode(c))
127-		if line.isHighlight {
128+		st = st.Foreground(colorFromCode(line.HeadColor))
129+		if line.Highlight {
130 			st = st.Reverse(true)
131 		}
132 		screen.SetContent(x-1, y0, ' ', nil, st)
133 		screen.SetContent(7+nickColWidth, y0, ' ', nil, st)
134-		printString(screen, &x, y0, st.Foreground(colorFromCode(c)), head)
135+		printString(screen, &x, y0, st, head)
136 		st = st.Reverse(false).Foreground(tcell.ColorDefault)
137 
138 		x = x0
139@@ -250,7 +224,7 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int)
140 
141 		var sb StyleBuffer
142 		sb.Reset()
143-		for i, r := range line.body {
144+		for i, r := range line.Body {
145 			if 0 < len(nls) && i == nls[0] {
146 				x = x0
147 				y++
148@@ -280,7 +254,7 @@ func (b *buffer) DrawLines(screen tcell.Screen, width, height, nickColWidth int)
149 	b.isAtTop = 0 <= y0
150 }
151 
152-type bufferList struct {
153+type BufferList struct {
154 	list    []buffer
155 	current int
156 
157@@ -289,8 +263,8 @@ type bufferList struct {
158 	nickColWidth int
159 }
160 
161-func newBufferList(width, height, nickColWidth int) bufferList {
162-	return bufferList{
163+func NewBufferList(width, height, nickColWidth int) BufferList {
164+	return BufferList{
165 		list:         []buffer{},
166 		width:        width,
167 		height:       height,
168@@ -298,24 +272,24 @@ func newBufferList(width, height, nickColWidth int) bufferList {
169 	}
170 }
171 
172-func (bs *bufferList) Resize(width, height int) {
173+func (bs *BufferList) Resize(width, height int) {
174 	bs.width = width
175 	bs.height = height
176 }
177 
178-func (bs *bufferList) Next() {
179+func (bs *BufferList) Next() {
180 	bs.current = (bs.current + 1) % len(bs.list)
181 	bs.list[bs.current].highlights = 0
182 	bs.list[bs.current].unread = false
183 }
184 
185-func (bs *bufferList) Previous() {
186+func (bs *BufferList) Previous() {
187 	bs.current = (bs.current - 1 + len(bs.list)) % len(bs.list)
188 	bs.list[bs.current].highlights = 0
189 	bs.list[bs.current].unread = false
190 }
191 
192-func (bs *bufferList) Add(title string) (ok bool) {
193+func (bs *BufferList) Add(title string) (ok bool) {
194 	lTitle := strings.ToLower(title)
195 	for _, b := range bs.list {
196 		if strings.ToLower(b.title) == lTitle {
197@@ -328,7 +302,7 @@ func (bs *bufferList) Add(title string) (ok bool) {
198 	return
199 }
200 
201-func (bs *bufferList) Remove(title string) (ok bool) {
202+func (bs *BufferList) Remove(title string) (ok bool) {
203 	lTitle := strings.ToLower(title)
204 	for i, b := range bs.list {
205 		if strings.ToLower(b.title) == lTitle {
206@@ -343,7 +317,7 @@ func (bs *bufferList) Remove(title string) (ok bool) {
207 	return
208 }
209 
210-func (bs *bufferList) AddLine(title string, line Line) {
211+func (bs *BufferList) AddLine(title string, highlight bool, line Line) {
212 	idx := bs.idx(title)
213 	if idx < 0 {
214 		return
215@@ -351,29 +325,30 @@ func (bs *bufferList) AddLine(title string, line Line) {
216 
217 	b := &bs.list[idx]
218 	n := len(b.lines)
219-	line.body = strings.TrimRight(line.body, "\t ")
220+	line.Body = strings.TrimRight(line.Body, "\t ")
221 
222-	if line.isStatus && n != 0 && b.lines[n-1].isStatus {
223+	if line.Mergeable && n != 0 && b.lines[n-1].Mergeable {
224 		l := &b.lines[n-1]
225-		l.body += " " + line.body
226+		l.Body += "  " + line.Body
227 		l.computeSplitPoints()
228 		l.width = 0
229 	} else {
230+		line.computeSplitPoints()
231 		b.lines = append(b.lines, line)
232 		if idx == bs.current && 0 < b.scrollAmt {
233 			b.scrollAmt += len(line.NewLines(bs.width-9-bs.nickColWidth)) + 1
234 		}
235 	}
236 
237-	if !line.isStatus && idx != bs.current {
238+	if !line.Mergeable && idx != bs.current {
239 		b.unread = true
240 	}
241-	if line.isHighlight && idx != bs.current {
242+	if highlight && idx != bs.current {
243 		b.highlights++
244 	}
245 }
246 
247-func (bs *bufferList) AddLines(title string, lines []Line) {
248+func (bs *BufferList) AddLines(title string, lines []Line) {
249 	idx := bs.idx(title)
250 	if idx < 0 {
251 		return
252@@ -383,19 +358,22 @@ func (bs *bufferList) AddLines(title string, lines []Line) {
253 	limit := len(lines)
254 
255 	if 0 < len(b.lines) {
256-		firstLineTime := b.lines[0].at.Unix()
257+		firstLineTime := b.lines[0].At.Unix()
258 		for i, l := range lines {
259-			if firstLineTime < l.at.Unix() {
260+			if firstLineTime < l.At.Unix() {
261 				limit = i
262 				break
263 			}
264 		}
265 	}
266+	for i := 0; i < limit; i++ {
267+		lines[i].computeSplitPoints()
268+	}
269 
270 	b.lines = append(lines[:limit], b.lines...)
271 }
272 
273-func (bs *bufferList) TypingStart(title, nick string) {
274+func (bs *BufferList) TypingStart(title, nick string) {
275 	idx := bs.idx(title)
276 	if idx < 0 {
277 		return
278@@ -411,7 +389,7 @@ func (bs *bufferList) TypingStart(title, nick string) {
279 	b.typings = append(b.typings, nick)
280 }
281 
282-func (bs *bufferList) TypingStop(title, nick string) {
283+func (bs *BufferList) TypingStop(title, nick string) {
284 	idx := bs.idx(title)
285 	if idx < 0 {
286 		return
287@@ -427,19 +405,19 @@ func (bs *bufferList) TypingStop(title, nick string) {
288 	}
289 }
290 
291-func (bs *bufferList) Current() (title string) {
292+func (bs *BufferList) Current() (title string) {
293 	return bs.list[bs.current].title
294 }
295 
296-func (bs *bufferList) CurrentOldestTime() (t *time.Time) {
297+func (bs *BufferList) CurrentOldestTime() (t *time.Time) {
298 	ls := bs.list[bs.current].lines
299 	if 0 < len(ls) {
300-		t = &ls[0].at
301+		t = &ls[0].At
302 	}
303 	return
304 }
305 
306-func (bs *bufferList) ScrollUp() {
307+func (bs *BufferList) ScrollUp() {
308 	b := &bs.list[bs.current]
309 	if b.isAtTop {
310 		return
311@@ -447,7 +425,7 @@ func (bs *bufferList) ScrollUp() {
312 	b.scrollAmt += bs.height / 2
313 }
314 
315-func (bs *bufferList) ScrollDown() {
316+func (bs *BufferList) ScrollDown() {
317 	b := &bs.list[bs.current]
318 	b.scrollAmt -= bs.height / 2
319 
320@@ -456,12 +434,12 @@ func (bs *bufferList) ScrollDown() {
321 	}
322 }
323 
324-func (bs *bufferList) IsAtTop() bool {
325+func (bs *BufferList) IsAtTop() bool {
326 	b := &bs.list[bs.current]
327 	return b.isAtTop
328 }
329 
330-func (bs *bufferList) idx(title string) int {
331+func (bs *BufferList) idx(title string) int {
332 	if title == "" {
333 		return bs.current
334 	}
335@@ -475,13 +453,13 @@ func (bs *bufferList) idx(title string) int {
336 	return -1
337 }
338 
339-func (bs *bufferList) Draw(screen tcell.Screen) {
340+func (bs *BufferList) Draw(screen tcell.Screen) {
341 	bs.list[bs.current].DrawLines(screen, bs.width, bs.height-3, bs.nickColWidth)
342 	bs.drawStatusBar(screen, bs.height-3)
343 	bs.drawTitleList(screen, bs.height-1)
344 }
345 
346-func (bs *bufferList) drawStatusBar(screen tcell.Screen, y int) {
347+func (bs *BufferList) drawStatusBar(screen tcell.Screen, y int) {
348 	st := tcell.StyleDefault.Dim(true)
349 	nicks := bs.list[bs.current].typings
350 	verb := " is typing..."
351@@ -517,7 +495,7 @@ func (bs *bufferList) drawStatusBar(screen tcell.Screen, y int) {
352 	}
353 }
354 
355-func (bs *bufferList) drawTitleList(screen tcell.Screen, y int) {
356+func (bs *BufferList) drawTitleList(screen tcell.Screen, y int) {
357 	var widths []int
358 	for _, b := range bs.list {
359 		width := StringWidth(b.title)
360@@ -604,24 +582,7 @@ func printTime(screen tcell.Screen, x int, y int, st tcell.Style, t time.Time) {
361 	screen.SetContent(x+4, y, mn1, nil, st)
362 }
363 
364-// see <https://modern.ircdocs.horse/formatting.html>
365-var identColorBlacklist = []int{1, 8, 16, 27, 28, 88, 89, 90, 91}
366-
367-func identColor(s string) (code int) {
368-	h := fnv.New32()
369-	_, _ = h.Write([]byte(s))
370-
371-	code = int(h.Sum32()) % (99 - len(identColorBlacklist))
372-	for _, c := range identColorBlacklist {
373-		if c <= code {
374-			code++
375-		}
376-	}
377-
378-	return
379-}
380-
381-func ircColorCode(code int) string {
382+func IrcColorCode(code int) string {
383 	var c [3]rune
384 	c[0] = 0x03
385 	c[1] = rune(code/10) + '0'
+23, -23
  1@@ -9,8 +9,8 @@ type Completion struct {
  2 	CursorIdx int
  3 }
  4 
  5-// editor is the text field where the user writes messages and commands.
  6-type editor struct {
  7+// Editor is the text field where the user writes messages and commands.
  8+type Editor struct {
  9 	// text contains the written runes. An empty slice means no text is written.
 10 	text [][]rune
 11 
 12@@ -38,8 +38,8 @@ type editor struct {
 13 	completeIdx  int
 14 }
 15 
 16-func newEditor(width int, autoComplete func(cursorIdx int, text []rune) []Completion) editor {
 17-	return editor{
 18+func NewEditor(width int, autoComplete func(cursorIdx int, text []rune) []Completion) Editor {
 19+	return Editor{
 20 		text:         [][]rune{{}},
 21 		textWidth:    []int{0},
 22 		width:        width,
 23@@ -47,7 +47,7 @@ func newEditor(width int, autoComplete func(cursorIdx int, text []rune) []Comple
 24 	}
 25 }
 26 
 27-func (e *editor) Resize(width int) {
 28+func (e *Editor) Resize(width int) {
 29 	if width < e.width {
 30 		e.cursorIdx = 0
 31 		e.offsetIdx = 0
 32@@ -56,21 +56,21 @@ func (e *editor) Resize(width int) {
 33 	e.width = width
 34 }
 35 
 36-func (e *editor) IsCommand() bool {
 37+func (e *Editor) IsCommand() bool {
 38 	return len(e.text[e.lineIdx]) != 0 && e.text[e.lineIdx][0] == '/'
 39 }
 40 
 41-func (e *editor) TextLen() int {
 42+func (e *Editor) TextLen() int {
 43 	return len(e.text[e.lineIdx])
 44 }
 45 
 46-func (e *editor) PutRune(r rune) {
 47+func (e *Editor) PutRune(r rune) {
 48 	e.putRune(r)
 49 	e.Right()
 50 	e.autoCache = nil
 51 }
 52 
 53-func (e *editor) putRune(r rune) {
 54+func (e *Editor) putRune(r rune) {
 55 	e.text[e.lineIdx] = append(e.text[e.lineIdx], ' ')
 56 	copy(e.text[e.lineIdx][e.cursorIdx+1:], e.text[e.lineIdx][e.cursorIdx:])
 57 	e.text[e.lineIdx][e.cursorIdx] = r
 58@@ -83,7 +83,7 @@ func (e *editor) putRune(r rune) {
 59 	}
 60 }
 61 
 62-func (e *editor) RemRune() (ok bool) {
 63+func (e *Editor) RemRune() (ok bool) {
 64 	ok = 0 < e.cursorIdx
 65 	if !ok {
 66 		return
 67@@ -94,7 +94,7 @@ func (e *editor) RemRune() (ok bool) {
 68 	return
 69 }
 70 
 71-func (e *editor) RemRuneForward() (ok bool) {
 72+func (e *Editor) RemRuneForward() (ok bool) {
 73 	ok = e.cursorIdx < len(e.text[e.lineIdx])
 74 	if !ok {
 75 		return
 76@@ -104,7 +104,7 @@ func (e *editor) RemRuneForward() (ok bool) {
 77 	return
 78 }
 79 
 80-func (e *editor) remRuneAt(idx int) {
 81+func (e *Editor) remRuneAt(idx int) {
 82 	// TODO avoid looping twice
 83 	rw := e.textWidth[idx+1] - e.textWidth[idx]
 84 	for i := idx + 1; i < len(e.textWidth); i++ {
 85@@ -117,7 +117,7 @@ func (e *editor) remRuneAt(idx int) {
 86 	e.text[e.lineIdx] = e.text[e.lineIdx][:len(e.text[e.lineIdx])-1]
 87 }
 88 
 89-func (e *editor) Flush() (content string) {
 90+func (e *Editor) Flush() (content string) {
 91 	content = string(e.text[e.lineIdx])
 92 	if len(e.text[len(e.text)-1]) == 0 {
 93 		e.lineIdx = len(e.text) - 1
 94@@ -132,12 +132,12 @@ func (e *editor) Flush() (content string) {
 95 	return
 96 }
 97 
 98-func (e *editor) Right() {
 99+func (e *Editor) Right() {
100 	e.right()
101 	e.autoCache = nil
102 }
103 
104-func (e *editor) right() {
105+func (e *Editor) right() {
106 	if e.cursorIdx == len(e.text[e.lineIdx]) {
107 		return
108 	}
109@@ -151,7 +151,7 @@ func (e *editor) right() {
110 	}
111 }
112 
113-func (e *editor) Left() {
114+func (e *Editor) Left() {
115 	if e.cursorIdx == 0 {
116 		return
117 	}
118@@ -165,7 +165,7 @@ func (e *editor) Left() {
119 	e.autoCache = nil
120 }
121 
122-func (e *editor) Home() {
123+func (e *Editor) Home() {
124 	if e.cursorIdx == 0 {
125 		return
126 	}
127@@ -174,7 +174,7 @@ func (e *editor) Home() {
128 	e.autoCache = nil
129 }
130 
131-func (e *editor) End() {
132+func (e *Editor) End() {
133 	if e.cursorIdx == len(e.text[e.lineIdx]) {
134 		return
135 	}
136@@ -185,7 +185,7 @@ func (e *editor) End() {
137 	e.autoCache = nil
138 }
139 
140-func (e *editor) Up() {
141+func (e *Editor) Up() {
142 	if e.lineIdx == 0 {
143 		return
144 	}
145@@ -197,7 +197,7 @@ func (e *editor) Up() {
146 	e.End()
147 }
148 
149-func (e *editor) Down() {
150+func (e *Editor) Down() {
151 	if e.lineIdx == len(e.text)-1 {
152 		if len(e.text[e.lineIdx]) == 0 {
153 			return
154@@ -213,7 +213,7 @@ func (e *editor) Down() {
155 	e.End()
156 }
157 
158-func (e *editor) AutoComplete() (ok bool) {
159+func (e *Editor) AutoComplete() (ok bool) {
160 	if e.autoCache == nil {
161 		e.autoCache = e.autoComplete(e.cursorIdx, e.text[e.lineIdx])
162 		if e.autoCache == nil {
163@@ -234,7 +234,7 @@ func (e *editor) AutoComplete() (ok bool) {
164 	return true
165 }
166 
167-func (e *editor) computeTextWidth() {
168+func (e *Editor) computeTextWidth() {
169 	e.textWidth = e.textWidth[:1]
170 	rw := 0
171 	for _, r := range e.text[e.lineIdx] {
172@@ -243,7 +243,7 @@ func (e *editor) computeTextWidth() {
173 	}
174 }
175 
176-func (e *editor) Draw(screen tcell.Screen, y int) {
177+func (e *Editor) Draw(screen tcell.Screen, y int) {
178 	st := tcell.StyleDefault
179 
180 	x := 0
+8, -0
 1@@ -169,6 +169,14 @@ func (cb *colorBuffer) WriteRune(r rune) (ok int) {
 2 	return
 3 }
 4 
 5+const (
 6+	ColorWhite = iota
 7+	ColorBlack
 8+	ColorBlue
 9+	ColorGreen
10+	ColorRed
11+)
12+
13 var ansiCodes = []tcell.Color{
14 	// Taken from <https://modern.ircdocs.horse/formatting.html>
15 	tcell.ColorWhite, tcell.ColorBlack, tcell.ColorBlue, tcell.ColorGreen,
+19, -50
  1@@ -19,8 +19,8 @@ type UI struct {
  2 	exit   atomic.Value // bool
  3 	config Config
  4 
  5-	bs bufferList
  6-	e  editor
  7+	bs BufferList
  8+	e  Editor
  9 }
 10 
 11 func New(config Config) (ui *UI, err error) {
 12@@ -52,11 +52,15 @@ func New(config Config) (ui *UI, err error) {
 13 	ui.exit.Store(false)
 14 
 15 	hmIdx := rand.Intn(len(homeMessages))
 16-	ui.bs = newBufferList(w, h, ui.config.NickColWidth)
 17+	ui.bs = NewBufferList(w, h, ui.config.NickColWidth)
 18 	ui.bs.Add(Home)
 19-	ui.bs.AddLine("", NewLineNow("--", homeMessages[hmIdx]))
 20+	ui.bs.AddLine("", false, Line{
 21+		At:   time.Now(),
 22+		Head: "--",
 23+		Body: homeMessages[hmIdx],
 24+	})
 25 
 26-	ui.e = newEditor(w, ui.config.AutoComplete)
 27+	ui.e = NewEditor(w, ui.config.AutoComplete)
 28 
 29 	ui.Resize()
 30 
 31@@ -85,22 +89,18 @@ func (ui *UI) CurrentBufferOldestTime() (t *time.Time) {
 32 
 33 func (ui *UI) NextBuffer() {
 34 	ui.bs.Next()
 35-	ui.draw()
 36 }
 37 
 38 func (ui *UI) PreviousBuffer() {
 39 	ui.bs.Previous()
 40-	ui.draw()
 41 }
 42 
 43 func (ui *UI) ScrollUp() {
 44 	ui.bs.ScrollUp()
 45-	ui.draw()
 46 }
 47 
 48 func (ui *UI) ScrollDown() {
 49 	ui.bs.ScrollDown()
 50-	ui.draw()
 51 }
 52 
 53 func (ui *UI) IsAtTop() bool {
 54@@ -108,37 +108,27 @@ func (ui *UI) IsAtTop() bool {
 55 }
 56 
 57 func (ui *UI) AddBuffer(title string) {
 58-	ok := ui.bs.Add(title)
 59-	if ok {
 60-		ui.draw()
 61-	}
 62+	_ = ui.bs.Add(title)
 63 }
 64 
 65 func (ui *UI) RemoveBuffer(title string) {
 66-	ok := ui.bs.Remove(title)
 67-	if ok {
 68-		ui.draw()
 69-	}
 70+	_ = ui.bs.Remove(title)
 71 }
 72 
 73-func (ui *UI) AddLine(buffer string, line Line) {
 74-	ui.bs.AddLine(buffer, line)
 75-	ui.draw()
 76+func (ui *UI) AddLine(buffer string, highlight bool, line Line) {
 77+	ui.bs.AddLine(buffer, highlight, line)
 78 }
 79 
 80 func (ui *UI) AddLines(buffer string, lines []Line) {
 81 	ui.bs.AddLines(buffer, lines)
 82-	ui.draw()
 83 }
 84 
 85 func (ui *UI) TypingStart(buffer, nick string) {
 86 	ui.bs.TypingStart(buffer, nick)
 87-	ui.draw()
 88 }
 89 
 90 func (ui *UI) TypingStop(buffer, nick string) {
 91 	ui.bs.TypingStop(buffer, nick)
 92-	ui.draw()
 93 }
 94 
 95 func (ui *UI) InputIsCommand() bool {
 96@@ -151,77 +141,56 @@ func (ui *UI) InputLen() int {
 97 
 98 func (ui *UI) InputRune(r rune) {
 99 	ui.e.PutRune(r)
100-	ui.draw()
101 }
102 
103 func (ui *UI) InputRight() {
104 	ui.e.Right()
105-	ui.draw()
106 }
107 
108 func (ui *UI) InputLeft() {
109 	ui.e.Left()
110-	ui.draw()
111 }
112 
113 func (ui *UI) InputHome() {
114 	ui.e.Home()
115-	ui.draw()
116 }
117 
118 func (ui *UI) InputEnd() {
119 	ui.e.End()
120-	ui.draw()
121 }
122 
123 func (ui *UI) InputUp() {
124 	ui.e.Up()
125-	ui.draw()
126 }
127 
128 func (ui *UI) InputDown() {
129 	ui.e.Down()
130-	ui.draw()
131 }
132 
133 func (ui *UI) InputBackspace() (ok bool) {
134-	ok = ui.e.RemRune()
135-	if ok {
136-		ui.draw()
137-	}
138-	return
139+	return ui.e.RemRune()
140 }
141 
142 func (ui *UI) InputDelete() (ok bool) {
143-	ok = ui.e.RemRuneForward()
144-	if ok {
145-		ui.draw()
146-	}
147-	return
148+	return ui.e.RemRuneForward()
149 }
150 
151 func (ui *UI) InputAutoComplete() (ok bool) {
152-	ok = ui.e.AutoComplete()
153-	if ok {
154-		ui.draw()
155-	}
156-	return
157+	return ui.e.AutoComplete()
158 }
159 
160 func (ui *UI) InputEnter() (content string) {
161-	content = ui.e.Flush()
162-	ui.draw()
163-	return
164+	return ui.e.Flush()
165 }
166 
167 func (ui *UI) Resize() {
168 	w, h := ui.screen.Size()
169 	ui.e.Resize(w)
170 	ui.bs.Resize(w, h)
171-	ui.draw()
172+	ui.Draw()
173 }
174 
175-func (ui *UI) draw() {
176+func (ui *UI) Draw() {
177 	_, h := ui.screen.Size()
178 	ui.e.Draw(ui.screen, h-2)
179 	ui.bs.Draw(ui.screen)
+19, -0
 1@@ -0,0 +1,19 @@
 2+package senpai
 3+
 4+import (
 5+	"time"
 6+
 7+	"git.sr.ht/~taiite/senpai/ui"
 8+)
 9+
10+func (app *App) addLineNow(buffer string, line ui.Line) {
11+	if line.At.IsZero() {
12+		line.At = time.Now()
13+	}
14+	app.win.AddLine(buffer, false, line)
15+	app.draw()
16+}
17+
18+func (app *App) draw() {
19+	app.win.Draw()
20+}