password: str = ''
 
 
+class ChatMessage:
+    'Collects all we want to know on incoming PRIVMSG or NOTICE chat message.'
+    content: str = ''
+    sender: str = ''
+    target: str = ''
+    is_notice: bool = False
+
+    def __str__(self) -> str:
+        return f'{"N" if self.is_notice else "P"} '\
+                + f'{self.sender} {self.target} :{self.content}'
+
+    def __bool__(self) -> bool:
+        return bool(self.content + self.sender + self.target) | self.is_notice
+
+
 class SharedClientDbFields(IrcConnSetup):
     'API for fields shared directly in name and type with TUI.'
     connection_state: str = ''
     motd: Iterable[str]
     sasl_account: str = ''
     sasl_auth_state: str = ''
+    message: ChatMessage = ChatMessage()
 
     def is_chan_name(self, name: str) -> bool:
         'Tests name to match CHANTYPES prefixes.'
         self.purge_users()
 
 
+class _ChatMessage(ChatMessage):
+
+    def __init__(self, sender: str = '', db: Optional['_ClientDb'] = None
+                 ) -> None:
+        self.sender = sender
+        self._db = db
+
+    def to(self, target: str) -> Self:
+        'Extend self with .target (empty if input as "me"), return self.'
+        self.target = '' if target == 'me' else target
+        return self
+
+    def __setattr__(self, key: str, value: str) -> None:
+        if key in {'privmsg', 'notice'}:
+            assert self._db is not None
+            self.is_notice = key == 'notice'
+            self.content = value
+            self._db.message = self
+            # to clean update cache, enabling equal messages in direct sequence
+            self._db.message = ChatMessage()
+        else:
+            super().__setattr__(key, value)
+
+
 class _SetNickuserhostMixin:
 
     def __setattr__(self, key: str, value: NickUserHost | str) -> None:
             attr._create_if_none = {}
         return attr
 
+    def messaging(self, src: str | NickUserHost) -> ChatMessage:
+        'Start input chain for chat message data.'
+        return _ChatMessage(sender=f':{src}' if isinstance(src, str)
+                            else src.nick, db=self)
+
     def into_endnode_updates(self, path: tuple[str, ...]
                              ) -> list[tuple[tuple[str, ...], Any]]:
         'Return path-value pairs for update-worthy (sub-)elements at path.'
                 for step in task.path:
                     key = ret[step] if step.isupper() else step
                     node = (node[key] if isinstance(node, Dict)
-                            else getattr(node, key))
+                            else (node(key) if callable(node)
+                                  else getattr(node, key)))
                 for tok_name in tok_names:
                     if task.verb == 'setattr':
                         setattr(node, tok_name, ret[tok_name])
             self.db.users[user_id].nick = ret['nick']
             if user_id == 'me':
                 self.db.nick_wanted = ret['nick']
-        elif ret['_verb'] in {'NOTICE', 'PRIVMSG'}:
-            kw: dict[str, str | bool] = {'as_notice': ret['_verb'] == 'NOTICE'}
-            if (scope := LogScope.CHAT if 'sender' in ret else None):
-                kw['them'] = ret['sender'].nick
-                kw['log_target'] = (kw['them'] if 'nick' in ret
-                                    else ret['channel'])
-            self._log(ret['message'], out=False, scope=scope, **kw)
         elif ret['_verb'] == 'PART':
             ret['parter'].part(ret['channel'], ret.get('message', ''))
             if ret['parter'] is self.db.users['me']:
 
 from ircplom.tui_base import (BaseTui, PromptWidget, TuiEvent, Window,
                               CMD_SHORTCUTS)
 from ircplom.client import (
-        AutoAttrMixin, Channel, Client, ClientQueueMixin, Dict, DictItem,
-        ImplementationFail, IrcConnSetup, LogScope, NewClientEvent,
+        AutoAttrMixin, Channel, ChatMessage, Client, ClientQueueMixin, Dict,
+        DictItem, ImplementationFail, IrcConnSetup, LogScope, NewClientEvent,
         NickUserHost, SendFail, ServerCapability, SharedClientDbFields, User)
 from ircplom.irc_conn import IrcMessage
 
                 update.results += [(LogScope.ALL, [':CONNECTED'])]
             elif not update.value:
                 update.results += [(LogScope.ALL, [':DISCONNECTED'])]
+        elif update.key == 'message' and update.value:
+            assert isinstance(update.value, ChatMessage)
+            update.results += [(LogScope.CHAT, [':' + update.value.content])]
 
 
 class _ClientWindowsManager:
             log_kwargs: dict[str, Any] = {'scope': scope}
             if scope in {LogScope.CHAT, LogScope.USER,
                          LogScope.USER_NO_CHANNELS}:
-                log_kwargs |= {'log_target': update.full_path[1]}
+                if update.full_path == ('message',):
+                    log_kwargs['log_target'] = (update.value.target
+                                                or update.value.sender)
+                    log_kwargs['them'] = update.value.sender
+                    log_kwargs['as_notice'] = update.value.is_notice
+                    log_kwargs['out'] = False
+                else:
+                    log_kwargs['log_target'] = update.full_path[1]
             if isinstance(result, list):
                 msg = ''
                 for item in result:
                     self.log(f'{log_path} cleared', **log_kwargs)
                 else:
                     announcement = f'{log_path} set to:'
-                    if isinstance(result, tuple):
+                    if isinstance(result, ChatMessage) or not isinstance(result, tuple):
+                        self.log(f'{announcement} [{result}]', **log_kwargs)
+                    else:
                         self.log(announcement, **log_kwargs)
                         for item in result:
                             self.log(f'  {item}', **log_kwargs)
-                    else:
-                        self.log(f'{announcement} [{result}]', **log_kwargs)
         for win in [w for w in self.windows if isinstance(w, _ChatWindow)]:
             win.set_prompt_prefix()
         return bool([w for w in self.windows if w.tainted])
             if scope == LogScope.DEBUG:
                 return [m.window(LogScope.DEBUG), m.window(LogScope.RAW)]
             if scope == LogScope.CHAT:
-                chatname = (kwargs['log_target']
-                            if kwargs['log_target'] != m.db.users['me'].nick
-                            else kwargs['them'])
-                return [m.window(LogScope.CHAT, chatname=chatname)]
+                return [m.window(LogScope.CHAT, chatname=kwargs['log_target'])]
             if scope == LogScope.USER:
                 return m.windows_for_userid(kwargs['log_target'])
             if scope == LogScope.USER_NO_CHANNELS:
 
 
 # expect some NOTICE and PING to process/reply during initiation
 0:2 < :*.?.net NOTICE * :*** Looking up your ident...
-1,2 $$$ *** Looking up your ident...
+1,2 $ message set to: [N :server  :*** Looking up your ident...]
+3 <<< [:server] *** Looking up your ident...
+1,2 $ message set to: [P   :]
 0:2 < :*.?.net NOTICE * :*** Looking up your hostname...
-1,2 $$$ *** Looking up your hostname...
+1,2 $ message set to: [N :server  :*** Looking up your hostname...]
+3 <<< [:server] *** Looking up your hostname...
+1,2 $ message set to: [P   :]
 0:2 < :*.?.net NOTICE * :*** Found your hostname (baz.bar.foo)
-1,2 $$$ *** Found your hostname (baz.bar.foo)
+1,2 $ message set to: [N :server  :*** Found your hostname (baz.bar.foo)]
+3 <<< [:server] *** Found your hostname (baz.bar.foo)
+1,2 $ message set to: [P   :]
 0:2 < PING :?
 2 > PONG :?
 
 
 # handle bot query NOTICE
 0:2 < :SaslServ!SaslServ@services.bar.baz NOTICE foo1 :Last login from ~foobarbaz@foo.bar.baz on Jan 1 22:00:00 2021 +0000.
-3 <<< [SaslServ] Last login from ~foobarbaz@foo.bar.baz on Jan 1 22:00:00 2021 +0000.
+1,2 $ message set to: [N SaslServ  :Last login from ~foobarbaz@foo.bar.baz on Jan 1 22:00:00 2021 +0000.]
+4 <<< [SaslServ] Last login from ~foobarbaz@foo.bar.baz on Jan 1 22:00:00 2021 +0000.
+1,2 $ message set to: [P   :]
 
 # check difference in available commands when switching to client window
 > /join #test
 0 #   0) :start
 0 #   1) foo.bar.baz :DEBUG
 0 #   2) foo.bar.baz :RAW
-0 #   3) foo.bar.baz SaslServ
+0 #   3) foo.bar.baz :server
+0 #   4) foo.bar.baz SaslServ
 > /window 1
 > /help
 1 # commands available in this window:
 1,2 $ channels:#test:exits cleared
 0:2 < :foo.bar.baz 333 foo1 #test bar!~bar@bar.bar 1234567890
 1,2 $ channels:#test:topic set to: [Topic(what='foo bar baz', who=NickUserHost(nick='bar', user='~bar', host='bar.bar'))]
-4 $ bar!~bar@bar.bar set topic: foo bar baz
+5 $ bar!~bar@bar.bar set topic: foo bar baz
 0:2 < :foo.bar.baz 353 foo1 @ #test :foo1 @bar
 1,2 $ users:1:nick set to: [?]
 1,2 $ users:1:nick set to: [bar]
 1,2 $ channels:#test:user_ids set to:
 1,2 $   1
 1,2 $   me
-4 $ residents: bar, foo1
+5 $ residents: bar, foo1
 
 # deliver PRIVMSG to channel window, update sender's user+host from metadata
 0:2 < :bar!~bar@bar.bar PRIVMSG #test :hi there
 1,2 $ users:1:user set to: [~bar]
 1,2 $ users:1:host set to: [bar.bar]
-4 < [bar] hi there
+1,2 $ message set to: [P bar #test :hi there]
+5 < [bar] hi there
+1,2 $ message set to: [P   :]
 
 # check _changing_ TOPIC message is communicated to channel window
 0:2 < :bar!~bar@bar.bar TOPIC #test :foo bar baz
 0:2 < :bar!~bar@bar.bar TOPIC #test :abc def ghi
 1,2 $ channels:#test:topic set to: [Topic(what='abc def ghi', who=NickUserHost(nick='bar', user='~bar', host='bar.bar'))]
-4 $ bar!~bar@bar.bar set topic: abc def ghi
+5 $ bar!~bar@bar.bar set topic: abc def ghi
 
 # process non-self channel JOIN
 0:2 < :baz!~baz@baz.baz JOIN :#test
 1,2 $   1
 1,2 $   2
 1,2 $   me
-4 $ baz!~baz@baz.baz joins
+5 $ baz!~baz@baz.baz joins
 
 # join second channel with partial residents identity to compare distribution of resident-specific messages
 > /join #testtest
 1,2 $ channels:#testtest:user_ids set to:
 1,2 $   2
 1,2 $   me
-5 $ residents: baz, foo1
+6 $ residents: baz, foo1
 
 # handle query window with known user
 0:2 < :baz!~baz@baz.baz PRIVMSG foo1 :hi there
-6 < [baz] hi there
+1,2 $ message set to: [P baz  :hi there]
+7 < [baz] hi there
+1,2 $ message set to: [P   :]
 > /privmsg baz hello, how is it going
 2 > PRIVMSG baz :hello, how is it going
-6 > [foo1] hello, how is it going
+7 > [foo1] hello, how is it going
 0:2 < :baz!~baz@baz.baz PRIVMSG foo1 :fine!
-6 < [baz] fine!
+1,2 $ message set to: [P baz  :fine!]
+7 < [baz] fine!
+1,2 $ message set to: [P   :]
 
 # handle failure to query absent user
 > /privmsg barbar hello!
 2 > PRIVMSG barbar :hello!
-7 > [foo1] hello!
+8 > [foo1] hello!
 0:2 < :*.?.net 401 foo1 barbar :No such nick/channel
-7 !$ barbar not online
+8 !$ barbar not online
 
 # handle non-self renaming
 0:2 < :baz!~baz@baz.baz NICK :bazbaz
 1,2 $ users:2:nick set to: [bazbaz]
-4,5,6 $ baz!~baz@baz.baz renames bazbaz
+5,6,7 $ baz!~baz@baz.baz renames bazbaz
 
 # handle non-self PART in one of two inhabited channels, preserve identity into re-JOIN
 0:2 < :bazbaz!~baz@baz.baz PART :#test
 1,2 $ channels:#test:user_ids set to:
 1,2 $   1
 1,2 $   me
-4 $ bazbaz!~baz@baz.baz parts
+5 $ bazbaz!~baz@baz.baz parts
 1,2 $ channels:#test:exits:2 cleared
 0:2 < :bazbaz!~baz@baz.baz JOIN :#test
 1,2 $ channels:#test:user_ids set to:
 1,2 $   1
 1,2 $   2
 1,2 $   me
-4 $ bazbaz!~baz@baz.baz joins
+5 $ bazbaz!~baz@baz.baz joins
 
 # handle non-self PART in only inhabited channel, lose identity, re-join as new identity
 0:2 < :bar!~bar@bar.bar PART :#test
 1,2 $ channels:#test:user_ids set to:
 1,2 $   2
 1,2 $   me
-4 $ bar!~bar@bar.bar parts
+5 $ bar!~bar@bar.bar parts
 1,2 $ channels:#test:exits:1 cleared
 1,2 $ users:1 cleared
 0:2 < :bar!~bar@bar.bar JOIN :#test
 1,2 $   2
 1,2 $   3
 1,2 $   me
-4 $ bar!~bar@bar.bar joins
+5 $ bar!~bar@bar.bar joins
 
 # handle non-self QUIT
 0:2 < :bazbaz!~baz@baz.baz QUIT :Client Quit
 1,2 $ users:2:exit_msg set to: [QClient Quit]
-6 $ bazbaz!~baz@baz.baz quits: Client Quit
+7 $ bazbaz!~baz@baz.baz quits: Client Quit
 1,2 $ channels:#test:exits:2 set to: [QClient Quit]
 1,2 $ channels:#test:user_ids set to:
 1,2 $   3
 1,2 $   me
-4 $ bazbaz!~baz@baz.baz quits: Client Quit
+5 $ bazbaz!~baz@baz.baz quits: Client Quit
 1,2 $ channels:#test:exits:2 cleared
 1,2 $ channels:#testtest:exits:2 set to: [QClient Quit]
 1,2 $ channels:#testtest:user_ids set to:
 1,2 $   me
-5 $ bazbaz!~baz@baz.baz quits: Client Quit
+6 $ bazbaz!~baz@baz.baz quits: Client Quit
 1,2 $ channels:#testtest:exits:2 cleared
 1,2 $ users:2 cleared
 
 1,2 $ channels:#test:exits:me set to: [P]
 1,2 $ channels:#test:user_ids set to:
 1,2 $   3
-4 $ foo1!~foobarbaz@baz.bar.foo parts
+5 $ foo1!~foobarbaz@baz.bar.foo parts
 1,2 $ channels:#test:exits:me cleared
 1,2 $ channels:#test cleared
 1,2 $ users:3 cleared
 2 > QUIT :ircplom says bye
 0:2 < :foo1!~foobarbaz@baz.bar.foo QUIT :Client Quit
 1,2 $ users:me:exit_msg set to: [QClient Quit]
-3,6,7 $ foo1!~foobarbaz@baz.bar.foo quits: Client Quit
+3,4,7,8 $ foo1!~foobarbaz@baz.bar.foo quits: Client Quit
 1,2 $ channels:#testtest:exits:me set to: [QClient Quit]
 1,2 $ channels:#testtest:user_ids set to:
-5 $ foo1!~foobarbaz@baz.bar.foo quits: Client Quit
+6 $ foo1!~foobarbaz@baz.bar.foo quits: Client Quit
 1,2 $ channels:#testtest:exits:me cleared
 0:2 < ERROR :Closing link: (~foobarbaz@baz.bar.foo) [Quit: ircplom says bye]
 1,2 $ connection_state set to: [Closing link: (~foobarbaz@baz.bar.foo) [Quit: ircplom says bye]]
 1,2 $ connection_state set to: []
-3,4,5,6,7 $ DISCONNECTED
+3,4,5,6,7,8 $ DISCONNECTED
 1,2 $ isupport cleared
 1,2 $ isupport:CHANTYPES set to: [#&]
 1,2 $ isupport:PREFIX set to: [(ov)@+]
 
 # test setting up second client, but 432 irrecoverably
 > /connect baz.bar.foo ?foo foo:foo
-8,9 $ isupport cleared
-8,9 $ isupport:CHANTYPES set to: [#&]
-8,9 $ isupport:PREFIX set to: [(ov)@+]
-8,9 $ isupport:USERLEN set to: [10]
-8,9 $ hostname set to: [baz.bar.foo]
-8,9 $ port set to: [-1]
-8,9 $ nick_wanted set to: [?foo]
-8,9 $ user_wanted set to: [foo]
-8,9 $ realname set to: [foo]
-8,9 $ port set to: [6697]
-8,9 $ connection_state set to: [connecting]
-8,9 $ connection_state set to: [connected]
+9,10 $ isupport cleared
+9,10 $ isupport:CHANTYPES set to: [#&]
+9,10 $ isupport:PREFIX set to: [(ov)@+]
+9,10 $ isupport:USERLEN set to: [10]
+9,10 $ hostname set to: [baz.bar.foo]
+9,10 $ port set to: [-1]
+9,10 $ nick_wanted set to: [?foo]
+9,10 $ user_wanted set to: [foo]
+9,10 $ realname set to: [foo]
+9,10 $ port set to: [6697]
+9,10 $ connection_state set to: [connecting]
+9,10 $ connection_state set to: [connected]
 , $ CONNECTED
-1:9 > CAP LS :302
-1:9 > USER foo 0 * :foo
-1:9 > NICK :?foo
-1:9 < :*.?.net 432 * ?foo :Erroneous nickname
-8,9 $ connection_state set to: []
+1:10 > CAP LS :302
+1:10 > USER foo 0 * :foo
+1:10 > NICK :?foo
+1:10 < :*.?.net 432 * ?foo :Erroneous nickname
+9,10 $ connection_state set to: []
 , $ DISCONNECTED
-8,9 $ isupport cleared
-8,9 $ isupport:CHANTYPES set to: [#&]
-8,9 $ isupport:PREFIX set to: [(ov)@+]
-8,9 $ isupport:USERLEN set to: [10]
-8,9 !$ nickname refused for bad format, giving up
+9,10 $ isupport cleared
+9,10 $ isupport:CHANTYPES set to: [#&]
+9,10 $ isupport:PREFIX set to: [(ov)@+]
+9,10 $ isupport:USERLEN set to: [10]
+9,10 !$ nickname refused for bad format, giving up
 
 # test failing third connection
 > /connect baz.baz.baz baz baz:baz
-10,11 $ isupport cleared
-10,11 $ isupport:CHANTYPES set to: [#&]
-10,11 $ isupport:PREFIX set to: [(ov)@+]
-10,11 $ isupport:USERLEN set to: [10]
-10,11 $ hostname set to: [baz.baz.baz]
-10,11 $ port set to: [-1]
-10,11 $ nick_wanted set to: [baz]
-10,11 $ user_wanted set to: [baz]
-10,11 $ realname set to: [baz]
-10,11 $ port set to: [6697]
-10,11 $ connection_state set to: [connecting]
-10,11 $ connection_state set to: [connected]
+11,12 $ isupport cleared
+11,12 $ isupport:CHANTYPES set to: [#&]
+11,12 $ isupport:PREFIX set to: [(ov)@+]
+11,12 $ isupport:USERLEN set to: [10]
+11,12 $ hostname set to: [baz.baz.baz]
+11,12 $ port set to: [-1]
+11,12 $ nick_wanted set to: [baz]
+11,12 $ user_wanted set to: [baz]
+11,12 $ realname set to: [baz]
+11,12 $ port set to: [6697]
+11,12 $ connection_state set to: [connecting]
+11,12 $ connection_state set to: [connected]
 , $ CONNECTED
-2:11 > CAP LS :302
-2:11 > USER baz 0 * :baz
-2:11 > NICK :baz
+2:12 > CAP LS :302
+2:12 > USER baz 0 * :baz
+2:12 > NICK :baz
 2: < FAKE_IRC_CONN_ABORT_EXCEPTION
-10,11 $ connection_state set to: [broken: FAKE_IRC_CONN_ABORT_EXCEPTION]
-10,11 $ connection_state set to: []
+11,12 $ connection_state set to: [broken: FAKE_IRC_CONN_ABORT_EXCEPTION]
+11,12 $ connection_state set to: []
 , $ DISCONNECTED
-10,11 $ isupport cleared
-10,11 $ isupport:CHANTYPES set to: [#&]
-10,11 $ isupport:PREFIX set to: [(ov)@+]
-10,11 $ isupport:USERLEN set to: [10]
+11,12 $ isupport cleared
+11,12 $ isupport:CHANTYPES set to: [#&]
+11,12 $ isupport:PREFIX set to: [(ov)@+]
+11,12 $ isupport:USERLEN set to: [10]
 
 # check that (save TUI tests assuming start on window 0, and no 4 yet) on reconnect, all the same effects can be expected
 > /reconnect
 repeat 63:65
-3,4,5,6,7 $ CONNECTED
-repeat 66:158
-repeat 168:368
+3,4,5,6,7,8 $ CONNECTED
+repeat 66:166
+repeat 177:383
 
 > /quit
 0 <