~taiite/senpai

9fb4378753ddec61a504a0dec403f40d6def7e90 — Hubert Hirtz 7 months ago 40544a1 bouncer-ext
Support for soju.im/bouncer-networks

This patch also disable the highlight on reconnect. This might be an
issue (the user would want to know when senpai is online again?), but
with multiple connections, it's bothersome to have to unread all of
them on start (it wasn't a problem with only one connection since it was
read instantly).

Now, lastbuffer.txt also contains the network ID, otherwise the user
might end up on another buffer with the same name.

This patch does not extend /r to support multiple networks (it will send
the message to the latest query, whatever the current displayed network
is).
M app.go => app.go +158 -118
@@ 17,13 17,6 @@ import (

const eventChanSize = 64

type source int

const (
	uiEvent source = iota
	ircEvent
)

func isCommand(input []rune) bool {
	// Command can't start with two slashes because that's an escape for
	// a literal slash in the message


@@ 70,30 63,32 @@ func (b *bound) Update(line *ui.Line) {
}

type event struct {
	src     source
	src     string // "*" if UI, netID otherwise
	content interface{}
}

type App struct {
	win     *ui.UI
	s       *irc.Session
	pasting bool
	events  chan event
	win      *ui.UI
	sessions map[string]*irc.Session
	pasting  bool
	events   chan event

	cfg        Config
	highlights []string

	lastQuery     string
	lastQueryNet  string
	messageBounds map[string]bound
	lastNetID     string
	lastBuffer    string
}

func NewApp(cfg Config, lastBuffer string) (app *App, err error) {
func NewApp(cfg Config) (app *App, err error) {
	app = &App{
		cfg:           cfg,
		sessions:      map[string]*irc.Session{},
		events:        make(chan event, eventChanSize),
		cfg:           cfg,
		messageBounds: map[string]bound{},
		lastBuffer:    lastBuffer,
	}

	if cfg.Highlights != nil {


@@ 133,18 128,28 @@ func NewApp(cfg Config, lastBuffer string) (app *App, err error) {

func (app *App) Close() {
	app.win.Close()
	if app.s != nil {
		app.s.Close()
	for _, session := range app.sessions {
		session.Close()
	}
}

func (app *App) SwitchToBuffer(netID, buffer string) {
	app.lastNetID = netID
	app.lastBuffer = buffer
}

func (app *App) Run() {
	go app.uiLoop()
	go app.ircLoop()
	go app.ircLoop("")
	app.eventLoop()
}

func (app *App) CurrentBuffer() string {
func (app *App) CurrentSession() *irc.Session {
	netID, _ := app.win.CurrentBuffer()
	return app.sessions[netID]
}

func (app *App) CurrentBuffer() (netID, buffer string) {
	return app.win.CurrentBuffer()
}



@@ 172,8 177,10 @@ func (app *App) eventLoop() {
			app.updatePrompt()
			app.setBufferNumbers()
			var currentMembers []irc.Member
			if app.s != nil {
				currentMembers = app.s.Names(app.win.CurrentBuffer())
			netID, buffer := app.win.CurrentBuffer()
			s := app.sessions[netID]
			if s != nil && buffer != "" {
				currentMembers = s.Names(buffer)
			}
			app.win.Draw(currentMembers)
		}


@@ 182,7 189,7 @@ func (app *App) eventLoop() {

// ircLoop maintains a connection to the IRC server by connecting and then
// forwarding IRC events to app.events repeatedly.
func (app *App) ircLoop() {
func (app *App) ircLoop(netID string) {
	var auth irc.SASLClient
	if app.cfg.Password != nil {
		auth = &irc.SASLPlain{


@@ 194,45 201,46 @@ func (app *App) ircLoop() {
		Nickname: app.cfg.Nick,
		Username: app.cfg.User,
		RealName: app.cfg.Real,
		NetID:    netID,
		Auth:     auth,
	}
	for !app.win.ShouldExit() {
		conn := app.connect()
		conn := app.connect(netID)
		in, out := irc.ChanInOut(conn)
		if app.cfg.Debug {
			out = app.debugOutputMessages(out)
			out = app.debugOutputMessages(netID, out)
		}
		session := irc.NewSession(out, params)
		app.events <- event{
			src:     ircEvent,
			src:     netID,
			content: session,
		}
		go func() {
			for stop := range session.TypingStops() {
				app.events <- event{
					src:     ircEvent,
					src:     netID,
					content: stop,
				}
			}
		}()
		for msg := range in {
			if app.cfg.Debug {
				app.queueStatusLine(ui.Line{
				app.queueStatusLine(netID, ui.Line{
					At:   time.Now(),
					Head: "IN --",
					Body: ui.PlainString(msg.String()),
				})
			}
			app.events <- event{
				src:     ircEvent,
				src:     netID,
				content: msg,
			}
		}
		app.events <- event{
			src:     ircEvent,
			src:     netID,
			content: nil,
		}
		app.queueStatusLine(ui.Line{
		app.queueStatusLine(netID, ui.Line{
			Head:      "!!",
			HeadColor: tcell.ColorRed,
			Body:      ui.PlainString("Connection lost"),


@@ 241,9 249,9 @@ func (app *App) ircLoop() {
	}
}

func (app *App) connect() net.Conn {
func (app *App) connect(netID string) net.Conn {
	for {
		app.queueStatusLine(ui.Line{
		app.queueStatusLine(netID, ui.Line{
			Head: "--",
			Body: ui.PlainSprintf("Connecting to %s...", app.cfg.Addr),
		})


@@ 251,7 259,7 @@ func (app *App) connect() net.Conn {
		if err == nil {
			return conn
		}
		app.queueStatusLine(ui.Line{
		app.queueStatusLine(netID, ui.Line{
			Head:      "!!",
			HeadColor: tcell.ColorRed,
			Body:      ui.PlainSprintf("Connection failed: %v", err),


@@ 295,11 303,11 @@ func (app *App) tryConnect() (conn net.Conn, err error) {
	return
}

func (app *App) debugOutputMessages(out chan<- irc.Message) chan<- irc.Message {
func (app *App) debugOutputMessages(netID string, out chan<- irc.Message) chan<- irc.Message {
	debugOut := make(chan irc.Message, cap(out))
	go func() {
		for msg := range debugOut {
			app.queueStatusLine(ui.Line{
			app.queueStatusLine(netID, ui.Line{
				At:   time.Now(),
				Head: "OUT --",
				Body: ui.PlainString(msg.String()),


@@ 316,7 324,7 @@ func (app *App) debugOutputMessages(out chan<- irc.Message) chan<- irc.Message {
func (app *App) uiLoop() {
	for ev := range app.win.Events {
		app.events <- event{
			src:     uiEvent,
			src:     "*",
			content: ev,
		}
	}


@@ 325,13 333,10 @@ func (app *App) uiLoop() {
// handleEvents handles a batch of events.
func (app *App) handleEvents(evs []event) {
	for _, ev := range evs {
		switch ev.src {
		case uiEvent:
		if ev.src == "*" {
			app.handleUIEvent(ev.content)
		case ircEvent:
			app.handleIRCEvent(ev.content)
		default:
			panic("unreachable")
		} else {
			app.handleIRCEvent(ev.src, ev.content)
		}
	}
}


@@ 346,10 351,10 @@ func (app *App) handleUIEvent(ev interface{}) {
		app.handleMouseEvent(ev)
	case *tcell.EventKey:
		app.handleKeyEvent(ev)
	case ui.Line:
		app.addStatusLine(ev)
	case statusLine:
		app.addStatusLine(ev.netID, ev.line)
	default:
		return
		panic("unreachable")
	}
}



@@ 472,11 477,11 @@ func (app *App) handleKeyEvent(ev *tcell.EventKey) {
			app.typing()
		}
	case tcell.KeyCR, tcell.KeyLF:
		buffer := app.win.CurrentBuffer()
		netID, buffer := app.win.CurrentBuffer()
		input := app.win.InputEnter()
		err := app.handleInput(buffer, input)
		if err != nil {
			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyUnread, ui.Line{
			app.win.AddLine(netID, buffer, ui.NotifyUnread, ui.Line{
				At:        time.Now(),
				Head:      "!!",
				HeadColor: tcell.ColorRed,


@@ 494,29 499,35 @@ func (app *App) handleKeyEvent(ev *tcell.EventKey) {
// requestHistory is a wrapper around irc.Session.RequestHistory to only request
// history when needed.
func (app *App) requestHistory() {
	if app.s == nil {
	netID, buffer := app.win.CurrentBuffer()
	s := app.sessions[netID]
	if s == nil {
		return
	}
	buffer := app.win.CurrentBuffer()
	if app.win.IsAtTop() && buffer != Home {
	if app.win.IsAtTop() && buffer != "" {
		t := time.Now()
		if bound, ok := app.messageBounds[buffer]; ok {
			t = bound.first
		}
		app.s.NewHistoryRequest(buffer).
		s.NewHistoryRequest(buffer).
			WithLimit(100).
			Before(t)
	}
}

func (app *App) handleIRCEvent(ev interface{}) {
func (app *App) handleIRCEvent(netID string, ev interface{}) {
	if ev == nil {
		app.s.Close()
		app.s = nil
		if s, ok := app.sessions[netID]; ok {
			s.Close()
			delete(app.sessions, netID)
		}
		return
	}
	if s, ok := ev.(*irc.Session); ok {
		app.s = s
		if s, ok := app.sessions[netID]; ok {
			s.Close()
		}
		app.sessions[netID] = s
		return
	}
	if _, ok := ev.(irc.Typing); ok {


@@ 524,12 535,19 @@ func (app *App) handleIRCEvent(ev interface{}) {
		return
	}

	msg := ev.(irc.Message)
	msg, ok := ev.(irc.Message)
	if !ok {
		panic("unreachable")
	}
	s, ok := app.sessions[netID]
	if !ok {
		panic("unreachable")
	}

	// Mutate IRC state
	ev, err := app.s.HandleMessage(msg)
	ev, err := s.HandleMessage(msg)
	if err != nil {
		app.win.AddLine(Home, ui.NotifyUnread, ui.Line{
		app.win.AddLine(netID, "", ui.NotifyUnread, ui.Line{
			Head:      "!!",
			HeadColor: tcell.ColorRed,
			Body:      ui.PlainSprintf("Received corrupt message %q: %s", msg.String(), err),


@@ 543,26 561,26 @@ func (app *App) handleIRCEvent(ev interface{}) {
		for _, channel := range app.cfg.Channels {
			// TODO: group JOIN messages
			// TODO: support autojoining channels with keys
			app.s.Join(channel, "")
			s.Join(channel, "")
		}
		body := "Connected to the server"
		if app.s.Nick() != app.cfg.Nick {
			body = fmt.Sprintf("Connected to the server as %s", app.s.Nick())
		if s.Nick() != app.cfg.Nick {
			body = fmt.Sprintf("Connected to the server as %s", s.Nick())
		}
		app.win.AddLine(Home, ui.NotifyUnread, ui.Line{
		app.win.AddLine(netID, "", ui.NotifyNone, ui.Line{
			At:   msg.TimeOrNow(),
			Head: "--",
			Body: ui.PlainString(body),
		})
	case irc.SelfNickEvent:
		var body ui.StyledStringBuilder
		body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, app.s.Nick()))
		body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, s.Nick()))
		textStyle := tcell.StyleDefault.Foreground(tcell.ColorGray)
		arrowStyle := tcell.StyleDefault
		body.AddStyle(0, textStyle)
		body.AddStyle(len(ev.FormerNick), arrowStyle)
		body.AddStyle(body.Len()-len(app.s.Nick()), textStyle)
		app.addStatusLine(ui.Line{
		body.AddStyle(body.Len()-len(s.Nick()), textStyle)
		app.addStatusLine(netID, ui.Line{
			At:        msg.TimeOrNow(),
			Head:      "--",
			HeadColor: tcell.ColorGray,


@@ 577,8 595,8 @@ func (app *App) handleIRCEvent(ev interface{}) {
		body.AddStyle(0, textStyle)
		body.AddStyle(len(ev.FormerNick), arrowStyle)
		body.AddStyle(body.Len()-len(ev.User), textStyle)
		for _, c := range app.s.ChannelsSharedWith(ev.User) {
			app.win.AddLine(c, ui.NotifyNone, ui.Line{
		for _, c := range s.ChannelsSharedWith(ev.User) {
			app.win.AddLine(netID, c, ui.NotifyNone, ui.Line{
				At:        msg.TimeOrNow(),
				Head:      "--",
				HeadColor: tcell.ColorGray,


@@ 587,14 605,14 @@ func (app *App) handleIRCEvent(ev interface{}) {
			})
		}
	case irc.SelfJoinEvent:
		i, added := app.win.AddBuffer(ev.Channel)
		i, added := app.win.AddBuffer(netID, "", ev.Channel)
		bounds, ok := app.messageBounds[ev.Channel]
		if added || !ok {
			app.s.NewHistoryRequest(ev.Channel).
			s.NewHistoryRequest(ev.Channel).
				WithLimit(200).
				Before(msg.TimeOrNow())
		} else {
			app.s.NewHistoryRequest(ev.Channel).
			s.NewHistoryRequest(ev.Channel).
				WithLimit(200).
				After(bounds.last)
		}


@@ 602,13 620,14 @@ func (app *App) handleIRCEvent(ev interface{}) {
			app.win.JumpBufferIndex(i)
		}
		if ev.Topic != "" {
			app.printTopic(ev.Channel)
			app.printTopic(netID, ev.Channel)
		}

		// Restore last buffer
		lastBuffer := app.lastBuffer
		if ev.Channel == lastBuffer {
			app.win.JumpBuffer(lastBuffer)
		if netID == app.lastNetID && ev.Channel == app.lastBuffer {
			app.win.JumpBufferNetwork(app.lastNetID, app.lastBuffer)
			app.lastNetID = ""
			app.lastBuffer = ""
		}
	case irc.UserJoinEvent:
		var body ui.StyledStringBuilder


@@ 617,7 636,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
		body.WriteByte('+')
		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
		body.WriteString(ev.User)
		app.win.AddLine(ev.Channel, ui.NotifyNone, ui.Line{
		app.win.AddLine(netID, ev.Channel, ui.NotifyNone, ui.Line{
			At:        msg.TimeOrNow(),
			Head:      "--",
			HeadColor: tcell.ColorGray,


@@ 634,7 653,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
		body.WriteByte('-')
		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
		body.WriteString(ev.User)
		app.win.AddLine(ev.Channel, ui.NotifyNone, ui.Line{
		app.win.AddLine(netID, ev.Channel, ui.NotifyNone, ui.Line{
			At:        msg.TimeOrNow(),
			Head:      "--",
			HeadColor: tcell.ColorGray,


@@ 649,7 668,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
		body.WriteString(ev.User)
		for _, c := range ev.Channels {
			app.win.AddLine(c, ui.NotifyNone, ui.Line{
			app.win.AddLine(netID, c, ui.NotifyNone, ui.Line{
				At:        msg.TimeOrNow(),
				Head:      "--",
				HeadColor: tcell.ColorGray,


@@ 659,7 678,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
		}
	case irc.TopicChangeEvent:
		body := fmt.Sprintf("Topic changed to: %s", ev.Topic)
		app.win.AddLine(ev.Channel, ui.NotifyUnread, ui.Line{
		app.win.AddLine(netID, ev.Channel, ui.NotifyUnread, ui.Line{
			At:        msg.TimeOrNow(),
			Head:      "--",
			HeadColor: tcell.ColorGray,


@@ 667,7 686,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
		})
	case irc.ModeChangeEvent:
		body := fmt.Sprintf("Mode change: %s", ev.Mode)
		app.win.AddLine(ev.Channel, ui.NotifyUnread, ui.Line{
		app.win.AddLine(netID, ev.Channel, ui.NotifyUnread, ui.Line{
			At:        msg.TimeOrNow(),
			Head:      "--",
			HeadColor: tcell.ColorGray,


@@ 677,11 696,11 @@ func (app *App) handleIRCEvent(ev interface{}) {
		var buffer string
		var notify ui.NotifyType
		var body string
		if app.s.IsMe(ev.Invitee) {
			buffer = Home
		if s.IsMe(ev.Invitee) {
			buffer = ""
			notify = ui.NotifyHighlight
			body = fmt.Sprintf("%s invited you to join %s", ev.Inviter, ev.Channel)
		} else if app.s.IsMe(ev.Inviter) {
		} else if s.IsMe(ev.Inviter) {
			buffer = ev.Channel
			notify = ui.NotifyNone
			body = fmt.Sprintf("You invited %s to join this channel", ev.Invitee)


@@ 690,7 709,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
			notify = ui.NotifyUnread
			body = fmt.Sprintf("%s invited %s to join this channel", ev.Inviter, ev.Invitee)
		}
		app.win.AddLine(buffer, notify, ui.Line{
		app.win.AddLine(netID, buffer, notify, ui.Line{
			At:        msg.TimeOrNow(),
			Head:      "--",
			HeadColor: tcell.ColorGray,


@@ 698,19 717,20 @@ func (app *App) handleIRCEvent(ev interface{}) {
			Highlight: notify == ui.NotifyHighlight,
		})
	case irc.MessageEvent:
		buffer, line, hlNotification := app.formatMessage(ev)
		buffer, line, hlNotification := app.formatMessage(s, ev)
		var notify ui.NotifyType
		if hlNotification {
			notify = ui.NotifyHighlight
		} else {
			notify = ui.NotifyUnread
		}
		app.win.AddLine(buffer, notify, line)
		app.win.AddLine(netID, buffer, notify, line)
		if hlNotification {
			app.notifyHighlight(buffer, ev.User, line.Body.String())
		}
		if !app.s.IsChannel(msg.Params[0]) && !app.s.IsMe(ev.User) {
		if !s.IsChannel(msg.Params[0]) && !s.IsMe(ev.User) {
			app.lastQuery = msg.Prefix.Name
			app.lastQueryNet = netID
		}
		bounds := app.messageBounds[ev.Target]
		bounds.Update(&line)


@@ 722,7 742,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
		for _, m := range ev.Messages {
			switch ev := m.(type) {
			case irc.MessageEvent:
				_, line, _ := app.formatMessage(ev)
				_, line, _ := app.formatMessage(s, ev)
				if hasBounds {
					c := bounds.Compare(&line)
					if c < 0 {


@@ 735,7 755,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
				}
			}
		}
		app.win.AddLines(ev.Target, linesBefore, linesAfter)
		app.win.AddLines(netID, ev.Target, linesBefore, linesAfter)
		if len(linesBefore) != 0 {
			bounds.Update(&linesBefore[0])
			bounds.Update(&linesBefore[len(linesBefore)-1])


@@ 745,6 765,11 @@ func (app *App) handleIRCEvent(ev interface{}) {
			bounds.Update(&linesAfter[len(linesAfter)-1])
		}
		app.messageBounds[ev.Target] = bounds
	case irc.BouncerNetworkEvent:
		_, added := app.win.AddBuffer(ev.ID, ev.Name, "")
		if added {
			go app.ircLoop(ev.ID)
		}
	case irc.ErrorEvent:
		if isBlackListed(msg.Command) {
			break


@@ 764,7 789,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
		default:
			panic("unreachable")
		}
		app.addStatusLine(ui.Line{
		app.addStatusLine(netID, ui.Line{
			At:   msg.TimeOrNow(),
			Head: head,
			Body: ui.PlainString(body),


@@ 782,13 807,13 @@ func isBlackListed(command string) bool {
}

// isHighlight reports whether the given message content is a highlight.
func (app *App) isHighlight(content string) bool {
	contentCf := app.s.Casemap(content)
func (app *App) isHighlight(s *irc.Session, content string) bool {
	contentCf := s.Casemap(content)
	if app.highlights == nil {
		return strings.Contains(contentCf, app.s.NickCf())
		return strings.Contains(contentCf, s.NickCf())
	}
	for _, h := range app.highlights {
		if strings.Contains(contentCf, app.s.Casemap(h)) {
		if strings.Contains(contentCf, s.Casemap(h)) {
			return true
		}
	}


@@ 805,8 830,9 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
	if err != nil {
		return
	}
	netID, curBuffer := app.win.CurrentBuffer()
	here := "0"
	if buffer == app.win.CurrentBuffer() {
	if buffer == curBuffer { // TODO also check netID
		here = "1"
	}
	cmd := exec.Command(sh, "-c", app.cfg.OnHighlight)


@@ 819,7 845,7 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
	output, err := cmd.CombinedOutput()
	if err != nil {
		body := fmt.Sprintf("Failed to invoke on-highlight command: %v. Output: %q", err, string(output))
		app.addStatusLine(ui.Line{
		app.addStatusLine(netID, ui.Line{
			At:        time.Now(),
			Head:      "!!",
			HeadColor: tcell.ColorRed,


@@ 831,32 857,36 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
// typing sends typing notifications to the IRC server according to the user
// input.
func (app *App) typing() {
	if app.s == nil || app.cfg.NoTypings {
	netID, buffer := app.win.CurrentBuffer()
	s := app.sessions[netID]
	if s == nil || app.cfg.NoTypings {
		return
	}
	buffer := app.win.CurrentBuffer()
	if buffer == Home {
	if buffer == "" {
		return
	}
	input := app.win.InputContent()
	if len(input) == 0 {
		app.s.TypingStop(buffer)
		s.TypingStop(buffer)
	} else if !isCommand(input) {
		app.s.Typing(app.win.CurrentBuffer())
		s.Typing(buffer)
	}
}

// completions computes the list of completions given the input text and the
// cursor position.
func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
	var cs []ui.Completion

	if len(text) == 0 {
		return cs
		return nil
	}
	netID, buffer := app.win.CurrentBuffer()
	s := app.sessions[netID]
	if s == nil {
		return nil
	}

	buffer := app.win.CurrentBuffer()
	if app.s.IsChannel(buffer) {
	var cs []ui.Completion
	if buffer != "" {
		cs = app.completionsChannelTopic(cs, cursorIdx, text)
		cs = app.completionsChannelMembers(cs, cursorIdx, text)
	}


@@ 878,17 908,22 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
// - which buffer the message must be added to,
// - the UI line,
// - whether senpai must trigger the "on-highlight" command.
func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) {
	isFromSelf := app.s.IsMe(ev.User)
	isHighlight := app.isHighlight(ev.Content)
func (app *App) formatMessage(s *irc.Session, ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) {
	isFromSelf := s.IsMe(ev.User)
	isHighlight := app.isHighlight(s, ev.Content)
	isAction := strings.HasPrefix(ev.Content, "\x01ACTION")
	isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG"
	isNotice := ev.Command == "NOTICE"

	if !ev.TargetIsChannel && isNotice {
		buffer = app.win.CurrentBuffer()
		curNetID, curBuffer := app.win.CurrentBuffer()
		if curNetID == s.NetID() {
			buffer = curBuffer
		} else {
			isHighlight = true
		}
	} else if !ev.TargetIsChannel {
		buffer = Home
		buffer = ""
	} else {
		buffer = ev.Target
	}


@@ 942,40 977,45 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line,

// updatePrompt changes the prompt text according to the application context.
func (app *App) updatePrompt() {
	buffer := app.win.CurrentBuffer()
	netID, buffer := app.win.CurrentBuffer()
	s := app.sessions[netID]
	command := isCommand(app.win.InputContent())
	var prompt ui.StyledString
	if buffer == Home || command {
	if buffer == "" || command {
		prompt = ui.Styled(">",
			tcell.
				StyleDefault.
				Foreground(tcell.Color(app.cfg.Colors.Prompt)),
		)
	} else if app.s == nil {
	} else if s == nil {
		prompt = ui.Styled("<offline>",
			tcell.
				StyleDefault.
				Foreground(tcell.ColorRed),
		)
	} else {
		prompt = identString(app.s.Nick())
		prompt = identString(s.Nick())
	}
	app.win.SetPrompt(prompt)
}

func (app *App) printTopic(buffer string) {
func (app *App) printTopic(netID, buffer string) (ok bool) {
	var body string

	topic, who, at := app.s.Topic(buffer)
	s := app.sessions[netID]
	if s == nil {
		return false
	}
	topic, who, at := s.Topic(buffer)
	if who == nil {
		body = fmt.Sprintf("Topic: %s", topic)
	} else {
		body = fmt.Sprintf("Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic)
	}
	app.win.AddLine(buffer, ui.NotifyNone, ui.Line{
	app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
		At:        time.Now(),
		Head:      "--",
		HeadColor: tcell.ColorGray,
		Body:      ui.Styled(body, tcell.StyleDefault.Foreground(tcell.ColorGray)),
	})
	return true
}

M cmd/senpai/main.go => cmd/senpai/main.go +14 -7
@@ 43,19 43,21 @@ func main() {

	cfg.Debug = cfg.Debug || debug

	lastBuffer := getLastBuffer()

	app, err := senpai.NewApp(cfg, lastBuffer)
	app, err := senpai.NewApp(cfg)
	if err != nil {
		panic(err)
	}

	lastNetID, lastBuffer := getLastBuffer()
	app.SwitchToBuffer(lastNetID, lastBuffer)

	app.Run()
	app.Close()

	// Write last buffer on close
	lastBufferPath := getLastBufferPath()
	err = os.WriteFile(lastBufferPath, []byte(app.CurrentBuffer()), 0666)
	lastNetID, lastBuffer = app.CurrentBuffer()
	err = os.WriteFile(lastBufferPath, []byte(fmt.Sprintf("%s %s", lastNetID, lastBuffer)), 0666)
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to write last buffer at %q: %s\n", lastBufferPath, err)
	}


@@ 76,11 78,16 @@ func getLastBufferPath() string {
	return lastBufferPath
}

func getLastBuffer() string {
func getLastBuffer() (netID, buffer string) {
	buf, err := ioutil.ReadFile(getLastBufferPath())
	if err != nil {
		return ""
		return "", ""
	}

	fields := strings.SplitN(string(buf), " ", 2)
	if len(fields) < 2 {
		return "", ""
	}

	return strings.TrimSpace(string(buf))
	return fields[0], fields[1]
}

M commands.go => commands.go +108 -96
@@ 136,28 136,27 @@ func init() {
	}
}

func noCommand(app *App, buffer, content string) error {
	// You can't send messages to home buffer, and it might get
	// delivered to a user "home" without a bouncer, which will be bad.
	if buffer == Home {
		return fmt.Errorf("can't send message to home")
func noCommand(app *App, content string) error {
	netID, buffer := app.win.CurrentBuffer()
	if buffer == "" {
		return fmt.Errorf("can't send message to this buffer")
	}

	if app.s == nil {
	s := app.sessions[netID]
	if s == nil {
		return errOffline
	}

	app.s.PrivMsg(buffer, content)
	if !app.s.HasCapability("echo-message") {
		buffer, line, _ := app.formatMessage(irc.MessageEvent{
			User:            app.s.Nick(),
	s.PrivMsg(buffer, content)
	if !s.HasCapability("echo-message") {
		buffer, line, _ := app.formatMessage(s, irc.MessageEvent{
			User:            s.Nick(),
			Target:          buffer,
			TargetIsChannel: true,
			Command:         "PRIVMSG",
			Content:         content,
			Time:            time.Now(),
		})
		app.win.AddLine(buffer, ui.NotifyNone, line)
		app.win.AddLine(netID, buffer, ui.NotifyNone, line)
	}

	return nil


@@ 180,8 179,9 @@ func commandDoBuffer(app *App, args []string) error {

func commandDoHelp(app *App, args []string) (err error) {
	t := time.Now()
	netID, buffer := app.win.CurrentBuffer()
	if len(args) == 0 {
		app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
		app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
			At:   t,
			Head: "--",
			Body: ui.PlainString("Available commands:"),


@@ 190,22 190,22 @@ func commandDoHelp(app *App, args []string) (err error) {
			if cmd.Desc == "" {
				continue
			}
			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
				At:   t,
				Body: ui.PlainSprintf("  \x02%s\x02 %s", cmdName, cmd.Usage),
			})
			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
				At:   t,
				Body: ui.PlainSprintf("    %s", cmd.Desc),
			})
			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
				At: t,
			})
		}
	} else {
		search := strings.ToUpper(args[0])
		found := false
		app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
		app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
			At:   t,
			Head: "--",
			Body: ui.PlainSprintf("Commands that match \"%s\":", search),


@@ 221,21 221,21 @@ func commandDoHelp(app *App, args []string) (err error) {
			usage.SetStyle(tcell.StyleDefault)
			usage.WriteByte(' ')
			usage.WriteString(cmd.Usage)
			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
				At:   t,
				Body: usage.StyledString(),
			})
			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
				At:   t,
				Body: ui.PlainSprintf("  %s", cmd.Desc),
			})
			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
				At: t,
			})
			found = true
		}
		if !found {
			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
				At:   t,
				Body: ui.PlainSprintf("  no command matches %q", args[0]),
			})


@@ 245,75 245,81 @@ func commandDoHelp(app *App, args []string) (err error) {
}

func commandDoJoin(app *App, args []string) (err error) {
	if app.s == nil {
	s := app.CurrentSession()
	if s == nil {
		return errOffline
	}

	channel := args[0]
	key := ""
	if len(args) == 2 {
		key = args[1]
	}
	app.s.Join(args[0], key)
	s.Join(channel, key)
	return nil
}

func commandDoMe(app *App, args []string) (err error) {
	if app.s == nil {
		return errOffline
	}

	buffer := app.win.CurrentBuffer()
	if buffer == Home {
	netID, buffer := app.win.CurrentBuffer()
	if buffer == "" {
		netID = app.lastQueryNet
		buffer = app.lastQuery
	}
	s := app.sessions[netID]
	if s == nil {
		return errOffline
	}
	content := fmt.Sprintf("\x01ACTION %s\x01", args[0])
	app.s.PrivMsg(buffer, content)
	if !app.s.HasCapability("echo-message") {
		buffer, line, _ := app.formatMessage(irc.MessageEvent{
			User:            app.s.Nick(),
	s.PrivMsg(buffer, content)
	if !s.HasCapability("echo-message") {
		buffer, line, _ := app.formatMessage(s, irc.MessageEvent{
			User:            s.Nick(),
			Target:          buffer,
			TargetIsChannel: true,
			Command:         "PRIVMSG",
			Content:         content,
			Time:            time.Now(),
		})
		app.win.AddLine(buffer, ui.NotifyNone, line)
		app.win.AddLine(netID, buffer, ui.NotifyNone, line)
	}
	return nil
}

func commandDoMsg(app *App, args []string) (err error) {
	if app.s == nil {
		return errOffline
	}

	target := args[0]
	content := args[1]
	app.s.PrivMsg(target, content)
	if !app.s.HasCapability("echo-message") {
		buffer, line, _ := app.formatMessage(irc.MessageEvent{
			User:            app.s.Nick(),
	netID, _ := app.win.CurrentBuffer()
	s := app.sessions[netID]
	if s == nil {
		return errOffline
	}
	s.PrivMsg(target, content)
	if !s.HasCapability("echo-message") {
		buffer, line, _ := app.formatMessage(s, irc.MessageEvent{
			User:            s.Nick(),
			Target:          target,
			TargetIsChannel: true,
			Command:         "PRIVMSG",
			Content:         content,
			Time:            time.Now(),
		})
		app.win.AddLine(buffer, ui.NotifyNone, line)
		app.win.AddLine(netID, buffer, ui.NotifyNone, line)
	}
	return nil
}

func commandDoNames(app *App, args []string) (err error) {
	if app.s == nil {
	netID, buffer := app.win.CurrentBuffer()
	s := app.sessions[netID]
	if s == nil {
		return errOffline
	}

	buffer := app.win.CurrentBuffer()
	if !s.IsChannel(buffer) {
		return fmt.Errorf("this is not a channel")
	}
	var sb ui.StyledStringBuilder
	sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGrey))
	sb.WriteString("Names: ")
	for _, name := range app.s.Names(buffer) {
	for _, name := range s.Names(buffer) {
		if name.PowerLevel != "" {
			sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen))
			sb.WriteString(name.PowerLevel)


@@ 324,7 330,7 @@ func commandDoNames(app *App, args []string) (err error) {
	}
	body := sb.StyledString()
	// TODO remove last space
	app.win.AddLine(buffer, ui.NotifyNone, ui.Line{
	app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
		At:        time.Now(),
		Head:      "--",
		HeadColor: tcell.ColorGray,


@@ 334,40 340,40 @@ func commandDoNames(app *App, args []string) (err error) {
}

func commandDoNick(app *App, args []string) (err error) {
	if app.s == nil {
		return errOffline
	}

	nick := args[0]
	if i := strings.IndexAny(nick, " :@!*?"); i >= 0 {
	if i := strings.IndexAny(nick, " :"); i >= 0 {
		return fmt.Errorf("illegal char %q in nickname", nick[i])
	}
	app.s.ChangeNick(nick)
	return nil
}

func commandDoMode(app *App, args []string) (err error) {
	if app.s == nil {
	s := app.CurrentSession()
	if s == nil {
		return errOffline
	}
	s.ChangeNick(nick)
	return
}

func commandDoMode(app *App, args []string) (err error) {
	channel := args[0]
	flags := args[1]
	modeArgs := args[2:]

	app.s.ChangeMode(channel, flags, modeArgs)
	s := app.CurrentSession()
	if s == nil {
		return errOffline
	}
	s.ChangeMode(channel, flags, modeArgs)
	return nil
}

func commandDoPart(app *App, args []string) (err error) {
	if app.s == nil {
	netID, channel := app.win.CurrentBuffer()
	s := app.sessions[netID]
	if s == nil {
		return errOffline
	}

	channel := app.win.CurrentBuffer()
	reason := ""
	if 0 < len(args) {
		if app.s.IsChannel(args[0]) {
		if s.IsChannel(args[0]) {
			channel = args[0]
			if 1 < len(args) {
				reason = args[1]


@@ 376,10 382,11 @@ func commandDoPart(app *App, args []string) (err error) {
			reason = args[0]
		}
	}
	if channel == Home {
		return fmt.Errorf("cannot part home")

	if channel == "" {
		return fmt.Errorf("cannot part this buffer")
	}
	app.s.Part(channel, reason)
	s.Part(channel, reason)
	return nil
}



@@ 388,68 395,73 @@ func commandDoQuit(app *App, args []string) (err error) {
	if 0 < len(args) {
		reason = args[0]
	}
	if app.s != nil {
		app.s.Quit(reason)
	for _, session := range app.sessions {
		session.Quit(reason)
	}
	app.win.Exit()
	return nil
}

func commandDoQuote(app *App, args []string) (err error) {
	if app.s == nil {
	s := app.CurrentSession()
	if s == nil {
		return errOffline
	}

	app.s.SendRaw(args[0])
	s.SendRaw(args[0])
	return nil
}

func commandDoR(app *App, args []string) (err error) {
	if app.s == nil {
	s := app.sessions[app.lastQueryNet]
	if s == nil {
		return errOffline
	}

	app.s.PrivMsg(app.lastQuery, args[0])
	if !app.s.HasCapability("echo-message") {
		buffer, line, _ := app.formatMessage(irc.MessageEvent{
			User:            app.s.Nick(),
	s.PrivMsg(app.lastQuery, args[0])
	if !s.HasCapability("echo-message") {
		buffer, line, _ := app.formatMessage(s, irc.MessageEvent{
			User:            s.Nick(),
			Target:          app.lastQuery,
			TargetIsChannel: true,
			Command:         "PRIVMSG",
			Content:         args[0],
			Time:            time.Now(),
		})
		app.win.AddLine(buffer, ui.NotifyNone, line)
		app.win.AddLine(app.lastQueryNet, buffer, ui.NotifyNone, line)
	}
	return nil
}

func commandDoTopic(app *App, args []string) (err error) {
	if app.s == nil {
		return errOffline
	}

	netID, buffer := app.win.CurrentBuffer()
	var ok bool
	if len(args) == 0 {
		app.printTopic(app.win.CurrentBuffer())
		ok = app.printTopic(netID, buffer)
	} else {
		app.s.ChangeTopic(app.win.CurrentBuffer(), args[0])
		s := app.sessions[netID]
		if s != nil {
			s.ChangeTopic(buffer, args[0])
			ok = true
		}
	}
	if !ok {
		return errOffline
	}
	return nil
}

func commandDoInvite(app *App, args []string) (err error) {
	if app.s == nil {
	nick := args[0]
	netID, channel := app.win.CurrentBuffer()
	s := app.sessions[netID]
	if s == nil {
		return errOffline
	}

	nick := args[0]
	channel := app.win.CurrentBuffer()
	if len(args) == 2 {
		channel = args[1]
	} else if channel == Home {
		return fmt.Errorf("cannot invite to home")
	} else if channel == "" {
		return fmt.Errorf("either send this command from a channel, or specify the channel")
	}
	app.s.Invite(nick, channel)
	s.Invite(nick, channel)
	return nil
}



@@ 522,7 534,7 @@ func (app *App) handleInput(buffer, content string) error {

	cmdName, rawArgs, isCommand := parseCommand(content)
	if !isCommand {
		return noCommand(app, buffer, rawArgs)
		return noCommand(app, rawArgs)
	}
	if cmdName == "" {
		return fmt.Errorf("lone slash at the beginning")


@@ 554,8 566,8 @@ func (app *App) handleInput(buffer, content string) error {
	if len(args) < cmd.MinArgs {
		return fmt.Errorf("usage: %s %s", cmdName, cmd.Usage)
	}
	if buffer == Home && !cmd.AllowHome {
		return fmt.Errorf("command %q cannot be executed from home", cmdName)
	if buffer == "" && !cmd.AllowHome {
		return fmt.Errorf("command %q cannot be executed from a server buffer", cmdName)
	}

	return cmd.Handle(app, args)

M completions.go => completions.go +12 -7
@@ 18,9 18,11 @@ func (app *App) completionsChannelMembers(cs []ui.Completion, cursorIdx int, tex
	if len(word) == 0 {
		return cs
	}
	wordCf := app.s.Casemap(string(word))
	for _, name := range app.s.Names(app.win.CurrentBuffer()) {
		if strings.HasPrefix(app.s.Casemap(name.Name.Name), wordCf) {
	netID, buffer := app.win.CurrentBuffer()
	s := app.sessions[netID] // is not nil
	wordCf := s.Casemap(string(word))
	for _, name := range s.Names(buffer) {
		if strings.HasPrefix(s.Casemap(name.Name.Name), wordCf) {
			nickComp := []rune(name.Name.Name)
			if start == 0 {
				nickComp = append(nickComp, ':')


@@ 45,7 47,9 @@ func (app *App) completionsChannelTopic(cs []ui.Completion, cursorIdx int, text 
	if !hasPrefix(text, []rune("/topic ")) {
		return cs
	}
	topic, _, _ := app.s.Topic(app.win.CurrentBuffer())
	netID, buffer := app.win.CurrentBuffer()
	s := app.sessions[netID] // is not nil
	topic, _, _ := s.Topic(buffer)
	if cursorIdx == len(text) {
		compText := append(text, []rune(topic)...)
		cs = append(cs, ui.Completion{


@@ 60,6 64,7 @@ func (app *App) completionsMsg(cs []ui.Completion, cursorIdx int, text []rune) [
	if !hasPrefix(text, []rune("/msg ")) {
		return cs
	}
	s := app.CurrentSession() // is not nil
	// Check if the first word (target) is already written and complete (in
	// which case we don't have completions to provide).
	var word string


@@ 69,15 74,15 @@ func (app *App) completionsMsg(cs []ui.Completion, cursorIdx int, text []rune) [
			return cs
		}
		if !hasMetALetter && text[i] != ' ' {
			word = app.s.Casemap(string(text[i:cursorIdx]))
			word = s.Casemap(string(text[i:cursorIdx]))
			hasMetALetter = true
		}
	}
	if word == "" {
		return cs
	}
	for _, user := range app.s.Users() {
		if strings.HasPrefix(app.s.Casemap(user), word) {
	for _, user := range s.Users() {
		if strings.HasPrefix(s.Casemap(user), word) {
			nickComp := append([]rune(user), ' ')
			c := make([]rune, len(text)+5+len(nickComp)-cursorIdx)
			copy(c[:5], []rune("/msg "))

M doc/senpai.1.scd => doc/senpai.1.scd +1 -0
@@ 23,6 23,7 @@ extensions, such as:

- _CHATHISTORY_, senpai fetches history from the server instead of keeping logs,
- _@+typing_, senpai shows when others are typing a message,
- _BOUNCER_, senpai connects to all your networks at once automatically,
- and more to come!

# CONFIGURATION

M irc/events.go => irc/events.go +5 -0
@@ 75,3 75,8 @@ type HistoryEvent struct {
	Target   string
	Messages []Event
}

type BouncerNetworkEvent struct {
	ID   string
	Name string
}

M irc/session.go => irc/session.go +42 -6
@@ 57,7 57,8 @@ var SupportedCapabilities = map[string]struct{}{
	"sasl":          {},
	"setname":       {},

	"draft/chathistory": {},
	"draft/chathistory":        {},
	"soju.im/bouncer-networks": {},
}

// Values taken by the "@+typing=" client tag.  TypingUnspec means the value or


@@ 91,8 92,8 @@ type SessionParams struct {
	Nickname string
	Username string
	RealName string

	Auth SASLClient
	NetID    string
	Auth     SASLClient
}

type Session struct {


@@ 108,6 109,7 @@ type Session struct {
	real   string
	acct   string
	host   string
	netID  string
	auth   SASLClient

	availableCaps map[string]string


@@ 138,6 140,7 @@ func NewSession(out chan<- Message, params SessionParams) *Session {
		nickCf:          CasemapASCII(params.Nickname),
		user:            params.Username,
		real:            params.RealName,
		netID:           params.NetID,
		auth:            params.Auth,
		availableCaps:   map[string]string{},
		enabledCaps:     map[string]struct{}{},


@@ 180,6 183,10 @@ func (s *Session) Nick() string {
	return s.nick
}

func (s *Session) NetID() string {
	return s.netID
}

// NickCf is our casemapped nickname.
func (s *Session) NickCf() string {
	return s.nickCf


@@ 486,10 493,10 @@ func (s *Session) handleUnregistered(msg Message) (Event, error) {
			return nil, err
		}

		s.out <- NewMessage("CAP", "END")
		s.endRegistration()
		s.host = ParsePrefix(userhost).Host
	case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs:
		s.out <- NewMessage("CAP", "END")
		s.endRegistration()
	case "CAP":
		var subcommand string
		if err := msg.ParseParams(nil, &subcommand); err != nil {


@@ 525,7 532,7 @@ func (s *Session) handleUnregistered(msg Message) (Event, error) {

				_, ok := s.availableCaps["sasl"]
				if s.auth == nil || !ok {
					s.out <- NewMessage("CAP", "END")
					s.endRegistration()
				}
			}
		default:


@@ 1024,6 1031,19 @@ func (s *Session) handleRegistered(msg Message) (Event, error) {
				FormerNick: msg.Prefix.Name,
			}, nil
		}
	case "BOUNCER":
		if len(msg.Params) < 3 {
			break
		}
		if msg.Params[0] != "NETWORK" || s.netID != "" {
			break
		}
		id := msg.Params[1]
		attrs := parseTags(msg.Params[2])
		return BouncerNetworkEvent{
			ID:   id,
			Name: attrs["name"],
		}, nil
	case "PING":
		var payload string
		if err := msg.ParseParams(&payload); err != nil {


@@ 1137,6 1157,8 @@ func (s *Session) updateFeatures(features []string) {

	Switch:
		switch key {
		case "BOUNCER_NETID":
			s.netID = value
		case "CASEMAPPING":
			switch value {
			case "ascii":


@@ 1175,3 1197,17 @@ func (s *Session) updateFeatures(features []string) {
		}
	}
}

func (s *Session) endRegistration() {
	if _, ok := s.enabledCaps["soju.im/bouncer-networks"]; !ok {
		s.out <- NewMessage("CAP", "END")
		return
	}
	if s.netID == "" {
		s.out <- NewMessage("CAP", "END")
		s.out <- NewMessage("BOUNCER", "LISTNETWORKS")
	} else {
		s.out <- NewMessage("BOUNCER", "BIND", s.netID)
		s.out <- NewMessage("CAP", "END")
	}
}

M irc/tokens.go => irc/tokens.go +1 -2
@@ 132,7 132,6 @@ func escapeTagValue(unescaped string) string {
}

func parseTags(s string) (tags map[string]string) {
	s = s[1:]
	tags = map[string]string{}

	for _, item := range strings.Split(s, ";") {


@@ 239,7 238,7 @@ func ParseMessage(line string) (msg Message, err error) {
		var tags string

		tags, line = word(line)
		msg.Tags = parseTags(tags)
		msg.Tags = parseTags(tags[1:])
	}

	line = strings.TrimLeft(line, " ")

M ui/buffers.go => ui/buffers.go +61 -18
@@ 172,6 172,8 @@ func (l *Line) NewLines(width int) []int {
}

type buffer struct {
	netID      string
	netName    string
	title      string
	highlights int
	unread     bool


@@ 239,15 241,39 @@ func (bs *BufferList) Previous() {
	bs.list[bs.current].unread = false
}

func (bs *BufferList) Add(title string) (i int, added bool) {
func (bs *BufferList) Add(netID, netName, title string) (i int, added bool) {
	lTitle := strings.ToLower(title)
	gotNetID := false
	for i, b := range bs.list {
		if strings.ToLower(b.title) == lTitle {
			return i, false
		lbTitle := strings.ToLower(b.title)
		if b.netID == netID {
			gotNetID = true
			if lbTitle == lTitle {
				return i, false
			}
		} else if gotNetID || (b.netID == netID && lbTitle < lTitle) {
			b := buffer{
				netID:   netID,
				netName: netName,
				title:   title,
			}
			bs.list = append(bs.list[:i+1], bs.list[i:]...)
			bs.list[i] = b
			if i <= bs.current && bs.current < len(bs.list) {
				bs.current++
			}
			return i, true
		}
	}

	bs.list = append(bs.list, buffer{title: title})
	if netName == "" {
		netName = netID
	}
	bs.list = append(bs.list, buffer{
		netID:   netID,
		netName: netName,
		title:   title,
	})
	return len(bs.list) - 1, true
}



@@ 266,8 292,8 @@ func (bs *BufferList) Remove(title string) (ok bool) {
	return
}

func (bs *BufferList) AddLine(title string, notify NotifyType, line Line) {
	idx := bs.idx(title)
func (bs *BufferList) AddLine(netID, title string, notify NotifyType, line Line) {
	idx := bs.idx(netID, title)
	if idx < 0 {
		return
	}


@@ 303,8 329,8 @@ func (bs *BufferList) AddLine(title string, notify NotifyType, line Line) {
	}
}

func (bs *BufferList) AddLines(title string, before, after []Line) {
	idx := bs.idx(title)
func (bs *BufferList) AddLines(netID, title string, before, after []Line) {
	idx := bs.idx(netID, title)
	if idx < 0 {
		return
	}


@@ 326,8 352,9 @@ func (bs *BufferList) AddLines(title string, before, after []Line) {
	}
}

func (bs *BufferList) Current() (title string) {
	return bs.list[bs.current].title
func (bs *BufferList) Current() (netID, title string) {
	b := &bs.list[bs.current]
	return b.netID, b.title
}

func (bs *BufferList) ScrollUp(n int) {


@@ 352,14 379,10 @@ func (bs *BufferList) IsAtTop() bool {
	return b.isAtTop
}

func (bs *BufferList) idx(title string) int {
	if title == "" {
		return bs.current
	}

func (bs *BufferList) idx(netID, title string) int {
	lTitle := strings.ToLower(title)
	for i, b := range bs.list {
		if strings.ToLower(b.title) == lTitle {
		if b.netID == netID && strings.ToLower(b.title) == lTitle {
			return i
		}
	}


@@ 391,7 414,18 @@ func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width,
			x = x0 + indexPadding
		}

		title := truncate(b.title, width-(x-x0), "\u2026")
		var title string
		if b.title == "" {
			title = b.netName
		} else {
			if i == bs.clicked {
				screen.SetContent(x, y, ' ', nil, tcell.StyleDefault.Reverse(true))
				screen.SetContent(x+1, y, ' ', nil, tcell.StyleDefault.Reverse(true))
			}
			x += 2
			title = b.title
		}
		title = truncate(title, width-(x-x0), "\u2026")
		printString(screen, &x, y, Styled(title, st))

		if i == bs.clicked {


@@ 427,8 461,17 @@ func (bs *BufferList) DrawHorizontalBufferList(screen tcell.Screen, x0, y0, widt
		if i == bs.clicked {
			st = st.Reverse(true)
		}
		title := truncate(b.title, width-x, "\u2026")

		var title string
		if b.title == "" {
			st = st.Dim(true)
			title = b.netName
		} else {
			title = b.title
		}
		title = truncate(title, width-x, "\u2026")
		printString(screen, &x, y0, Styled(title, st))

		if 0 < b.highlights {
			st = st.Foreground(tcell.ColorRed).Reverse(true)
			screen.SetContent(x, y0, ' ', nil, st)

M ui/ui.go => ui/ui.go +20 -7
@@ 82,7 82,7 @@ func (ui *UI) Close() {
	ui.screen.Fini()
}

func (ui *UI) CurrentBuffer() string {
func (ui *UI) CurrentBuffer() (netID, title string) {
	return ui.bs.Current()
}



@@ 147,8 147,8 @@ func (ui *UI) IsAtTop() bool {
	return ui.bs.IsAtTop()
}

func (ui *UI) AddBuffer(title string) (i int, added bool) {
	return ui.bs.Add(title)
func (ui *UI) AddBuffer(netID, netName, title string) (i int, added bool) {
	return ui.bs.Add(netID, netName, title)
}

func (ui *UI) RemoveBuffer(title string) {


@@ 156,12 156,12 @@ func (ui *UI) RemoveBuffer(title string) {
	ui.memberOffset = 0
}

func (ui *UI) AddLine(buffer string, notify NotifyType, line Line) {
	ui.bs.AddLine(buffer, notify, line)
func (ui *UI) AddLine(netID, buffer string, notify NotifyType, line Line) {
	ui.bs.AddLine(netID, buffer, notify, line)
}

func (ui *UI) AddLines(buffer string, before, after []Line) {
	ui.bs.AddLines(buffer, before, after)
func (ui *UI) AddLines(netID, buffer string, before, after []Line) {
	ui.bs.AddLines(netID, buffer, before, after)
}

func (ui *UI) JumpBuffer(sub string) bool {


@@ 188,6 188,19 @@ func (ui *UI) JumpBufferIndex(i int) bool {
	return false
}

func (ui *UI) JumpBufferNetwork(netID, sub string) bool {
	subLower := strings.ToLower(sub)
	for i, b := range ui.bs.list {
		if b.netID == netID && strings.Contains(strings.ToLower(b.title), subLower) {
			if ui.bs.To(i) {
				ui.memberOffset = 0
			}
			return true
		}
	}
	return false
}

func (ui *UI) SetStatus(status string) {
	ui.status = status
}

M window.go => window.go +22 -14
@@ 9,42 9,50 @@ import (
	"github.com/gdamore/tcell/v2"
)

var Home = "home"

const welcomeMessage = "senpai dev build. See senpai(1) for a list of keybindings and commands. Private messages and status notices go here."

func (app *App) initWindow() {
	app.win.AddBuffer(Home)
	app.win.AddLine(Home, ui.NotifyNone, ui.Line{
	app.win.AddBuffer("", "(home)", "")
	app.win.AddLine("", "", ui.NotifyNone, ui.Line{
		Head: "--",
		Body: ui.PlainString(welcomeMessage),
		At:   time.Now(),
	})
}

func (app *App) queueStatusLine(line ui.Line) {
type statusLine struct {
	netID string
	line  ui.Line
}

func (app *App) queueStatusLine(netID string, line ui.Line) {
	if line.At.IsZero() {
		line.At = time.Now()
	}
	app.events <- event{
		src:     uiEvent,
		content: line,
		src: "*",
		content: statusLine{
			netID: netID,
			line:  line,
		},
	}
}

func (app *App) addStatusLine(line ui.Line) {
	buffer := app.win.CurrentBuffer()
	if buffer != Home {
		app.win.AddLine(Home, ui.NotifyNone, line)
func (app *App) addStatusLine(netID string, line ui.Line) {
	currentNetID, buffer := app.win.CurrentBuffer()
	if currentNetID == netID && buffer != "" {
		app.win.AddLine(netID, buffer, ui.NotifyNone, line)
	}
	app.win.AddLine(buffer, ui.NotifyNone, line)
	app.win.AddLine(netID, "", ui.NotifyNone, line)
}

func (app *App) setStatus() {
	if app.s == nil {
	netID, buffer := app.win.CurrentBuffer()
	s := app.sessions[netID]
	if s == nil {
		return
	}
	ts := app.s.Typings(app.win.CurrentBuffer())
	ts := s.Typings(buffer)
	status := ""
	if 3 < len(ts) {
		status = "several people are typing..."