You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by sr...@apache.org on 2026/09/03 08:14:26 UTC

(plc4x-extras) 09/09: feat: Port both terminal tools to Bubble Tea v2, with demo mode

This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x-extras.git

commit 2aa9af159f630ab08708e73aa19cc0c695f6c3ac
Author: Sebastian Rühl <sr...@apache.org>
AuthorDate: Thu Sep 3 10:04:07 2026 +0200

    feat: Port both terminal tools to Bubble Tea v2, with demo mode
    
    Replaces the tview interfaces in both plc4go tools. tview and tcell are
    gone from the module.
    
    Neither old interface could be tested: their state lived in package-level
    tview widgets which command actions wrote into directly and goroutines
    mutated through QueueUpdateDraw. Both are now Bubble Tea models where all
    state is mutated only inside Update, every piece of I/O is a tea.Cmd
    returning a typed message, and a channel is drained by a command that reads
    one value and re-arms itself. No goroutine touches model state.
    
    plc4xbrowser gains the four commands it never had. read, write, browse and
    subscribe were advertised in its help and answered with "mode switch not yet
    implemented"; they are now a modal request composer with multi-row tag entry
    and live validation, composited over the panes with lipgloss v2's layer
    compositor. The command layer is a tree of values returning a Result the
    model applies, so every command can be run against a fake session with no
    interface present. It also gains a cobra root -- it previously had no
    command-line parsing at all -- wrapped in fang, with --demo, --ascii and
    --log-level.
    
    plc4xpcapanalyzer keeps its command set and gains a tabbed main pane
    (Packets, Log, Findings), a detail pane showing the original bytes against
    the reserialized ones with the first differing offset marked, and a run
    panel with a phase breadcrumb and live counters. --demo generates a capture
    from the verified protocol fixtures and analyses it, so the tool
    demonstrates itself with neither a capture nor a device.
    
    Both tools show a DEMO badge, work down to 60x12, and drop panes in a
    defined order as space runs out. The prompt is never dropped and never
    clipped at any size, which is the defect that motivated the port: below 100
    columns the old grid discarded the command area entirely and the user typed
    into something invisible.
    
    Defects found and fixed while reviewing the ports, each mutation-verified:
    
      - The byte-difference caret, the most valuable line the pcap tool
        produces, was cut at every height from 20 to 26 because the detail pane
        padded from the head. It now sheds the previews instead.
      - A resize could leave the keyboard on a pane the renderer no longer
        draws, with no on-screen clue at 80 columns.
      - The detail pane scrolled indefinitely past its content.
      - Quitting did not cancel a running analysis, parking a goroutine on a
        blocking send while the deferred cleanup deleted the capture under it.
      - Two pcap tests could not fail: one awaited a tea.Batch without ever
        entering the batched command, the other asserted only that Init returned
        non-nil.
      - The packet cursor was pinned to the bottom visible row, so every
        keypress scrolled the list instead of moving the selection.
      - Enter did nothing in a pane: Submit is scoped to text entry, so panes
        had no activation key at all. Added a pane-scoped Select.
      - The message table ignored the theme, emitting 256-colour output under a
        colourless one.
      - The completion popup pushed the help footer off the bottom of the
        screen; it is an overlay now.
    
    Known and deliberately not changed: the pcap tool's config file decodes
    through pointers into the live CLI singletons, so a persisted value can
    override a flag cobra already applied. This is pre-existing -- the previous
    config.go had the identical shape -- and fixing it properly needs
    flag-precedence logic that would change whether "conf set" edits persist.
    
    764 tests pass under the pom's gotestsum invocation, race-clean, with
    apache-rat reporting no unapproved or unknown files. Coverage: 85% of the
    pcap interface, 75% of the browser.
---
 plc4go/go.mod                                      |    6 +-
 plc4go/go.sum                                      |   43 +-
 plc4go/tools/internal/tui/keys.go                  |   21 +-
 plc4go/tools/plc4xbrowser/browser/command.go       |  364 +++++
 plc4go/tools/plc4xbrowser/browser/commands.go      |  624 +++++++++
 plc4go/tools/plc4xbrowser/browser/commands_test.go |  396 ++++++
 plc4go/tools/plc4xbrowser/browser/compose.go       |  448 ++++++
 plc4go/tools/plc4xbrowser/browser/config.go        |  183 +++
 plc4go/tools/plc4xbrowser/browser/doc.go           |   40 +
 plc4go/tools/plc4xbrowser/browser/model.go         |  868 ++++++++++++
 plc4go/tools/plc4xbrowser/browser/model_test.go    |  513 +++++++
 plc4go/tools/plc4xbrowser/browser/run.go           |  170 +++
 plc4go/tools/plc4xbrowser/browser/view.go          |  485 +++++++
 plc4go/tools/plc4xbrowser/main.go                  |   19 +-
 plc4go/tools/plc4xbrowser/ui/actions.go            |  150 --
 plc4go/tools/plc4xbrowser/ui/commands.go           |  803 -----------
 plc4go/tools/plc4xbrowser/ui/common.go             |   86 --
 plc4go/tools/plc4xbrowser/ui/config.go             |  186 ---
 plc4go/tools/plc4xbrowser/ui/ui.go                 |  299 ----
 plc4go/tools/plc4xpcapanalyzer/cmd/ui.go           |   30 +-
 plc4go/tools/plc4xpcapanalyzer/ui/actions.go       |  150 --
 plc4go/tools/plc4xpcapanalyzer/ui/analysis.go      |  669 +++++++++
 plc4go/tools/plc4xpcapanalyzer/ui/analysis_test.go |  420 ++++++
 plc4go/tools/plc4xpcapanalyzer/ui/commands.go      | 1474 ++++++++++++--------
 plc4go/tools/plc4xpcapanalyzer/ui/commands_test.go |  432 ++++++
 plc4go/tools/plc4xpcapanalyzer/ui/common.go        |   87 --
 plc4go/tools/plc4xpcapanalyzer/ui/config.go        |  230 +--
 plc4go/tools/plc4xpcapanalyzer/ui/demo.go          |   96 ++
 plc4go/tools/plc4xpcapanalyzer/ui/helpers_test.go  |  138 ++
 plc4go/tools/plc4xpcapanalyzer/ui/keys.go          |  557 ++++++++
 plc4go/tools/plc4xpcapanalyzer/ui/logsink.go       |  106 ++
 plc4go/tools/plc4xpcapanalyzer/ui/model.go         |  871 ++++++++++++
 plc4go/tools/plc4xpcapanalyzer/ui/model_test.go    |  858 ++++++++++++
 plc4go/tools/plc4xpcapanalyzer/ui/state.go         |  267 ++++
 plc4go/tools/plc4xpcapanalyzer/ui/ui.go            |  392 ++----
 plc4go/tools/plc4xpcapanalyzer/ui/ui_test.go       |  286 ++++
 plc4go/tools/plc4xpcapanalyzer/ui/view.go          | 1226 ++++++++++++++++
 plc4go/tools/plc4xpcapanalyzer/ui/view_test.go     |  561 ++++++++
 38 files changed, 11735 insertions(+), 2819 deletions(-)

diff --git a/plc4go/go.mod b/plc4go/go.mod
index 67d63427..90046c0e 100644
--- a/plc4go/go.mod
+++ b/plc4go/go.mod
@@ -27,9 +27,8 @@ require (
 	charm.land/lipgloss/v2 v2.0.6
 	github.com/apache/plc4x/plc4go v0.0.0-20260902093211-931674134ff2
 	github.com/charmbracelet/fang v1.0.0
-	github.com/gdamore/tcell/v2 v2.13.10
+	github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260902165432-6f6ad8b37b0a
 	github.com/gopacket/gopacket v1.7.1
-	github.com/rivo/tview v0.42.0
 	github.com/rs/zerolog v1.35.1
 	github.com/spf13/cobra v1.10.2
 	github.com/spf13/viper v1.21.0
@@ -40,10 +39,12 @@ require (
 
 require (
 	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/aymanbagabas/go-udiff v0.4.1 // indirect
 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
 	github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
 	github.com/charmbracelet/x/ansi v0.11.8 // indirect
 	github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
+	github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f // indirect
 	github.com/charmbracelet/x/term v0.2.2 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -51,7 +52,6 @@ require (
 	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
 	github.com/fatih/color v1.19.0 // indirect
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
-	github.com/gdamore/encoding v1.0.1 // indirect
 	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
diff --git a/plc4go/go.sum b/plc4go/go.sum
index 5002043a..82f7d7c7 100644
--- a/plc4go/go.sum
+++ b/plc4go/go.sum
@@ -22,8 +22,10 @@ github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7
 github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY=
+github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw=
+github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260902165432-6f6ad8b37b0a h1:7rIWhMhA7nUZAgAi+MVixH9oXst25gMHFJk5fsLEAcw=
+github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260902165432-6f6ad8b37b0a/go.mod h1:aRoQwQWmN9LBG2xi3sVByMFt2fdkPCagd0GAJ1qwOfw=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
@@ -41,10 +43,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
-github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
-github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
-github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -83,8 +81,6 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
-github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
@@ -117,52 +113,21 @@ github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695AP
 github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
 go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
 golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
 golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
 golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
 golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
 golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/plc4go/tools/internal/tui/keys.go b/plc4go/tools/internal/tui/keys.go
index 79f0a391..e97eb71a 100644
--- a/plc4go/tools/internal/tui/keys.go
+++ b/plc4go/tools/internal/tui/keys.go
@@ -194,13 +194,17 @@ func (s spec) resolve(f Focus) key.Binding {
 // binding has to come from what is left: esc, enter, shift+tab, the page keys, the function
 // keys and alt+digit.
 var (
-	specQuit        = spec{scope: ScopeGlobal, keys: []string{"ctrl+c"}, bare: []string{"q"}, label: "q", alt: "ctrl+c", desc: "quit"}
-	specHelp        = spec{scope: ScopeGlobal, keys: []string{"f1"}, bare: []string{"?"}, label: "?", alt: "f1", desc: "help"}
-	specCancel      = spec{scope: ScopeGlobal, keys: []string{"esc"}, label: "esc", desc: "cancel"}
-	specPaneJump    = spec{scope: ScopeGlobal, keys: []string{"alt+1", "alt+2", "alt+3", "alt+4"}, bare: []string{"1", "2", "3", "4"}, label: "1-4", alt: "alt+1-4", desc: "pane"}
-	specPageUp      = spec{scope: ScopeGlobal, keys: []string{"pgup"}, label: "pgup/pgdown", desc: "scroll"}
-	specPageDown    = spec{scope: ScopeGlobal, keys: []string{"pgdown"}}
-	specSubmit      = spec{scope: ScopeInput, keys: []string{"enter"}, label: "enter", desc: "run"}
+	specQuit     = spec{scope: ScopeGlobal, keys: []string{"ctrl+c"}, bare: []string{"q"}, label: "q", alt: "ctrl+c", desc: "quit"}
+	specHelp     = spec{scope: ScopeGlobal, keys: []string{"f1"}, bare: []string{"?"}, label: "?", alt: "f1", desc: "help"}
+	specCancel   = spec{scope: ScopeGlobal, keys: []string{"esc"}, label: "esc", desc: "cancel"}
+	specPaneJump = spec{scope: ScopeGlobal, keys: []string{"alt+1", "alt+2", "alt+3", "alt+4"}, bare: []string{"1", "2", "3", "4"}, label: "1-4", alt: "alt+1-4", desc: "pane"}
+	specPageUp   = spec{scope: ScopeGlobal, keys: []string{"pgup"}, label: "pgup/pgdown", desc: "scroll"}
+	specPageDown = spec{scope: ScopeGlobal, keys: []string{"pgdown"}}
+	specSubmit   = spec{scope: ScopeInput, keys: []string{"enter"}, label: "enter", desc: "run"}
+	// Select is Submit's counterpart for a pane. Submit is ScopeInput, so without this
+	// nothing at all responded to enter while a pane held the keyboard, and a highlighted row
+	// could not be chosen.
+	specSelect      = spec{scope: ScopePane, keys: []string{"enter"}, label: "enter", desc: "select"}
 	specFocusPanes  = spec{scope: ScopePrompt, keys: []string{"shift+tab"}, label: "shift+tab", desc: "panes"}
 	specComplete    = spec{scope: ScopePrompt, keys: []string{"tab"}, label: "tab", desc: "complete"}
 	specHistoryPrev = spec{scope: ScopePrompt, keys: []string{"up"}, label: "up/down", desc: "history"}
@@ -237,6 +241,8 @@ type KeyMap struct {
 
 	// Text entry, at the prompt or in a modal form.
 	Submit key.Binding
+	// Select activates the highlighted row of a pane.
+	Select key.Binding
 
 	// The prompt only.
 	FocusPanes  key.Binding
@@ -294,6 +300,7 @@ func keyMapFor(f Focus) KeyMap {
 		PageDown: specPageDown.resolve(f),
 
 		Submit: specSubmit.resolve(f),
+		Select: specSelect.resolve(f),
 
 		FocusPanes:  specFocusPanes.resolve(f),
 		Complete:    specComplete.resolve(f),
diff --git a/plc4go/tools/plc4xbrowser/browser/command.go b/plc4go/tools/plc4xbrowser/browser/command.go
new file mode 100644
index 00000000..93b440b3
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/command.go
@@ -0,0 +1,364 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"context"
+	"slices"
+	"strings"
+	"time"
+
+	"github.com/apache/plc4x/plc4go/spi/errors"
+	"github.com/rs/zerolog"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession"
+)
+
+// The command layer.
+//
+// Nothing here knows that a terminal exists. A command is data: a name, a description, an
+// optional argument summary, subcommands, a completion function and a Run function that takes
+// a context and an [Env] and returns a [Result]. The previous implementation instead had each
+// action write into package-level tview widgets, which is why not one of its commands could be
+// tested and why four of them were never finished at all.
+//
+// Result is a value rather than a set of callbacks for the same reason: the model applies it,
+// so a test can run every command against a plcsession.Demo and assert on what came back
+// without constructing a user interface.
+
+// Operation is the kind of request the composer is opened for.
+type Operation string
+
+// The operations the request composer can be opened for. These are the names of the four
+// commands that the previous implementation advertised but answered with "mode switch not yet
+// implemented".
+const (
+	OperationRead      Operation = "read"
+	OperationWrite     Operation = "write"
+	OperationBrowse    Operation = "browse"
+	OperationSubscribe Operation = "subscribe"
+)
+
+// ClearTarget is a set of outputs to empty, as a bit set so that the bare `clear` command can
+// name all of them at once.
+type ClearTarget uint8
+
+// The clearable outputs. Messages is the message table and its detail; Console is the log
+// pane, which is where both the log and command output land.
+const (
+	ClearMessages ClearTarget = 1 << iota
+	ClearConsole
+	ClearCommands
+)
+
+// ClearAll is every output, which is what the bare `clear` command asks for.
+const ClearAll = ClearMessages | ClearConsole | ClearCommands
+
+// Has reports whether t includes target.
+func (t ClearTarget) Has(target ClearTarget) bool { return t&target != 0 }
+
+// ComposeSpec asks the model to open the request composer, pre-set for one operation.
+type ComposeSpec struct {
+	Operation Operation
+	// Connection is the connection to pre-select, empty when the user named none.
+	Connection string
+	// Tags pre-fills the tag rows; a nil slice means start with one empty row.
+	Tags []plcsession.TagSpec
+	// Query pre-fills the browse query.
+	Query string
+}
+
+// Stream is a live subscription handed back to the model to drain.
+//
+// The channel is drained by a tea.Cmd that reads one event and re-arms, never by a goroutine
+// that touches model state. Cancel stops the subscription; the model calls it when the
+// connection is closed, when the user aborts, and at shutdown.
+type Stream struct {
+	Connection string
+	// Label names the subscription in the UI, typically the tag address.
+	Label  string
+	Events <-chan plcsession.Event
+	Cancel context.CancelFunc
+}
+
+// Result is everything a command asks the user interface to do. The zero Result means "the
+// command succeeded and there is nothing to show", which is a legitimate outcome.
+type Result struct {
+	// Lines are console lines to append to the log pane.
+	Lines []string
+	// Events are entries for the message table.
+	Events []plcsession.Event
+	// Compose, when set, opens the request composer.
+	Compose *ComposeSpec
+	// Stream, when set, is a subscription for the model to drain.
+	Stream *Stream
+	// Quit asks the application to exit.
+	Quit bool
+	// Clear names outputs to empty.
+	Clear ClearTarget
+	// LogLevel, when set, is the new log level.
+	LogLevel *zerolog.Level
+	// ConnectionsChanged and DriversChanged tell the sidebar to re-read the session.
+	ConnectionsChanged bool
+	DriversChanged     bool
+}
+
+// Env is what a command is allowed to reach. It is deliberately small: a session, the
+// persisted settings, a clock and a way to obtain a context that outlives the command.
+type Env struct {
+	// Session is the PLC seam. Never nil.
+	Session plcsession.Session
+	// Config is the persisted settings, which the connect history and the auto-register list
+	// live in. Never nil.
+	Config *Config
+	// Now supplies timestamps, injected so that tests can pin them.
+	Now func() time.Time
+	// StreamContext returns a context that outlives the command that created it, for a
+	// subscription. A command's own context is cancelled when the command returns, so a
+	// subscription started on it would die immediately.
+	StreamContext func() (context.Context, context.CancelFunc)
+	// Registry is the command tree, so that `help` can enumerate it without a global.
+	Registry *Registry
+	// Debug mirrors the old plc4xbrowser-debug switch: the browser's own debug logging.
+	Debug bool
+	// Demo records whether the session is the simulated one, so that suggestions and error
+	// messages can name something the user can actually reach.
+	Demo bool
+}
+
+// Command is one node of the command tree.
+type Command struct {
+	// Name is the word that selects this command.
+	Name string
+	// Description is the one-line help text.
+	Description string
+	// Args summarises the arguments, for the help listing.
+	Args string
+	// Sub are the subcommands.
+	Sub []*Command
+	// Run executes the command. A nil Run means the command only groups subcommands.
+	Run func(ctx context.Context, env *Env, args string) (Result, error)
+	// Suggest returns completion candidates for the argument text typed so far. The candidates
+	// are argument text, not whole lines: the registry prepends the command path.
+	Suggest func(env *Env, partial string) []string
+}
+
+// Registry is a resolved command tree.
+type Registry struct {
+	root *Command
+}
+
+// NewRegistry builds a registry over a root command whose Sub are the top-level commands.
+func NewRegistry(root *Command) *Registry { return &Registry{root: root} }
+
+// Root exposes the root command, for the help listing and for tests.
+func (r *Registry) Root() *Command { return r.root }
+
+// Names lists the top-level command names in tree order.
+func (r *Registry) Names() []string {
+	names := make([]string, 0, len(r.root.Sub))
+	for _, command := range r.root.Sub {
+		names = append(names, command.Name)
+	}
+	return names
+}
+
+// Resolve finds the deepest command the line names, and returns it with the path that was
+// consumed and the argument text that was not.
+//
+// A word is only consumed as a command name when it is followed by a space. That is what keeps
+// `read` and `read-direct` apart: on the line "read-direct x" the word "read" is not a
+// candidate, because the line does not start with "read ". The previous implementation needed
+// a special case for exactly this (acceptsCurrentText refused a match when the text continued
+// with a hyphen), which broke for any command name that was a prefix of another with a
+// different separator.
+func (r *Registry) Resolve(line string) (command *Command, path string, args string) {
+	command = r.root
+	rest := strings.TrimLeft(line, " ")
+	var consumed []string
+
+	for {
+		child, remainder, ok := matchChild(command, rest)
+		if !ok {
+			break
+		}
+		consumed = append(consumed, child.Name)
+		command = child
+		rest = remainder
+	}
+	return command, strings.Join(consumed, " "), strings.TrimSpace(rest)
+}
+
+// matchChild consumes a leading child-command name from rest.
+//
+// Only a complete word counts, and only one that is followed by more text: "log" on its own is
+// still being typed, so it is left for the completion of the word rather than treated as a
+// resolved command with no arguments. Execute puts the word back before dispatching.
+func matchChild(parent *Command, rest string) (*Command, string, bool) {
+	for _, child := range parent.Sub {
+		if after, found := strings.CutPrefix(rest, child.Name+" "); found {
+			return child, strings.TrimLeft(after, " "), true
+		}
+	}
+	return nil, "", false
+}
+
+// Execute runs the command a line names.
+//
+// The line is resolved first, so an unknown word is reported as an unknown command rather than
+// being handed to some parent as an argument.
+func (r *Registry) Execute(ctx context.Context, env *Env, line string) (Result, error) {
+	line = strings.TrimSpace(line)
+	if line == "" {
+		return Result{}, nil
+	}
+
+	command, path, args := r.Resolve(line)
+	// Resolve leaves a trailing bare word unconsumed so that completion can still work on it.
+	// Executing has no such need, so a remaining word that names a child is taken now.
+	if child := exactChild(command, args); child != nil {
+		command, args = child, ""
+		path = strings.TrimSpace(path + " " + child.Name)
+	}
+
+	if command == r.root {
+		return Result{}, errors.Errorf("unknown command %q, try help", firstWord(line))
+	}
+	if command.Run == nil {
+		return Result{}, errors.Errorf("%s needs one of: %s", path, strings.Join(childNames(command), ", "))
+	}
+	if args != "" && len(command.Sub) > 0 && exactChild(command, firstWord(args)) == nil && command.Suggest == nil {
+		// A grouping command that also runs, such as `clear`, only accepts its own
+		// subcommands; anything else is a typo worth naming.
+		return Result{}, errors.Errorf("%s does not understand %q, try one of: %s",
+			path, args, strings.Join(childNames(command), ", "))
+	}
+	return command.Run(ctx, env, args)
+}
+
+// exactChild returns the child of command whose name is exactly name.
+func exactChild(command *Command, name string) *Command {
+	for _, child := range command.Sub {
+		if child.Name == name {
+			return child
+		}
+	}
+	return nil
+}
+
+// childNames lists a command's subcommand names.
+func childNames(command *Command) []string {
+	names := make([]string, 0, len(command.Sub))
+	for _, child := range command.Sub {
+		names = append(names, child.Name)
+	}
+	return names
+}
+
+// firstWord returns the first space-separated word of s.
+func firstWord(s string) string {
+	if before, _, ok := strings.Cut(s, " "); ok {
+		return before
+	}
+	return s
+}
+
+// Complete returns whole replacement lines for the text typed so far.
+//
+// Whole lines, not fragments: [tui.Prompt] hands the candidates to bubbles/textinput, which
+// matches a candidate by testing it as a case-insensitive prefix of the ENTIRE field value. A
+// candidate of "temp/1" for the line "read-direct demo://plant-1 te" would match nothing.
+//
+// An empty line returns nothing. Offering the whole command list before a single key has been
+// pressed pushed a popup over the panes on every start, which the previous implementation
+// avoided in the same way.
+func (r *Registry) Complete(env *Env, line string) []string {
+	if strings.TrimSpace(line) == "" {
+		return nil
+	}
+
+	command, path, args := r.Resolve(line)
+	prefix := ""
+	if path != "" {
+		prefix = path + " "
+	}
+
+	var candidates []string
+	// Subcommands whose name continues the word being typed.
+	partialWord := firstWord(args)
+	for _, child := range command.Sub {
+		if strings.HasPrefix(child.Name, partialWord) {
+			candidates = append(candidates, prefix+child.Name)
+		}
+	}
+	// Argument suggestions for the command itself, and for a subcommand whose name is complete
+	// but which Resolve left unconsumed because no space follows it yet.
+	candidates = append(candidates, suggestionsFor(command, env, prefix, args)...)
+	if child := exactChild(command, partialWord); child != nil {
+		candidates = append(candidates, suggestionsFor(child, env, prefix+child.Name+" ", "")...)
+	}
+
+	// Only candidates that continue what the user has already typed are useful, since anything
+	// else is filtered out by textinput anyway and merely inflates the "n more" counter.
+	lowerLine := strings.ToLower(line)
+	out := make([]string, 0, len(candidates))
+	for _, candidate := range candidates {
+		if !strings.HasPrefix(strings.ToLower(candidate), lowerLine) {
+			continue
+		}
+		if slices.Contains(out, candidate) {
+			continue
+		}
+		out = append(out, candidate)
+	}
+	return out
+}
+
+// suggestionsFor collects one command's argument suggestions, prefixed with its path.
+func suggestionsFor(command *Command, env *Env, prefix, partial string) []string {
+	if command.Run == nil || command.Suggest == nil {
+		return nil
+	}
+	suggestions := command.Suggest(env, partial)
+	out := make([]string, 0, len(suggestions))
+	for _, suggestion := range suggestions {
+		out = append(out, prefix+suggestion)
+	}
+	return out
+}
+
+// Walk visits every command depth-first, deepest last, with its indent level and full path.
+// The root itself is not visited: it has no name a user could type.
+func (r *Registry) Walk(visit func(depth int, path string, command *Command)) {
+	var recurse func(depth int, path string, command *Command)
+	recurse = func(depth int, path string, command *Command) {
+		if command != r.root {
+			visit(depth, path, command)
+		}
+		for _, child := range command.Sub {
+			childPath := child.Name
+			if command != r.root {
+				childPath = path + " " + child.Name
+			}
+			recurse(depth+1, childPath, child)
+		}
+	}
+	recurse(-1, "", r.root)
+}
diff --git a/plc4go/tools/plc4xbrowser/browser/commands.go b/plc4go/tools/plc4xbrowser/browser/commands.go
new file mode 100644
index 00000000..acf494da
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/commands.go
@@ -0,0 +1,624 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/apache/plc4x/plc4go/spi/errors"
+	"github.com/rs/zerolog"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession"
+)
+
+// The command tree.
+//
+// Every command the previous implementation advertised is here, with the same name and the
+// same meaning, because a user's muscle memory and their saved command history both survive
+// the port. Four of them - read, write, browse and subscribe - are new work rather than a
+// port: they existed only as an error message, returning "mode switch not yet implemented",
+// so the tool's headline capability had never actually shipped.
+
+// NewCommands builds the command tree.
+func NewCommands() *Registry {
+	root := &Command{Name: rootName}
+	root.Sub = []*Command{
+		discoverCommand(),
+		connectCommand(),
+		disconnectCommand(),
+		registerCommand(),
+
+		composeCommand(OperationRead, "read", "Compose a read request against a connection"),
+		composeCommand(OperationWrite, "write", "Compose a write request against a connection"),
+		composeCommand(OperationBrowse, "browse", "Compose a browse request against a connection"),
+		composeCommand(OperationSubscribe, "subscribe", "Compose a subscription against a connection"),
+
+		readDirectCommand(),
+		writeDirectCommand(),
+		browseDirectCommand(),
+		subscribeDirectCommand(),
+
+		logCommand(),
+		historyCommand(),
+		clearCommand(),
+		helpCommand(),
+		quitCommand(),
+	}
+	return NewRegistry(root)
+}
+
+// rootName labels the synthetic root of the tree.
+const rootName = "plc4xbrowser"
+
+// discoverCommand searches for devices speaking a protocol.
+func discoverCommand() *Command {
+	return &Command{
+		Name:        "discover",
+		Description: "Discover devices speaking a protocol",
+		Args:        "[protocol]",
+		Suggest: func(env *Env, partial string) []string {
+			return prefixed(env.Session.Protocols(), partial)
+		},
+		Run: func(ctx context.Context, env *Env, args string) (Result, error) {
+			protocol := strings.TrimSpace(args)
+			if protocol == "" {
+				return Result{}, errors.Errorf("discover needs a protocol, one of %s",
+					strings.Join(env.Session.Protocols(), ", "))
+			}
+			var result Result
+			err := env.Session.Discover(ctx, protocol, func(item plcsession.DiscoveryItem) {
+				result.Lines = append(result.Lines,
+					fmt.Sprintf("found %s (%s)", item.ConnectionString, item.Name))
+				result.Events = append(result.Events, plcsession.Event{
+					Kind:       plcsession.EventDiscover,
+					Connection: item.ConnectionString,
+					Received:   env.Now(),
+					Summary:    item.Name,
+				})
+			})
+			if err != nil {
+				return Result{}, err
+			}
+			if len(result.Events) == 0 {
+				result.Lines = append(result.Lines, "no devices found")
+			}
+			return result, nil
+		},
+	}
+}
+
+// connectCommand opens a connection.
+func connectCommand() *Command {
+	return &Command{
+		Name:        "connect",
+		Description: "Connect to a device",
+		Args:        "[connection string]",
+		Suggest:     suggestConnectTargets,
+		Run: func(ctx context.Context, env *Env, args string) (Result, error) {
+			connectionString := strings.TrimSpace(args)
+			if connectionString == "" {
+				return Result{}, errors.New("connect needs a connection string")
+			}
+			info, err := env.Session.Connect(ctx, connectionString)
+			if err != nil {
+				return Result{}, err
+			}
+			// Remember the host so the next session can suggest it, as the old tool did.
+			if parsed, parseErr := url.Parse(connectionString); parseErr == nil && parsed.Host != "" {
+				env.Config.AddHost(parsed.Host)
+			}
+			return Result{
+				Lines:              []string{"connected " + info.ID},
+				ConnectionsChanged: true,
+			}, nil
+		},
+	}
+}
+
+// disconnectCommand closes a connection.
+func disconnectCommand() *Command {
+	return &Command{
+		Name:        "disconnect",
+		Description: "Disconnect a connection",
+		Args:        "[connection]",
+		Suggest:     suggestConnections,
+		Run: func(_ context.Context, env *Env, args string) (Result, error) {
+			id := strings.TrimSpace(args)
+			if id == "" {
+				return Result{}, errors.New("disconnect needs a connection")
+			}
+			if err := env.Session.Disconnect(id); err != nil {
+				return Result{}, err
+			}
+			return Result{
+				Lines:              []string{"disconnected " + id},
+				ConnectionsChanged: true,
+			}, nil
+		},
+	}
+}
+
+// registerCommand registers a protocol driver.
+func registerCommand() *Command {
+	return &Command{
+		Name:        "register",
+		Description: "Register a protocol driver",
+		Args:        "[protocol]",
+		Suggest: func(env *Env, partial string) []string {
+			return prefixed(env.Session.Protocols(), partial)
+		},
+		Run: func(_ context.Context, env *Env, args string) (Result, error) {
+			protocol := strings.TrimSpace(args)
+			if protocol == "" {
+				return Result{}, errors.Errorf("register needs a protocol, one of %s",
+					strings.Join(env.Session.Protocols(), ", "))
+			}
+			info, err := env.Session.RegisterDriver(protocol)
+			if err != nil {
+				return Result{}, err
+			}
+			return Result{
+				Lines:          []string{"registered " + info.Code + " (" + info.Name + ")"},
+				DriversChanged: true,
+			}, nil
+		},
+	}
+}
+
+// composeCommand opens the request composer for one operation.
+//
+// These four are the commands the previous implementation never finished. Rather than
+// executing immediately, they hand the model a ComposeSpec: a read or a write is a multi-tag
+// request, and typing several tag/address pairs on one line is exactly the interface the
+// -direct variants already provide badly.
+func composeCommand(operation Operation, name, description string) *Command {
+	return &Command{
+		Name:        name,
+		Description: description,
+		Args:        "[connection] [tag address...]",
+		Suggest:     suggestConnectionThenTag,
+		Run: func(_ context.Context, env *Env, args string) (Result, error) {
+			connection, rest := splitFirst(strings.TrimSpace(args))
+			if connection != "" {
+				if err := requireConnected(env, connection); err != nil {
+					return Result{}, err
+				}
+			}
+			spec := &ComposeSpec{Operation: operation, Connection: connection}
+			if operation == OperationBrowse {
+				spec.Query = strings.TrimSpace(rest)
+			} else {
+				for i, address := range strings.Fields(rest) {
+					spec.Tags = append(spec.Tags, plcsession.TagSpec{
+						Name:    "tag" + strconv.Itoa(i+1),
+						Address: address,
+					})
+				}
+			}
+			return Result{Compose: spec}, nil
+		},
+	}
+}
+
+// readDirectCommand reads one tag without opening the composer.
+func readDirectCommand() *Command {
+	return &Command{
+		Name:        "read-direct",
+		Description: "Read one tag from a connection",
+		Args:        "[connection] [tag address]",
+		Suggest:     suggestConnectionThenTag,
+		Run: func(ctx context.Context, env *Env, args string) (Result, error) {
+			connection, address, err := connectionAndAddress(env, args, "read-direct")
+			if err != nil {
+				return Result{}, err
+			}
+			read, err := env.Session.Read(ctx, connection, []plcsession.TagSpec{
+				{Name: "readTag", Address: address},
+			})
+			if err != nil {
+				return Result{}, err
+			}
+			return resultForTags(env, plcsession.EventRead, connection, read.Tags, read.Duration), nil
+		},
+	}
+}
+
+// writeDirectCommand writes one tag without opening the composer.
+func writeDirectCommand() *Command {
+	return &Command{
+		Name:        "write-direct",
+		Description: "Write one tag on a connection",
+		Args:        "[connection] [tag address] [value]",
+		Suggest:     suggestConnectionThenTag,
+		Run: func(ctx context.Context, env *Env, args string) (Result, error) {
+			fields := strings.Fields(strings.TrimSpace(args))
+			if len(fields) < 3 {
+				return Result{}, errors.New("write-direct needs a connection, a tag address and a value")
+			}
+			connection, address, value := fields[0], fields[1], strings.Join(fields[2:], " ")
+			if err := requireConnected(env, connection); err != nil {
+				return Result{}, err
+			}
+			write, err := env.Session.Write(ctx, connection, []plcsession.TagSpec{
+				{Name: "writeTag", Address: address, Value: value},
+			})
+			if err != nil {
+				return Result{}, err
+			}
+			return resultForTags(env, plcsession.EventWrite, connection, write.Tags, write.Duration), nil
+		},
+	}
+}
+
+// browseDirectCommand browses a connection without opening the composer.
+func browseDirectCommand() *Command {
+	return &Command{
+		Name:        "browse-direct",
+		Description: "Browse the tags a connection exposes",
+		Args:        "[connection] [query]",
+		Suggest:     suggestConnections,
+		Run: func(ctx context.Context, env *Env, args string) (Result, error) {
+			connection, query := splitFirst(strings.TrimSpace(args))
+			if connection == "" {
+				return Result{}, errors.New("browse-direct needs a connection")
+			}
+			if err := requireConnected(env, connection); err != nil {
+				return Result{}, err
+			}
+			browse, err := env.Session.Browse(ctx, connection, strings.TrimSpace(query))
+			if err != nil {
+				return Result{}, err
+			}
+			result := Result{Lines: []string{
+				fmt.Sprintf("browse %s found %d tags in %s", connection, len(browse.Items), browse.Duration),
+			}}
+			for _, item := range browse.Items {
+				result.Events = append(result.Events, plcsession.Event{
+					Kind:       plcsession.EventBrowse,
+					Connection: connection,
+					Received:   env.Now(),
+					Summary:    browseSummary(item),
+					Tags: []plcsession.TagResult{{
+						Name:     item.Name,
+						Address:  item.Address,
+						DataType: item.DataType,
+						Code:     plcsession.ResponseCodeOK,
+					}},
+				})
+			}
+			if len(browse.Items) == 0 {
+				result.Lines = append(result.Lines, "no tags matched")
+			}
+			return result, nil
+		},
+	}
+}
+
+// subscribeDirectCommand subscribes to one tag without opening the composer.
+func subscribeDirectCommand() *Command {
+	return &Command{
+		Name:        "subscribe-direct",
+		Description: "Subscribe to one tag on a connection",
+		Args:        "[connection] [tag address]",
+		Suggest:     suggestConnectionThenTag,
+		Run: func(_ context.Context, env *Env, args string) (Result, error) {
+			connection, address, err := connectionAndAddress(env, args, "subscribe-direct")
+			if err != nil {
+				return Result{}, err
+			}
+			// A subscription outlives the command that started it, so it needs a context that
+			// is not the command's own; the command's is cancelled the moment it returns.
+			streamCtx, cancel := env.StreamContext()
+			events, err := env.Session.Subscribe(streamCtx, connection, []plcsession.TagSpec{
+				{Name: "subscriptionTag", Address: address},
+			})
+			if err != nil {
+				cancel()
+				return Result{}, err
+			}
+			return Result{
+				Lines: []string{"subscribed " + connection + " " + address},
+				Stream: &Stream{
+					Connection: connection,
+					Label:      address,
+					Events:     events,
+					Cancel:     cancel,
+				},
+			}, nil
+		},
+	}
+}
+
+// logCommand groups the log-related subcommands.
+func logCommand() *Command {
+	return &Command{
+		Name:        "log",
+		Description: "Log related operations",
+		Sub: []*Command{
+			{
+				Name:        "level",
+				Description: "Set the log level",
+				Args:        "[level]",
+				Suggest: func(_ *Env, partial string) []string {
+					return prefixed(logLevelNames(), partial)
+				},
+				Run: func(_ context.Context, env *Env, args string) (Result, error) {
+					name := strings.TrimSpace(args)
+					level, err := zerolog.ParseLevel(name)
+					if err != nil {
+						return Result{}, errors.Errorf("unknown log level %q, one of %s",
+							name, strings.Join(logLevelNames(), ", "))
+					}
+					env.Config.LogLevel = level.String()
+					return Result{
+						Lines:    []string{"log level " + level.String()},
+						LogLevel: &level,
+					}, nil
+				},
+			},
+			{
+				Name:        "clear",
+				Description: "Clear the log pane",
+				Run: func(_ context.Context, _ *Env, _ string) (Result, error) {
+					return Result{Clear: ClearConsole}, nil
+				},
+			},
+		},
+	}
+}
+
+// historyCommand shows the recent command history.
+func historyCommand() *Command {
+	return &Command{
+		Name:        "history",
+		Description: "Show the recent command history",
+		Run: func(_ context.Context, env *Env, _ string) (Result, error) {
+			recent := env.Config.History.Last10Commands
+			if len(recent) == 0 {
+				return Result{Lines: []string{"no commands yet"}}, nil
+			}
+			lines := make([]string, 0, len(recent)+1)
+			lines = append(lines, "recent commands:")
+			for i, command := range recent {
+				lines = append(lines, fmt.Sprintf("  %d: %s", i, command))
+			}
+			return Result{Lines: lines}, nil
+		},
+	}
+}
+
+// clearCommand empties the output panes.
+func clearCommand() *Command {
+	return &Command{
+		Name:        "clear",
+		Description: "Clear the output panes",
+		Args:        "[messages|console|all]",
+		Suggest: func(_ *Env, partial string) []string {
+			return prefixed([]string{"messages", "console", "all"}, partial)
+		},
+		Run: func(_ context.Context, _ *Env, args string) (Result, error) {
+			switch strings.TrimSpace(args) {
+			case "", "all":
+				return Result{Clear: ClearAll}, nil
+			case "messages":
+				return Result{Clear: ClearMessages}, nil
+			case "console":
+				return Result{Clear: ClearConsole}, nil
+			default:
+				return Result{}, errors.Errorf("clear takes messages, console or all")
+			}
+		},
+	}
+}
+
+// helpCommand lists the command tree.
+func helpCommand() *Command {
+	return &Command{
+		Name:        "help",
+		Description: "List the available commands",
+		Run: func(_ context.Context, env *Env, _ string) (Result, error) {
+			var lines []string
+			// Walk skips the root by identity, so every command it visits belongs in the
+			// listing; depth 0 is a top-level command, not the root.
+			env.Registry.Walk(func(depth int, path string, command *Command) {
+				entry := strings.Repeat("  ", depth) + path
+				if command.Args != "" {
+					entry += " " + command.Args
+				}
+				if command.Description != "" {
+					entry += " — " + command.Description
+				}
+				lines = append(lines, entry)
+			})
+			return Result{Lines: lines}, nil
+		},
+	}
+}
+
+// quitCommand exits the application.
+func quitCommand() *Command {
+	return &Command{
+		Name:        "quit",
+		Description: "Quit the application",
+		Run: func(_ context.Context, _ *Env, _ string) (Result, error) {
+			return Result{Quit: true}, nil
+		},
+	}
+}
+
+// --- shared helpers ---
+
+// requireConnected reports a useful error when a command names a connection that is not open.
+func requireConnected(env *Env, id string) error {
+	for _, connection := range env.Session.Connections() {
+		if connection.ID == id {
+			return nil
+		}
+	}
+	open := connectionIDs(env)
+	if len(open) == 0 {
+		return errors.Errorf("%s is not connected, and nothing else is either; connect first", id)
+	}
+	return errors.Errorf("%s is not connected; open connections are %s", id, strings.Join(open, ", "))
+}
+
+// connectionAndAddress parses the "[connection] [address]" argument shape the -direct commands
+// share, reporting which command complained so the message is actionable.
+func connectionAndAddress(env *Env, args, command string) (connection, address string, err error) {
+	fields := strings.Fields(strings.TrimSpace(args))
+	if len(fields) != 2 {
+		return "", "", errors.Errorf("%s needs exactly two arguments: a connection and a tag address", command)
+	}
+	if err := requireConnected(env, fields[0]); err != nil {
+		return "", "", err
+	}
+	return fields[0], fields[1], nil
+}
+
+// resultForTags turns a read or write outcome into console lines and a message-table entry.
+func resultForTags(env *Env, kind plcsession.EventKind, connection string, tags []plcsession.TagResult, duration time.Duration) Result {
+	failed := 0
+	for _, tag := range tags {
+		if !tag.Succeeded() {
+			failed++
+		}
+	}
+	summary := fmt.Sprintf("%s %s: %d tags in %s", kind, connection, len(tags), duration)
+	if failed > 0 {
+		summary += fmt.Sprintf(", %d failed", failed)
+	}
+	return Result{
+		Lines: []string{summary},
+		Events: []plcsession.Event{{
+			Kind:       kind,
+			Connection: connection,
+			Received:   env.Now(),
+			Tags:       tags,
+			Summary:    summary,
+		}},
+	}
+}
+
+// browseSummary renders one browse item for the message list.
+func browseSummary(item plcsession.BrowseItem) string {
+	var access strings.Builder
+	for _, flag := range []struct {
+		enabled bool
+		letter  string
+	}{{item.Readable, "r"}, {item.Writable, "w"}, {item.Subscribable, "s"}} {
+		if flag.enabled {
+			access.WriteString(flag.letter)
+		} else {
+			access.WriteString("-")
+		}
+	}
+	return fmt.Sprintf("%s %s %s %s", item.Address, item.DataType, access.String(), item.Name)
+}
+
+// suggestConnections offers the open connections.
+func suggestConnections(env *Env, partial string) []string {
+	return prefixed(connectionIDs(env), partial)
+}
+
+// suggestConnectTargets offers protocols, and the hosts this user connected to before.
+//
+// The recalled hosts are what makes the prompt worth using on a second run: the old tool kept
+// the same history and this port keeps reading it.
+func suggestConnectTargets(env *Env, partial string) []string {
+	var candidates []string
+	for _, protocol := range env.Session.Protocols() {
+		candidates = append(candidates, protocol+"://")
+		for _, host := range env.Config.History.Last10Hosts {
+			candidates = append(candidates, protocol+"://"+host)
+		}
+	}
+	if env.Demo {
+		// In demo mode the only reachable devices are the simulated ones, so offer those first
+		// rather than a protocol prefix that cannot connect to anything.
+		candidates = append([]string{plcsession.DemoDeviceOne, plcsession.DemoDeviceTwo}, candidates...)
+	}
+	return prefixed(candidates, partial)
+}
+
+// suggestConnectionThenTag offers connections until one has been typed, then tag addresses.
+func suggestConnectionThenTag(env *Env, partial string) []string {
+	connection, rest := splitFirst(partial)
+	if rest == "" && !strings.HasSuffix(partial, " ") {
+		return prefixed(connectionIDs(env), partial)
+	}
+	// A tag address is being typed. In demo mode the catalogue is known, so offer it; against a
+	// real device the addresses are protocol-specific and browsing is how you find them.
+	var addresses []string
+	if env.Demo {
+		addresses = plcsession.DemoTagAddresses()
+	}
+	out := make([]string, 0, len(addresses))
+	for _, address := range addresses {
+		candidate := connection + " " + address
+		if strings.HasPrefix(candidate, partial) {
+			out = append(out, candidate)
+		}
+	}
+	return out
+}
+
+// connectionIDs lists the open connection identifiers.
+func connectionIDs(env *Env) []string {
+	connections := env.Session.Connections()
+	ids := make([]string, 0, len(connections))
+	for _, connection := range connections {
+		ids = append(ids, connection.ID)
+	}
+	return ids
+}
+
+// prefixed filters candidates by prefix, which is the matching bubbles/textinput performs.
+func prefixed(candidates []string, partial string) []string {
+	if partial == "" {
+		return candidates
+	}
+	out := make([]string, 0, len(candidates))
+	for _, candidate := range candidates {
+		if strings.HasPrefix(candidate, partial) {
+			out = append(out, candidate)
+		}
+	}
+	return out
+}
+
+// splitFirst splits off the first whitespace-separated word.
+func splitFirst(s string) (first, rest string) {
+	trimmed := strings.TrimLeft(s, " ")
+	index := strings.IndexAny(trimmed, " \t")
+	if index < 0 {
+		return trimmed, ""
+	}
+	return trimmed[:index], strings.TrimLeft(trimmed[index:], " ")
+}
+
+// logLevelNames lists the levels `log level` accepts.
+func logLevelNames() []string {
+	return []string{"trace", "debug", "info", "warn", "error", "fatal", "panic", "disabled"}
+}
diff --git a/plc4go/tools/plc4xbrowser/browser/commands_test.go b/plc4go/tools/plc4xbrowser/browser/commands_test.go
new file mode 100644
index 00000000..be2d2f1f
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/commands_test.go
@@ -0,0 +1,396 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession"
+)
+
+// The command layer is tested against the demo session rather than a bespoke mock: it is the
+// same implementation --demo runs on, so these tests exercise the path a user actually takes.
+
+// testEnv builds an environment over a demo session, connected to the first demo device.
+func testEnv(t *testing.T) (*Env, *Registry) {
+	t.Helper()
+
+	session := plcsession.NewDemo(plcsession.DemoOptions{
+		SubscriptionInterval: time.Millisecond,
+		Now:                  steppingClock(),
+	})
+	t.Cleanup(func() { _ = session.Close() })
+	_, err := session.RegisterDriver(plcsession.DemoProtocol)
+	require.NoError(t, err)
+	_, err = session.Connect(t.Context(), plcsession.DemoDeviceOne)
+	require.NoError(t, err)
+
+	config := NewConfig()
+	registry := NewCommands()
+	env := &Env{
+		Session:  session,
+		Config:   &config,
+		Now:      steppingClock(),
+		Registry: registry,
+		Demo:     true,
+		StreamContext: func() (context.Context, context.CancelFunc) {
+			return context.WithCancel(context.Background())
+		},
+	}
+	return env, registry
+}
+
+// steppingClock returns timestamps that advance, so ordering is observable without wall time.
+func steppingClock() func() time.Time {
+	base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+	step := 0
+	return func() time.Time {
+		step++
+		return base.Add(time.Duration(step) * time.Second)
+	}
+}
+
+// TestEveryAdvertisedCommandIsReachable guards the inventory. The port had to preserve every
+// command the previous tool offered, and a command that the registry cannot resolve is a
+// command the user's saved history can no longer run.
+func TestEveryAdvertisedCommandIsReachable(t *testing.T) {
+	_, registry := testEnv(t)
+	for _, name := range []string{
+		"discover", "connect", "disconnect", "register",
+		"read", "write", "browse", "subscribe",
+		"read-direct", "write-direct", "browse-direct", "subscribe-direct",
+		"log level", "log clear",
+		"history", "clear", "help", "quit",
+	} {
+		t.Run(name, func(t *testing.T) {
+			// A trailing space matters: Resolve deliberately leaves a bare final word
+			// unconsumed, because a word still being typed belongs to completion rather than
+			// to dispatch. That is what keeps "read" and "read-direct" apart.
+			command, path, _ := registry.Resolve(name + " ")
+			require.NotNil(t, command, "%q must resolve to a command", name)
+			assert.Equal(t, name, strings.TrimSpace(path))
+			assert.NotNil(t, command.Run, "%q must be runnable, not just a grouping node", name)
+		})
+	}
+}
+
+// TestLogIsAGroupingCommand documents the one node that is deliberately not runnable.
+func TestLogIsAGroupingCommand(t *testing.T) {
+	_, registry := testEnv(t)
+	command, path, _ := registry.Resolve("log ")
+	require.NotNil(t, command)
+	assert.Equal(t, "log", path)
+	assert.Nil(t, command.Run, "log only groups its subcommands")
+	assert.NotEmpty(t, command.Sub)
+}
+
+func TestEveryRunnableCommandHasHelpText(t *testing.T) {
+	_, registry := testEnv(t)
+	registry.Walk(func(depth int, path string, command *Command) {
+		if depth == 0 {
+			return
+		}
+		assert.NotEmpty(t, command.Description, "%s needs a description; it appears in help", path)
+	})
+}
+
+// TestTheFourPreviouslyUnimplementedCommandsNowDoSomething is the regression test for the
+// headline defect: read, write, browse and subscribe each answered "mode switch not yet
+// implemented" and did nothing at all.
+func TestTheFourPreviouslyUnimplementedCommandsNowDoSomething(t *testing.T) {
+	for _, name := range []string{"read", "write", "browse", "subscribe"} {
+		t.Run(name, func(t *testing.T) {
+			env, registry := testEnv(t)
+			result, err := registry.Execute(t.Context(), env, name+" "+plcsession.DemoDeviceOne)
+			require.NoError(t, err, "%s must not fail", name)
+			require.NotNil(t, result.Compose, "%s must open the request composer", name)
+			assert.Equal(t, Operation(name), result.Compose.Operation)
+			assert.Equal(t, plcsession.DemoDeviceOne, result.Compose.Connection)
+
+			// The old implementation's error text must be gone entirely.
+			assert.NotContains(t, strings.Join(result.Lines, " "), "not yet implemented")
+		})
+	}
+}
+
+func TestComposeCommandsRejectAnUnknownConnection(t *testing.T) {
+	env, registry := testEnv(t)
+	_, err := registry.Execute(t.Context(), env, "read demo://nowhere")
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "not connected")
+}
+
+func TestComposeCommandPrefillsTagsFromTheCommandLine(t *testing.T) {
+	env, registry := testEnv(t)
+	result, err := registry.Execute(t.Context(), env, "read "+plcsession.DemoDeviceOne+" temp/1 press/1")
+	require.NoError(t, err)
+	require.NotNil(t, result.Compose)
+	require.Len(t, result.Compose.Tags, 2)
+	assert.Equal(t, "temp/1", result.Compose.Tags[0].Address)
+	assert.Equal(t, "press/1", result.Compose.Tags[1].Address)
+}
+
+func TestBrowseComposePrefillsTheQueryRatherThanTags(t *testing.T) {
+	env, registry := testEnv(t)
+	result, err := registry.Execute(t.Context(), env, "browse "+plcsession.DemoDeviceOne+" temp")
+	require.NoError(t, err)
+	require.NotNil(t, result.Compose)
+	assert.Equal(t, "temp", result.Compose.Query)
+	assert.Empty(t, result.Compose.Tags)
+}
+
+func TestReadDirectProducesAMessageEntry(t *testing.T) {
+	env, registry := testEnv(t)
+	result, err := registry.Execute(t.Context(), env, "read-direct "+plcsession.DemoDeviceOne+" temp/1")
+	require.NoError(t, err)
+	require.Len(t, result.Events, 1)
+
+	event := result.Events[0]
+	assert.Equal(t, plcsession.EventRead, event.Kind)
+	assert.Equal(t, plcsession.DemoDeviceOne, event.Connection)
+	require.Len(t, event.Tags, 1)
+	assert.True(t, event.Tags[0].Succeeded())
+	assert.NotEmpty(t, event.Tags[0].Value, "a read that shows no value is not worth showing")
+	assert.NotEmpty(t, result.Lines)
+}
+
+func TestWriteDirectIsObservableByAFollowingRead(t *testing.T) {
+	env, registry := testEnv(t)
+	_, err := registry.Execute(t.Context(), env, "write-direct "+plcsession.DemoDeviceOne+" motor/speed 1234")
+	require.NoError(t, err)
+
+	result, err := registry.Execute(t.Context(), env, "read-direct "+plcsession.DemoDeviceOne+" motor/speed")
+	require.NoError(t, err)
+	require.Len(t, result.Events, 1)
+	assert.Equal(t, "1234", result.Events[0].Tags[0].Value)
+}
+
+func TestBrowseDirectListsTags(t *testing.T) {
+	env, registry := testEnv(t)
+	result, err := registry.Execute(t.Context(), env, "browse-direct "+plcsession.DemoDeviceOne)
+	require.NoError(t, err)
+	assert.NotEmpty(t, result.Events, "browsing the demo device should find its catalogue")
+	assert.Contains(t, strings.Join(result.Lines, " "), "found")
+}
+
+func TestSubscribeDirectReturnsAStreamTheModelCanDrain(t *testing.T) {
+	env, registry := testEnv(t)
+	result, err := registry.Execute(t.Context(), env, "subscribe-direct "+plcsession.DemoDeviceOne+" temp/1")
+	require.NoError(t, err)
+	require.NotNil(t, result.Stream)
+	require.NotNil(t, result.Stream.Cancel)
+	t.Cleanup(result.Stream.Cancel)
+
+	select {
+	case event, ok := <-result.Stream.Events:
+		require.True(t, ok, "the stream closed before delivering anything")
+		assert.Equal(t, plcsession.EventSubscribe, event.Kind)
+	case <-time.After(2 * time.Second):
+		t.Fatal("the subscription delivered no event")
+	}
+
+	// Cancelling must close the channel: a stream nobody closes is a leak in the model.
+	result.Stream.Cancel()
+	deadline := time.After(2 * time.Second)
+	for {
+		select {
+		case _, ok := <-result.Stream.Events:
+			if !ok {
+				return
+			}
+		case <-deadline:
+			t.Fatal("cancelling the stream did not close it")
+		}
+	}
+}
+
+func TestDirectCommandsRejectTheWrongArgumentCount(t *testing.T) {
+	env, registry := testEnv(t)
+	for _, line := range []string{
+		"read-direct",
+		"read-direct " + plcsession.DemoDeviceOne,
+		"read-direct " + plcsession.DemoDeviceOne + " a b",
+		"write-direct " + plcsession.DemoDeviceOne + " motor/speed",
+		"subscribe-direct " + plcsession.DemoDeviceOne,
+	} {
+		t.Run(line, func(t *testing.T) {
+			_, err := registry.Execute(t.Context(), env, line)
+			assert.Error(t, err)
+		})
+	}
+}
+
+// TestNotConnectedErrorsNameTheOpenConnections keeps the failure actionable, rather than the
+// bare "%s not connected" the old tool produced.
+func TestNotConnectedErrorsNameTheOpenConnections(t *testing.T) {
+	env, registry := testEnv(t)
+	_, err := registry.Execute(t.Context(), env, "read-direct demo://nowhere temp/1")
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), plcsession.DemoDeviceOne, "the error should say what IS connected")
+}
+
+func TestConnectAndDisconnectUpdateTheSidebar(t *testing.T) {
+	env, registry := testEnv(t)
+
+	result, err := registry.Execute(t.Context(), env, "connect "+plcsession.DemoDeviceTwo)
+	require.NoError(t, err)
+	assert.True(t, result.ConnectionsChanged, "the sidebar must be told to re-read")
+
+	result, err = registry.Execute(t.Context(), env, "disconnect "+plcsession.DemoDeviceTwo)
+	require.NoError(t, err)
+	assert.True(t, result.ConnectionsChanged)
+}
+
+func TestConnectRemembersTheHostForNextTime(t *testing.T) {
+	env, registry := testEnv(t)
+	_, err := registry.Execute(t.Context(), env, "connect "+plcsession.DemoDeviceTwo)
+	require.NoError(t, err)
+	assert.Contains(t, env.Config.History.Last10Hosts, "plant-2",
+		"the connect history is what makes the prompt useful on a second run")
+}
+
+func TestRegisterReportsTheDriver(t *testing.T) {
+	env, registry := testEnv(t)
+	// The demo driver is already registered by the fixture, so registering again must complain.
+	_, err := registry.Execute(t.Context(), env, "register "+plcsession.DemoProtocol)
+	assert.Error(t, err, "registering twice should be reported rather than silently ignored")
+}
+
+func TestLogLevelSetsTheLevelAndPersistsIt(t *testing.T) {
+	env, registry := testEnv(t)
+	result, err := registry.Execute(t.Context(), env, "log level debug")
+	require.NoError(t, err)
+	require.NotNil(t, result.LogLevel)
+	assert.Equal(t, "debug", result.LogLevel.String())
+	assert.Equal(t, "debug", env.Config.LogLevel, "the level must survive into the saved config")
+}
+
+func TestLogLevelRejectsAnUnknownLevelAndSaysWhatIsValid(t *testing.T) {
+	env, registry := testEnv(t)
+	_, err := registry.Execute(t.Context(), env, "log level shouty")
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "debug")
+}
+
+func TestClearTargetsTheRightPanes(t *testing.T) {
+	env, registry := testEnv(t)
+	for line, want := range map[string]ClearTarget{
+		"clear":          ClearAll,
+		"clear all":      ClearAll,
+		"clear messages": ClearMessages,
+		"clear console":  ClearConsole,
+		"log clear":      ClearConsole,
+	} {
+		t.Run(line, func(t *testing.T) {
+			result, err := registry.Execute(t.Context(), env, line)
+			require.NoError(t, err)
+			assert.Equal(t, want, result.Clear)
+		})
+	}
+}
+
+func TestQuitAsksTheApplicationToExit(t *testing.T) {
+	env, registry := testEnv(t)
+	result, err := registry.Execute(t.Context(), env, "quit")
+	require.NoError(t, err)
+	assert.True(t, result.Quit)
+}
+
+func TestHelpListsEveryCommand(t *testing.T) {
+	env, registry := testEnv(t)
+	result, err := registry.Execute(t.Context(), env, "help")
+	require.NoError(t, err)
+	joined := strings.Join(result.Lines, "\n")
+	for _, name := range []string{"connect", "read-direct", "subscribe", "quit"} {
+		assert.Contains(t, joined, name)
+	}
+}
+
+func TestUnknownCommandIsReportedRatherThanIgnored(t *testing.T) {
+	env, registry := testEnv(t)
+	_, err := registry.Execute(t.Context(), env, "frobnicate the widget")
+	assert.Error(t, err)
+}
+
+func TestEmptyLineIsNotAnError(t *testing.T) {
+	env, registry := testEnv(t)
+	for _, line := range []string{"", "   ", "\t"} {
+		result, err := registry.Execute(t.Context(), env, line)
+		assert.NoError(t, err, "an empty prompt submission is a no-op, not a failure")
+		assert.Empty(t, result.Lines)
+	}
+}
+
+// --- completion ---
+
+func TestCompletionOffersTopLevelCommands(t *testing.T) {
+	env, registry := testEnv(t)
+	suggestions := registry.Complete(env, "co")
+	assert.Contains(t, suggestions, "connect")
+}
+
+func TestCompletionOffersSubcommands(t *testing.T) {
+	env, registry := testEnv(t)
+	suggestions := registry.Complete(env, "log ")
+	assert.Contains(t, suggestions, "log level")
+	assert.Contains(t, suggestions, "log clear")
+}
+
+func TestCompletionOffersOpenConnectionsAsArguments(t *testing.T) {
+	env, registry := testEnv(t)
+	suggestions := registry.Complete(env, "read-direct ")
+	joined := strings.Join(suggestions, " ")
+	assert.Contains(t, joined, plcsession.DemoDeviceOne,
+		"the open connection is the only sensible first argument")
+}
+
+func TestCompletionOffersDemoTagsOnceAConnectionIsTyped(t *testing.T) {
+	env, registry := testEnv(t)
+	suggestions := registry.Complete(env, "read-direct "+plcsession.DemoDeviceOne+" te")
+	joined := strings.Join(suggestions, " ")
+	assert.Contains(t, joined, "temp/1", "demo mode knows its catalogue, so it should offer it")
+}
+
+func TestCompletionOffersTheDemoDevicesToConnectTo(t *testing.T) {
+	env, registry := testEnv(t)
+	suggestions := registry.Complete(env, "connect ")
+	joined := strings.Join(suggestions, " ")
+	assert.Contains(t, joined, plcsession.DemoDeviceOne,
+		"in demo mode the reachable devices should lead, not a protocol prefix that connects to nothing")
+}
+
+func TestCompletionReturnsWholeLinesSoThePromptCanAcceptThemDirectly(t *testing.T) {
+	env, registry := testEnv(t)
+	for _, suggestion := range registry.Complete(env, "read-direct ") {
+		assert.True(t, strings.HasPrefix(suggestion, "read-direct "),
+			"a suggestion is substituted for the whole line, so it must include the command: %q", suggestion)
+	}
+}
+
+func TestCompletionOfAnUnknownCommandOffersNothing(t *testing.T) {
+	env, registry := testEnv(t)
+	assert.Empty(t, registry.Complete(env, "frobnicate "))
+}
diff --git a/plc4go/tools/plc4xbrowser/browser/compose.go b/plc4go/tools/plc4xbrowser/browser/compose.go
new file mode 100644
index 00000000..541e0f45
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/compose.go
@@ -0,0 +1,448 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"context"
+	"strconv"
+	"strings"
+
+	"charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession"
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/tui"
+)
+
+// The request composer.
+//
+// This is the interface behind read, write, browse and subscribe -- the four commands the
+// previous implementation advertised in its help and then answered with "mode switch not yet
+// implemented". They were never finished because a read is a multi-tag request and the
+// single-line prompt cannot express one: the -direct variants exist precisely because the real
+// form did not. A modal form is the missing piece.
+
+// composeField identifies a focusable field in the form.
+type composeField int
+
+const (
+	fieldConnection composeField = iota
+	fieldFirstTag
+)
+
+// composeRow is one tag row of the form.
+type composeRow struct {
+	name    string
+	address string
+	value   string
+}
+
+// composer is the modal request form.
+type composer struct {
+	operation Operation
+	// connection is the connection the request runs against.
+	connection string
+	// query is the browse query; browse has no tag rows.
+	query string
+	rows  []composeRow
+
+	// field is the focused field index: 0 is the connection, then two or three cells per tag
+	// row depending on whether the operation writes.
+	field int
+	// submitting suppresses further edits while the request is in flight.
+	submitting bool
+	// problem is the validation message shown at the foot of the form.
+	problem string
+}
+
+// cellsPerRow is how many editable cells a tag row has: name and address, plus a value for a
+// write.
+func (c *composer) cellsPerRow() int {
+	if c.operation == OperationWrite {
+		return 3
+	}
+	return 2
+}
+
+// fieldCount is the number of focusable cells, including the connection and the trailing
+// "add tag" affordance.
+func (c *composer) fieldCount() int {
+	if c.operation == OperationBrowse {
+		// Connection and query.
+		return 2
+	}
+	return 1 + len(c.rows)*c.cellsPerRow() + 1
+}
+
+// isAddRow reports whether the focus is on the "+ add tag" affordance.
+func (c *composer) isAddRow() bool {
+	return c.operation != OperationBrowse && c.field == c.fieldCount()-1
+}
+
+// rowAndCell maps the focused field onto a tag row and the cell within it.
+func (c *composer) rowAndCell() (row, cell int, ok bool) {
+	if c.operation == OperationBrowse || c.field == 0 || c.isAddRow() {
+		return 0, 0, false
+	}
+	offset := c.field - 1
+	return offset / c.cellsPerRow(), offset % c.cellsPerRow(), true
+}
+
+// openComposer opens the form for a spec.
+func (m *Model) openComposer(spec ComposeSpec) {
+	form := &composer{
+		operation:  spec.Operation,
+		connection: spec.Connection,
+		query:      spec.Query,
+	}
+	for _, tag := range spec.Tags {
+		form.rows = append(form.rows, composeRow{name: tag.Name, address: tag.Address})
+	}
+	if len(form.rows) == 0 && spec.Operation != OperationBrowse {
+		form.rows = append(form.rows, composeRow{name: "tag1"})
+	}
+	if form.connection == "" {
+		// Nothing was named on the command line, so start on the connection field; otherwise
+		// start where the user still has work to do.
+		form.field = int(fieldConnection)
+	} else {
+		form.field = int(fieldFirstTag)
+	}
+	m.composer = form
+	m.prompt.Blur()
+}
+
+// closeComposer dismisses the form and returns the keyboard to the prompt.
+func (m *Model) closeComposer() {
+	m.composer = nil
+	m.focusPrompt()
+}
+
+// updateComposer handles a key while the form is open.
+func (m *Model) updateComposer(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	form := m.composer
+	if form.submitting {
+		// The request is in flight. Only escape is honoured, so a stray keystroke cannot edit
+		// a form that has already been sent.
+		if msg.String() == "esc" {
+			m.closeComposer()
+		}
+		return m, nil
+	}
+
+	switch msg.String() {
+	case "esc":
+		m.closeComposer()
+		return m, nil
+
+	case "tab", "down":
+		form.field = (form.field + 1) % form.fieldCount()
+		return m, nil
+
+	case "shift+tab", "up":
+		form.field = (form.field - 1 + form.fieldCount()) % form.fieldCount()
+		return m, nil
+
+	case "enter":
+		if form.isAddRow() {
+			form.addRow()
+			return m, nil
+		}
+		return m.submitComposer()
+
+	case "ctrl+n":
+		form.addRow()
+		return m, nil
+
+	case "ctrl+d":
+		form.removeRow()
+		return m, nil
+
+	case "backspace":
+		form.edit(func(current string) string {
+			if current == "" {
+				return current
+			}
+			runes := []rune(current)
+			return string(runes[:len(runes)-1])
+		})
+		return m, nil
+	}
+
+	// Any other printable key types into the focused cell.
+	if text := msg.String(); len([]rune(text)) == 1 {
+		form.edit(func(current string) string { return current + text })
+	}
+	return m, nil
+}
+
+// addRow appends an empty tag row and moves to its first cell.
+func (c *composer) addRow() {
+	if c.operation == OperationBrowse {
+		return
+	}
+	c.rows = append(c.rows, composeRow{name: "tag" + strconv.Itoa(len(c.rows)+1)})
+	c.field = 1 + (len(c.rows)-1)*c.cellsPerRow()
+}
+
+// removeRow deletes the focused tag row, keeping at least one.
+func (c *composer) removeRow() {
+	row, _, ok := c.rowAndCell()
+	if !ok || len(c.rows) <= 1 {
+		return
+	}
+	c.rows = append(c.rows[:row], c.rows[row+1:]...)
+	if c.field >= c.fieldCount()-1 {
+		c.field = c.fieldCount() - 2
+	}
+}
+
+// edit applies a change to the focused cell.
+func (c *composer) edit(change func(string) string) {
+	if c.field == 0 {
+		c.connection = change(c.connection)
+		return
+	}
+	if c.operation == OperationBrowse {
+		c.query = change(c.query)
+		return
+	}
+	row, cell, ok := c.rowAndCell()
+	if !ok {
+		return
+	}
+	switch cell {
+	case 0:
+		c.rows[row].name = change(c.rows[row].name)
+	case 1:
+		c.rows[row].address = change(c.rows[row].address)
+	case 2:
+		c.rows[row].value = change(c.rows[row].value)
+	}
+}
+
+// validate reports why the form cannot be submitted, or an empty string when it can.
+func (c *composer) validate() string {
+	if strings.TrimSpace(c.connection) == "" {
+		return "a connection is required"
+	}
+	if c.operation == OperationBrowse {
+		return ""
+	}
+	for i, row := range c.rows {
+		if strings.TrimSpace(row.address) == "" {
+			return "tag " + strconv.Itoa(i+1) + " needs an address"
+		}
+		if c.operation == OperationWrite && strings.TrimSpace(row.value) == "" {
+			return "tag " + strconv.Itoa(i+1) + " needs a value to write"
+		}
+	}
+	return ""
+}
+
+// tagSpecs turns the form's rows into a request.
+func (c *composer) tagSpecs() []plcsession.TagSpec {
+	specs := make([]plcsession.TagSpec, 0, len(c.rows))
+	for i, row := range c.rows {
+		name := strings.TrimSpace(row.name)
+		if name == "" {
+			name = "tag" + strconv.Itoa(i+1)
+		}
+		specs = append(specs, plcsession.TagSpec{
+			Name:    name,
+			Address: strings.TrimSpace(row.address),
+			Value:   strings.TrimSpace(row.value),
+		})
+	}
+	return specs
+}
+
+// submitComposer validates and runs the composed request.
+func (m *Model) submitComposer() (tea.Model, tea.Cmd) {
+	form := m.composer
+	if problem := form.validate(); problem != "" {
+		form.problem = problem
+		return m, nil
+	}
+	form.problem = ""
+	form.submitting = true
+
+	session := m.options.Session
+	env := m.env
+	operation := form.operation
+	connection := strings.TrimSpace(form.connection)
+	query := strings.TrimSpace(form.query)
+	tags := form.tagSpecs()
+
+	return m, func() tea.Msg {
+		ctx := context.Background()
+		switch operation {
+		case OperationRead:
+			result, err := session.Read(ctx, connection, tags)
+			if err != nil {
+				return commandDoneMsg{err: err}
+			}
+			return commandDoneMsg{result: resultForTags(env, plcsession.EventRead, connection, result.Tags, result.Duration)}
+
+		case OperationWrite:
+			result, err := session.Write(ctx, connection, tags)
+			if err != nil {
+				return commandDoneMsg{err: err}
+			}
+			return commandDoneMsg{result: resultForTags(env, plcsession.EventWrite, connection, result.Tags, result.Duration)}
+
+		case OperationBrowse:
+			result, err := session.Browse(ctx, connection, query)
+			if err != nil {
+				return commandDoneMsg{err: err}
+			}
+			out := Result{Lines: []string{
+				"browse " + connection + " found " + strconv.Itoa(len(result.Items)) + " tags",
+			}}
+			for _, item := range result.Items {
+				out.Events = append(out.Events, plcsession.Event{
+					Kind:       plcsession.EventBrowse,
+					Connection: connection,
+					Received:   env.Now(),
+					Summary:    browseSummary(item),
+					Tags: []plcsession.TagResult{{
+						Name:     item.Name,
+						Address:  item.Address,
+						DataType: item.DataType,
+						Code:     plcsession.ResponseCodeOK,
+					}},
+				})
+			}
+			return commandDoneMsg{result: out}
+
+		case OperationSubscribe:
+			streamCtx, cancel := env.StreamContext()
+			events, err := session.Subscribe(streamCtx, connection, tags)
+			if err != nil {
+				cancel()
+				return commandDoneMsg{err: err}
+			}
+			label := tags[0].Address
+			if len(tags) > 1 {
+				label = tags[0].Address + " +" + strconv.Itoa(len(tags)-1)
+			}
+			return commandDoneMsg{result: Result{
+				Lines:  []string{"subscribed " + connection + " " + label},
+				Stream: &Stream{Connection: connection, Label: label, Events: events, Cancel: cancel},
+			}}
+		}
+		return commandDoneMsg{}
+	}
+}
+
+// ComposerOpen reports whether the form is showing, for tests.
+func (m *Model) ComposerOpen() bool { return m.composer != nil }
+
+// ComposerProblem exposes the validation message, for tests.
+func (m *Model) ComposerProblem() string {
+	if m.composer == nil {
+		return ""
+	}
+	return m.composer.problem
+}
+
+// renderComposer draws the form.
+func (m *Model) renderComposer(width, height int) string {
+	form := m.composer
+	theme := m.theme
+	glyphs := theme.Glyphs
+
+	// The form is a fixed, readable width rather than the full pane: a form stretched across a
+	// wide terminal is harder to read, not easier.
+	formWidth := min(max(width-8, 40), 68)
+
+	title := strings.ToUpper(string(form.operation)[:1]) + string(form.operation)[1:] + " request"
+	if form.connection != "" {
+		title += " " + glyphs.Separator + " " + form.connection
+	}
+
+	var lines []string
+	lines = append(lines, m.composeCell("connection", form.connection, 0, formWidth-4))
+
+	if form.operation == OperationBrowse {
+		lines = append(lines, m.composeCell("query", form.query, 1, formWidth-4))
+	} else {
+		for i, row := range form.rows {
+			base := 1 + i*form.cellsPerRow()
+			cells := []string{
+				m.composeInline("tag name", row.name, base, 20),
+				m.composeInline("address", row.address, base+1, 22),
+			}
+			if form.operation == OperationWrite {
+				cells = append(cells, m.composeInline("value", row.value, base+2, 12))
+			}
+			lines = append(lines, "  "+strings.Join(cells, "  "))
+		}
+		add := "  + add tag"
+		if form.isAddRow() {
+			add = theme.SelectedRow.Render("  " + glyphs.Selected + " add tag")
+		}
+		lines = append(lines, add)
+	}
+
+	lines = append(lines, "")
+	switch {
+	case form.submitting:
+		lines = append(lines, "  "+theme.Muted.Render("running…"))
+	case form.problem != "":
+		lines = append(lines, "  "+theme.Err.Render(glyphs.Err+" "+form.problem))
+	default:
+		lines = append(lines, "  "+theme.Ok.Render(glyphs.Ok+" ready")+
+			"   "+theme.Muted.Render("enter run "+glyphs.Separator+" tab next "+glyphs.Separator+" esc cancel"))
+	}
+
+	body := strings.Join(lines, "\n")
+	pane := tui.Pane{Title: title, Width: formWidth, Height: len(lines) + 2, Focused: true}
+	return pane.Render(theme, body)
+}
+
+// composeCell renders a full-width labelled field.
+func (m *Model) composeCell(label, value string, field, width int) string {
+	return "  " + m.composeInline(label, value, field, width-len(label)-4)
+}
+
+// composeInline renders one labelled field, marking it when focused.
+func (m *Model) composeInline(label, value string, field, width int) string {
+	theme := m.theme
+	shown := value
+	if m.composer.field == field && !m.composer.submitting {
+		// A block cursor, so the focused cell is unmistakable even with colour off.
+		shown = value + "█"
+	}
+	shown = pad(shown, width)
+	if m.composer.field == field {
+		return theme.Muted.Render(label+" ") + theme.SelectedRow.Render(shown)
+	}
+	return theme.Muted.Render(label+" ") + theme.Value.Render(shown)
+}
+
+// pad right-pads text to a width, measured in display cells.
+func pad(text string, width int) string {
+	actual := lipgloss.Width(text)
+	if actual >= width {
+		return text
+	}
+	return text + strings.Repeat(" ", width-actual)
+}
diff --git a/plc4go/tools/plc4xbrowser/browser/config.go b/plc4go/tools/plc4xbrowser/browser/config.go
new file mode 100644
index 00000000..5556a93f
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/config.go
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"os"
+	"path/filepath"
+	"slices"
+	"time"
+
+	"github.com/apache/plc4x/plc4go/spi/errors"
+	"gopkg.in/yaml.v3"
+)
+
+// The on-disk settings, carried over from the previous implementation so that an existing
+// ~/.config/plc4xbrowser/config.yml keeps working: the field names and the YAML keys are
+// unchanged.
+//
+// What did change is ownership. The previous version kept the settings in a package-level
+// variable, loaded them from an init function and panicked from that init when the user's
+// config directory could not be created - which aborts the process before main runs, with a
+// stack trace and no explanation. Here the path is resolved by a function that returns an
+// error, the settings are a value, and a broken or unreadable file degrades to the defaults.
+
+// HistoryLimit is how many hosts and commands are remembered. It is ten because the YAML keys
+// are named for ten and an existing file must keep meaning what it says.
+const HistoryLimit = 10
+
+// Config is the persisted state of a browser session.
+type Config struct {
+	History struct {
+		Last10Hosts    []string `yaml:"last_hosts"`
+		Last10Commands []string `yaml:"last_commands"`
+	} `yaml:"history"`
+	AutoRegisterDrivers []string  `yaml:"auto_register_driver"`
+	LastUpdated         time.Time `yaml:"last_updated"`
+	LogLevel            string    `yaml:"log_level"`
+	MaxConsoleLines     int       `yaml:"max_console_lines"`
+	MaxOutputLines      int       `yaml:"max_output_lines"`
+}
+
+// Default numbers of retained lines, matching the previous implementation.
+const (
+	defaultMaxConsoleLines = 500
+	defaultMaxOutputLines  = 500
+)
+
+// NewConfig returns the defaults.
+func NewConfig() Config {
+	return Config{
+		MaxConsoleLines: defaultMaxConsoleLines,
+		MaxOutputLines:  defaultMaxOutputLines,
+	}
+}
+
+// ConfigPath returns the file the settings live in, creating the directory if it does not
+// exist. Unlike the code it replaces it reports a failure rather than panicking, because a
+// read-only home directory is a reason to run without persistence, not a reason to refuse to
+// start.
+func ConfigPath() (string, error) {
+	userConfigDir, err := os.UserConfigDir()
+	if err != nil {
+		return "", errors.Wrap(err, "cannot locate the user config directory")
+	}
+	dir := filepath.Join(userConfigDir, "plc4xbrowser")
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		return "", errors.Wrapf(err, "cannot create %s", dir)
+	}
+	return filepath.Join(dir, "config.yml"), nil
+}
+
+// LoadConfig reads the settings from path.
+//
+// A missing file is not an error: it is the first run. A malformed one is reported, but the
+// defaults are still returned, so a hand-edited file with a typo in it cannot lock the user
+// out of the tool.
+func LoadConfig(path string) (Config, error) {
+	config := NewConfig()
+	data, err := os.ReadFile(path) //nolint:gosec // the path comes from ConfigPath or a test.
+	if err != nil {
+		if os.IsNotExist(err) {
+			return config, nil
+		}
+		return config, errors.Wrapf(err, "cannot read %s", path)
+	}
+	if err := yaml.Unmarshal(data, &config); err != nil {
+		return NewConfig(), errors.Wrapf(err, "cannot parse %s", path)
+	}
+	// A file written by an older version, or by hand, may leave these at zero, which would
+	// mean "retain nothing" rather than "unset".
+	if config.MaxConsoleLines <= 0 {
+		config.MaxConsoleLines = defaultMaxConsoleLines
+	}
+	if config.MaxOutputLines <= 0 {
+		config.MaxOutputLines = defaultMaxOutputLines
+	}
+	return config, nil
+}
+
+// Save writes the settings to path.
+func (c *Config) Save(path string) error {
+	c.LastUpdated = time.Now()
+	data, err := yaml.Marshal(c)
+	if err != nil {
+		return errors.Wrap(err, "cannot encode the configuration")
+	}
+	// Truncating: the previous implementation opened with O_RDWR|O_CREATE and no O_TRUNC, so
+	// writing a shorter document than the one already there left the tail of the old one
+	// behind and produced a file that no longer parses.
+	if err := os.WriteFile(path, data, 0o600); err != nil {
+		return errors.Wrapf(err, "cannot write %s", path)
+	}
+	return nil
+}
+
+// AddHost records a host in the connect history, most recent last.
+func (c *Config) AddHost(host string) {
+	if host == "" {
+		return
+	}
+	c.History.Last10Hosts = pushRecent(c.History.Last10Hosts, host)
+}
+
+// AddCommand records a command line in the history, most recent last.
+//
+// clear and history are skipped, as they were before: replaying either from the history is
+// never what the user meant, and both would otherwise crowd out the ten slots.
+func (c *Config) AddCommand(command string) {
+	switch command {
+	case "", "clear", "history":
+		return
+	}
+	c.History.Last10Commands = pushRecent(c.History.Last10Commands, command)
+}
+
+// EnableAutoRegister marks a protocol for registration at startup.
+func (c *Config) EnableAutoRegister(protocol string) error {
+	if slices.Contains(c.AutoRegisterDrivers, protocol) {
+		return errors.Errorf("%s is already registered for auto register", protocol)
+	}
+	c.AutoRegisterDrivers = append(c.AutoRegisterDrivers, protocol)
+	return nil
+}
+
+// DisableAutoRegister unmarks a protocol.
+func (c *Config) DisableAutoRegister(protocol string) error {
+	index := slices.Index(c.AutoRegisterDrivers, protocol)
+	if index < 0 {
+		return errors.Errorf("%s is not registered for auto register", protocol)
+	}
+	c.AutoRegisterDrivers = slices.Delete(c.AutoRegisterDrivers, index, index+1)
+	return nil
+}
+
+// pushRecent appends value, removing any earlier occurrence and dropping the oldest entries
+// once the limit is passed.
+func pushRecent(entries []string, value string) []string {
+	if index := slices.Index(entries, value); index >= 0 {
+		entries = slices.Delete(entries, index, index+1)
+	}
+	entries = append(entries, value)
+	if len(entries) > HistoryLimit {
+		entries = slices.Clone(entries[len(entries)-HistoryLimit:])
+	}
+	return entries
+}
diff --git a/plc4go/tools/plc4xbrowser/browser/doc.go b/plc4go/tools/plc4xbrowser/browser/doc.go
new file mode 100644
index 00000000..da7a7293
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/doc.go
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Package browser is the terminal user interface of plc4xbrowser.
+//
+// It replaces a tview implementation whose command actions wrote their results directly into
+// package-level widgets from background goroutines. That arrangement had three consequences
+// this package is shaped to avoid.
+//
+// Nothing could be tested: an action returned an error and otherwise communicated only by
+// mutating a widget, so there was no value to assert on. Here a command is data with a Run
+// function that takes a [plcsession.Session] and returns a [Result]; the model turns results
+// into state and the view renders state. Each of those three steps is separately testable, and
+// none of them needs a terminal.
+//
+// Four advertised commands were never finished. read, write, browse and subscribe each
+// answered "mode switch not yet implemented", because the mechanism they needed - a modal
+// editor - was never built. It is built here, as the request composer.
+//
+// And the widgets were mutated off the event loop, which is a data race by construction. No
+// goroutine in this package touches model state. Every stream, whether a subscription, the log
+// or a command in flight, is drained by a tea.Cmd that reads one value and returns a message
+// that re-arms itself.
+package browser
diff --git a/plc4go/tools/plc4xbrowser/browser/model.go b/plc4go/tools/plc4xbrowser/browser/model.go
new file mode 100644
index 00000000..171d21fa
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/model.go
@@ -0,0 +1,868 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"charm.land/bubbles/v2/key"
+	"charm.land/bubbles/v2/table"
+	"charm.land/bubbles/v2/viewport"
+	tea "charm.land/bubbletea/v2"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession"
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/tui"
+)
+
+// Model is the browser's root Bubble Tea model.
+//
+// Every piece of state lives here and is mutated only inside Update. Nothing else may touch
+// it: the implementation this replaces mutated tview widgets from goroutines through
+// QueueUpdateDraw, which is precisely the pattern the Elm architecture forbids and precisely
+// why none of it could be tested.
+
+// pane identifies a focusable region.
+type pane int
+
+const (
+	paneSidebar pane = iota
+	paneMessages
+	paneDetail
+	paneLog
+
+	paneCount
+)
+
+// title names a pane for the status bar and its border.
+func (p pane) title() string {
+	switch p {
+	case paneSidebar:
+		return "Session"
+	case paneMessages:
+		return "Messages"
+	case paneDetail:
+		return "Detail"
+	case paneLog:
+		return "Log"
+	default:
+		return "?"
+	}
+}
+
+// sidebarRow is one line of the sidebar. Sections and items share a row list so that a single
+// selection index walks the whole pane: the design review flagged two independent cursors in
+// one focus target as a defect, and one flat list is the fix.
+type sidebarRow struct {
+	// heading marks a section title, which cannot be selected.
+	heading string
+	// label is the row text for a selectable row.
+	label string
+	// detail is dimmer trailing text, such as a driver's name.
+	detail string
+	// marker is a status glyph.
+	marker string
+	// connection and driver name what the row acts on when it is chosen.
+	connection string
+	driver     string
+}
+
+// selectable reports whether the cursor may rest on this row.
+func (r sidebarRow) selectable() bool { return r.heading == "" }
+
+// Options configures a Model.
+type Options struct {
+	// Session is the PLC seam: plcsession.Live normally, plcsession.Demo under --demo.
+	Session plcsession.Session
+	// Config is the persisted settings.
+	Config *Config
+	// Demo records that the session is simulated, so the UI can say so.
+	Demo bool
+	// Version labels the status bar.
+	Version string
+	// Theme overrides the theme; when nil one is derived from the environment and the
+	// terminal's reported background.
+	Theme *tui.Theme
+	// ForceASCII pins the ASCII glyph set regardless of the locale.
+	ForceASCII bool
+	// Now supplies timestamps, injected for tests.
+	Now func() time.Time
+}
+
+// Model is the root model.
+type Model struct {
+	options  Options
+	theme    tui.Theme
+	keys     tui.KeyMap
+	layout   tui.Layout
+	registry *Registry
+	env      *Env
+
+	focus pane
+	// promptFocused is tracked separately from focus because the prompt is not a pane: it is
+	// always visible, and the pane focus ring is what tab walks.
+	promptFocused bool
+
+	prompt tui.Prompt
+	help   tui.HelpFooter
+
+	messages     table.Model
+	detail       viewport.Model
+	logView      viewport.Model
+	composer     *composer
+	sidebarRows  []sidebarRow
+	sidebarIndex int
+
+	// events backs the message table. The filter is applied when the rows are built.
+	events   []plcsession.Event
+	filter   string
+	logLines []string
+
+	// toast is the last error, held until dismissed. The old UI let errors scroll away in a
+	// ten-row console, so a failed command could vanish before it was read.
+	toast string
+
+	streams []*Stream
+	quit    bool
+	ready   bool
+}
+
+// NewModel builds the root model.
+func NewModel(options Options) *Model {
+	if options.Now == nil {
+		options.Now = time.Now
+	}
+	if options.Config == nil {
+		config := NewConfig()
+		options.Config = &config
+	}
+
+	theme := tui.NewTheme(themeOptions(true, options))
+	if options.Theme != nil {
+		theme = *options.Theme
+	}
+
+	registry := NewCommands()
+	model := &Model{
+		options:  options,
+		theme:    theme,
+		keys:     tui.NewKeyMap(),
+		registry: registry,
+		focus:    paneMessages,
+	}
+	model.env = &Env{
+		Session:  options.Session,
+		Config:   options.Config,
+		Now:      options.Now,
+		Registry: registry,
+		Demo:     options.Demo,
+		StreamContext: func() (context.Context, context.CancelFunc) {
+			return context.WithCancel(context.Background())
+		},
+	}
+
+	model.prompt = tui.NewPrompt(theme, func(line string) []string {
+		return registry.Complete(model.env, line)
+	})
+	model.prompt.SetClock(options.Now)
+	model.help = tui.NewHelpFooter(theme)
+
+	model.messages = table.New(
+		table.WithColumns(messageColumns(80)),
+		table.WithFocused(false),
+		table.WithStyles(tableStyles(theme)),
+	)
+	model.detail = viewport.New()
+	model.logView = viewport.New()
+	model.promptFocused = true
+	model.refreshSidebar()
+	return model
+}
+
+// themeOptions resolves the theme options for a background brightness.
+func themeOptions(dark bool, options Options) tui.Options {
+	themeOptions := tui.OptionsFromEnv(dark)
+	if options.ForceASCII {
+		themeOptions.ASCII = true
+	}
+	return themeOptions
+}
+
+// messageColumns sizes the message table for a pane width.
+func messageColumns(width int) []table.Column {
+	// The fixed columns are sized to their content; the connection column takes what is left,
+	// because a connection string is the one field that is unboundedly long.
+	const fixed = 4 + 14 + 10 + 12 + 8
+	connection := max(width-fixed, 10)
+	return []table.Column{
+		{Title: "#", Width: 4},
+		{Title: "time", Width: 14},
+		{Title: "op", Width: 10},
+		{Title: "connection", Width: connection},
+		{Title: "tag", Width: 12},
+		{Title: "code", Width: 8},
+	}
+}
+
+// tableStyles maps the theme onto the table's own styling.
+//
+// bubbles/table defaults to a palette of its own, so without this a colourless theme still
+// emitted a 256-colour selected row -- which breaks NO_COLOR and makes golden output unstable.
+func tableStyles(theme tui.Theme) table.Styles {
+	styles := table.DefaultStyles()
+	styles.Header = theme.Key.Bold(true)
+	styles.Cell = theme.Value
+	styles.Selected = theme.SelectedRow
+	if theme.IsNoColor() {
+		// Reverse video would be the obvious colourless highlight, but it fights the selection
+		// glyph and hurts readability; bold alone is enough.
+		styles.Header = theme.Key.Bold(true)
+		styles.Selected = theme.Value.Bold(true)
+	}
+	return styles
+}
+
+// commandDoneMsg carries the outcome of a command back into Update.
+type commandDoneMsg struct {
+	line   string
+	result Result
+	err    error
+}
+
+// streamEventMsg is one subscription event, plus the stream it came from so the reader can
+// re-arm on the same channel.
+type streamEventMsg struct {
+	stream *Stream
+	event  plcsession.Event
+	open   bool
+}
+
+// Init starts the prompt cursor and its spinner ticker.
+func (m *Model) Init() tea.Cmd {
+	return tea.Batch(m.prompt.Focus(), tui.PromptTick())
+}
+
+// Update handles one message.
+func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.resize(tui.Size{Width: msg.Width, Height: msg.Height})
+		return m, nil
+
+	case tea.BackgroundColorMsg:
+		// The terminal reports its background at startup and whenever it changes, which is the
+		// only reliable way to pick a palette that stays legible.
+		if m.options.Theme == nil {
+			m.theme = tui.NewTheme(themeOptions(msg.IsDark(), m.options))
+			m.help = tui.NewHelpFooter(m.theme)
+			m.messages.SetStyles(tableStyles(m.theme))
+			m.refreshSidebar()
+		}
+		return m, nil
+
+	case tea.KeyPressMsg:
+		return m.handleKey(msg)
+
+	case tui.PromptSubmitMsg:
+		return m.submit(msg.Line)
+
+	case commandDoneMsg:
+		return m.applyCommand(msg)
+
+	case streamEventMsg:
+		return m.applyStreamEvent(msg)
+	}
+
+	var cmd tea.Cmd
+	m.prompt, cmd = m.prompt.Update(msg)
+	return m, cmd
+}
+
+// handleKey routes a key press according to what currently has focus.
+func (m *Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	// The composer is modal: while it is open it owns the keyboard, apart from quit.
+	if m.composer != nil {
+		if key.Matches(msg, m.keys.ForFocus(tui.FocusOverlay).Quit) {
+			return m.shutdown()
+		}
+		return m.updateComposer(msg)
+	}
+
+	keys := m.keys.ForFocus(m.currentFocus())
+
+	switch {
+	case key.Matches(msg, keys.Quit):
+		return m.shutdown()
+
+	case key.Matches(msg, keys.Help):
+		m.help.SetShowAll(!m.help.ShowAll())
+		return m, nil
+
+	case key.Matches(msg, keys.Cancel):
+		// Escape unwinds one level at a time: the prompt's own use of it first, then the
+		// toast, then a focused pane back to the prompt.
+		if m.promptFocused && m.prompt.ConsumesEscape() {
+			break
+		}
+		if m.toast != "" {
+			m.toast = ""
+			return m, nil
+		}
+		if !m.promptFocused {
+			m.focusPrompt()
+			return m, nil
+		}
+
+	case key.Matches(msg, keys.NextPane):
+		m.cyclePane(1)
+		return m, nil
+
+	case key.Matches(msg, keys.PrevPane):
+		m.cyclePane(-1)
+		return m, nil
+
+	case key.Matches(msg, keys.FocusPrompt):
+		m.focusPrompt()
+		return m, nil
+
+	case key.Matches(msg, keys.FocusPanes):
+		// From the prompt, tab enters the pane ring only when there is nothing to complete;
+		// otherwise tab is completion, which is what a REPL user expects.
+		if m.promptFocused && m.prompt.CompletionOpen() {
+			break
+		}
+		if m.promptFocused {
+			m.blurPrompt()
+			m.setFocus(paneSidebar)
+			return m, nil
+		}
+	}
+
+	if m.promptFocused {
+		var cmd tea.Cmd
+		m.prompt, cmd = m.prompt.Update(msg)
+		return m, cmd
+	}
+	return m.paneKey(msg, keys)
+}
+
+// currentFocus maps the model's focus onto the keymap's notion of it.
+func (m *Model) currentFocus() tui.Focus {
+	switch {
+	case m.composer != nil:
+		return tui.FocusOverlay
+	case m.promptFocused:
+		return tui.FocusPrompt
+	default:
+		return tui.FocusPane
+	}
+}
+
+// paneKey handles a key aimed at the focused pane.
+func (m *Model) paneKey(msg tea.KeyPressMsg, keys tui.KeyMap) (tea.Model, tea.Cmd) {
+	switch m.focus {
+	case paneSidebar:
+		switch {
+		case key.Matches(msg, keys.Up):
+			m.moveSidebar(-1)
+			return m, nil
+		case key.Matches(msg, keys.Down):
+			m.moveSidebar(1)
+			return m, nil
+		case key.Matches(msg, keys.Select):
+			return m.activateSidebarRow()
+		}
+	case paneMessages:
+		if key.Matches(msg, keys.Select) {
+			m.syncDetail()
+			return m, nil
+		}
+		var cmd tea.Cmd
+		m.messages, cmd = m.messages.Update(msg)
+		m.syncDetail()
+		return m, cmd
+	case paneDetail:
+		if key.Matches(msg, keys.Wrap) {
+			m.detail.SoftWrap = !m.detail.SoftWrap
+			return m, nil
+		}
+		var cmd tea.Cmd
+		m.detail, cmd = m.detail.Update(msg)
+		return m, cmd
+	case paneLog:
+		var cmd tea.Cmd
+		m.logView, cmd = m.logView.Update(msg)
+		return m, cmd
+	}
+	return m, nil
+}
+
+// focusPrompt returns the keyboard to the command line.
+func (m *Model) focusPrompt() {
+	m.promptFocused = true
+	m.messages.Blur()
+	_ = m.prompt.Focus()
+}
+
+// blurPrompt moves the keyboard into the pane ring.
+func (m *Model) blurPrompt() {
+	m.promptFocused = false
+	m.prompt.Blur()
+}
+
+// cyclePane walks the focus ring, returning to the prompt past either end so that no sequence
+// of tabs can strand the user away from the command line.
+func (m *Model) cyclePane(delta int) {
+	if m.promptFocused {
+		m.blurPrompt()
+		if delta < 0 {
+			m.setFocus(paneLog)
+		} else {
+			m.setFocus(paneSidebar)
+		}
+		return
+	}
+	next := int(m.focus) + delta
+	if next < 0 || next >= int(paneCount) {
+		m.focusPrompt()
+		return
+	}
+	m.setFocus(pane(next))
+}
+
+// setFocus moves focus to a pane, keeping the table's own focus flag in step.
+func (m *Model) setFocus(p pane) {
+	m.focus = p
+	if p == paneMessages {
+		m.messages.Focus()
+	} else {
+		m.messages.Blur()
+	}
+}
+
+// Focused reports the focused pane and whether the prompt holds the keyboard, for tests.
+func (m *Model) Focused() (pane, bool) { return m.focus, m.promptFocused }
+
+// shutdown cancels every subscription and saves the configuration before quitting.
+func (m *Model) shutdown() (tea.Model, tea.Cmd) {
+	m.quit = true
+	for _, stream := range m.streams {
+		stream.Cancel()
+	}
+	m.streams = nil
+	_ = m.options.Session.Close()
+	if path, err := ConfigPath(); err == nil {
+		_ = m.options.Config.Save(path)
+	}
+	return m, tea.Quit
+}
+
+// submit runs the line the prompt handed over.
+func (m *Model) submit(line string) (tea.Model, tea.Cmd) {
+	trimmed := strings.TrimSpace(line)
+	if trimmed == "" {
+		return m, nil
+	}
+	m.options.Config.AddCommand(trimmed)
+	m.appendLog("$ " + trimmed)
+	m.prompt.Start()
+	return m, m.runCommand(trimmed)
+}
+
+// runCommand executes a line off the event loop and reports the outcome as a message.
+//
+// This is the shape every piece of I/O in the model takes: a tea.Cmd does the work and a typed
+// message carries the result back, so that nothing but Update ever writes state.
+func (m *Model) runCommand(line string) tea.Cmd {
+	env := m.env
+	registry := m.registry
+	return func() tea.Msg {
+		result, err := registry.Execute(context.Background(), env, line)
+		return commandDoneMsg{line: line, result: result, err: err}
+	}
+}
+
+// applyCommand folds a command's outcome into the model.
+func (m *Model) applyCommand(msg commandDoneMsg) (tea.Model, tea.Cmd) {
+	m.prompt.Finish()
+	if msg.err != nil {
+		m.toast = msg.err.Error()
+		m.appendLog(m.theme.Glyphs.Err + " " + msg.err.Error())
+		return m, nil
+	}
+	m.prompt.Reset()
+	return m.applyResult(msg.result)
+}
+
+// applyResult applies a Result. It is separate from applyCommand so the composer can reuse it.
+func (m *Model) applyResult(result Result) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	for _, line := range result.Lines {
+		m.appendLog(line)
+	}
+	if len(result.Events) > 0 {
+		m.events = append(m.events, result.Events...)
+		m.trimEvents()
+		m.refreshMessages()
+	}
+	if result.Clear.Has(ClearMessages) {
+		m.events = nil
+		m.refreshMessages()
+		m.detail.SetContent("")
+	}
+	if result.Clear.Has(ClearConsole) {
+		m.logLines = nil
+		m.logView.SetContent("")
+	}
+	if result.ConnectionsChanged || result.DriversChanged {
+		m.refreshSidebar()
+	}
+	if result.LogLevel != nil {
+		m.options.Config.LogLevel = result.LogLevel.String()
+	}
+	if result.Compose != nil {
+		m.openComposer(*result.Compose)
+	}
+	if result.Stream != nil {
+		m.streams = append(m.streams, result.Stream)
+		cmds = append(cmds, awaitStream(result.Stream))
+	}
+	if result.Quit {
+		return m.shutdown()
+	}
+	return m, tea.Batch(cmds...)
+}
+
+// awaitStream reads ONE event and re-arms itself.
+//
+// Reading in a loop from a goroutine would mean touching the model from outside Update. One
+// value per command is how Bubble Tea consumes a channel.
+func awaitStream(stream *Stream) tea.Cmd {
+	return func() tea.Msg {
+		event, open := <-stream.Events
+		return streamEventMsg{stream: stream, event: event, open: open}
+	}
+}
+
+// applyStreamEvent folds a subscription event in and re-arms the reader.
+func (m *Model) applyStreamEvent(msg streamEventMsg) (tea.Model, tea.Cmd) {
+	if !msg.open {
+		m.dropStream(msg.stream)
+		m.appendLog("subscription ended " + msg.stream.Connection + " " + msg.stream.Label)
+		return m, nil
+	}
+	m.events = append(m.events, msg.event)
+	m.trimEvents()
+	m.refreshMessages()
+	return m, awaitStream(msg.stream)
+}
+
+// dropStream forgets a finished subscription.
+func (m *Model) dropStream(stream *Stream) {
+	for i, candidate := range m.streams {
+		if candidate == stream {
+			m.streams = append(m.streams[:i], m.streams[i+1:]...)
+			return
+		}
+	}
+}
+
+// trimEvents bounds the message list, as the old UI's MaxOutputLines did.
+func (m *Model) trimEvents() {
+	limit := m.options.Config.MaxOutputLines
+	if limit > 0 && len(m.events) > limit {
+		m.events = m.events[len(m.events)-limit:]
+	}
+}
+
+// appendLog adds a console line, bounded by the configured maximum.
+func (m *Model) appendLog(line string) {
+	stamp := m.options.Now().Format("15:04:05")
+	m.logLines = append(m.logLines, stamp+" "+line)
+	if limit := m.options.Config.MaxConsoleLines; limit > 0 && len(m.logLines) > limit {
+		m.logLines = m.logLines[len(m.logLines)-limit:]
+	}
+	m.logView.SetContent(strings.Join(m.logLines, "\n"))
+	m.logView.GotoBottom()
+}
+
+// AppendLog exposes the console so a caller can write a startup banner before the program runs.
+func (m *Model) AppendLog(line string) { m.appendLog(line) }
+
+// LogLines exposes the console contents, for tests.
+func (m *Model) LogLines() []string { return append([]string(nil), m.logLines...) }
+
+// Toast exposes the pending error line, for tests.
+func (m *Model) Toast() string { return m.toast }
+
+// SetFilter sets the message filter.
+func (m *Model) SetFilter(filter string) {
+	m.filter = filter
+	m.refreshMessages()
+}
+
+// filteredEvents returns the events the table shows.
+//
+// bubbles/table has no filtering of its own -- verified: the package contains no reference to
+// filtering at all -- so the filter is applied to the row slice here and the "n of m" counter
+// is derived from the two lengths.
+func (m *Model) filteredEvents() []plcsession.Event {
+	if m.filter == "" {
+		return m.events
+	}
+	needle := strings.ToLower(m.filter)
+	var out []plcsession.Event
+	for _, event := range m.events {
+		if strings.Contains(strings.ToLower(eventHaystack(event)), needle) {
+			out = append(out, event)
+		}
+	}
+	return out
+}
+
+// eventHaystack is the text a filter matches against.
+func eventHaystack(event plcsession.Event) string {
+	parts := []string{string(event.Kind), event.Connection, event.Summary}
+	for _, tag := range event.Tags {
+		parts = append(parts, tag.Address, tag.Name, tag.Value, tag.Code)
+	}
+	return strings.Join(parts, " ")
+}
+
+// refreshMessages rebuilds the table rows from the events.
+func (m *Model) refreshMessages() {
+	events := m.filteredEvents()
+	rows := make([]table.Row, 0, len(events))
+	for i, event := range events {
+		rows = append(rows, table.Row{
+			fmt.Sprintf("%d", i+1),
+			event.Received.Format("15:04:05.000"),
+			string(event.Kind),
+			event.Connection,
+			firstTagAddress(event),
+			eventCode(event),
+		})
+	}
+	m.messages.SetRows(rows)
+	if len(rows) > 0 {
+		m.messages.GotoBottom()
+	}
+	m.syncDetail()
+}
+
+// firstTagAddress is the tag column's content.
+func firstTagAddress(event plcsession.Event) string {
+	switch len(event.Tags) {
+	case 0:
+		return ""
+	case 1:
+		return event.Tags[0].Address
+	default:
+		return fmt.Sprintf("%s +%d", event.Tags[0].Address, len(event.Tags)-1)
+	}
+}
+
+// eventCode summarises an event's outcome for the code column.
+func eventCode(event plcsession.Event) string {
+	failed := 0
+	for _, tag := range event.Tags {
+		if !tag.Succeeded() {
+			failed++
+		}
+	}
+	switch {
+	case len(event.Tags) == 0:
+		return ""
+	case failed == 0:
+		return plcsession.ResponseCodeOK
+	default:
+		return fmt.Sprintf("%d bad", failed)
+	}
+}
+
+// syncDetail rebuilds the detail pane for the selected message.
+func (m *Model) syncDetail() {
+	events := m.filteredEvents()
+	cursor := m.messages.Cursor()
+	if cursor < 0 || cursor >= len(events) {
+		m.detail.SetContent("")
+		return
+	}
+	m.detail.SetContent(renderEventDetail(m.theme, events[cursor]))
+}
+
+// SelectedEvent exposes the highlighted message, for tests and for the detail pane.
+func (m *Model) SelectedEvent() (plcsession.Event, bool) {
+	events := m.filteredEvents()
+	cursor := m.messages.Cursor()
+	if cursor < 0 || cursor >= len(events) {
+		return plcsession.Event{}, false
+	}
+	return events[cursor], true
+}
+
+// EventCount reports how many messages are held and how many the filter shows.
+func (m *Model) EventCount() (shown, total int) {
+	return len(m.filteredEvents()), len(m.events)
+}
+
+// refreshSidebar re-reads the session into the sidebar rows.
+func (m *Model) refreshSidebar() {
+	glyphs := m.theme.Glyphs
+	rows := []sidebarRow{{heading: "CONNECTIONS"}}
+	connections := m.options.Session.Connections()
+	if len(connections) == 0 {
+		// An empty pane that names the command to run beats an empty pane.
+		rows = append(rows, sidebarRow{label: "none, try: connect"})
+	}
+	for _, connection := range connections {
+		rows = append(rows, sidebarRow{
+			label:      connection.ID,
+			marker:     glyphs.Yes,
+			connection: connection.ID,
+		})
+	}
+
+	rows = append(rows, sidebarRow{heading: "DRIVERS"})
+	drivers := m.options.Session.Drivers()
+	if len(drivers) == 0 {
+		rows = append(rows, sidebarRow{label: "none, try: register"})
+	}
+	for _, driver := range drivers {
+		marker := glyphs.No
+		if driver.SupportsDiscovery {
+			marker = glyphs.Yes
+		}
+		rows = append(rows, sidebarRow{
+			label:  driver.Code,
+			detail: driver.Name,
+			marker: marker,
+			driver: driver.Code,
+		})
+	}
+
+	m.sidebarRows = rows
+	m.clampSidebar()
+}
+
+// clampSidebar keeps the cursor on a selectable row.
+func (m *Model) clampSidebar() {
+	if len(m.sidebarRows) == 0 {
+		m.sidebarIndex = 0
+		return
+	}
+	m.sidebarIndex = min(max(m.sidebarIndex, 0), len(m.sidebarRows)-1)
+	if !m.sidebarRows[m.sidebarIndex].selectable() {
+		m.moveSidebar(1)
+	}
+}
+
+// moveSidebar walks to the next selectable row in a direction, stopping at the ends.
+func (m *Model) moveSidebar(delta int) {
+	for i := m.sidebarIndex + delta; i >= 0 && i < len(m.sidebarRows); i += delta {
+		if m.sidebarRows[i].selectable() {
+			m.sidebarIndex = i
+			return
+		}
+	}
+}
+
+// activateSidebarRow acts on the highlighted row.
+//
+// Both of these handlers were "// TODO: disconnect popup" in the previous UI: selecting a
+// connection or a driver did nothing at all, which is the kind of dead affordance this port
+// exists to remove. Selecting pre-fills the prompt rather than acting immediately, because a
+// destructive action one keystroke away from a cursor move is too easy to trigger by accident.
+func (m *Model) activateSidebarRow() (tea.Model, tea.Cmd) {
+	row, ok := m.SidebarSelection()
+	if !ok {
+		return m, nil
+	}
+	switch {
+	case row.connection != "":
+		m.prompt.SetValue("read " + row.connection + " ")
+		m.focusPrompt()
+	case row.driver != "":
+		m.prompt.SetValue("discover " + row.driver)
+		m.focusPrompt()
+	}
+	return m, nil
+}
+
+// SidebarSelection exposes the highlighted row, for tests.
+func (m *Model) SidebarSelection() (sidebarRow, bool) {
+	if m.sidebarIndex < 0 || m.sidebarIndex >= len(m.sidebarRows) {
+		return sidebarRow{}, false
+	}
+	return m.sidebarRows[m.sidebarIndex], true
+}
+
+// PromptValue exposes the command line's contents, for tests.
+func (m *Model) PromptValue() string { return m.prompt.Value() }
+
+// resize recomputes the layout and resizes every child.
+func (m *Model) resize(size tui.Size) {
+	m.layout = tui.Compute(size)
+	m.ready = true
+	if m.layout.Mode == tui.ModeTooSmall {
+		return
+	}
+
+	// The prompt and the help footer span the full width less one cell of margin either side.
+	m.prompt.SetWidth(max(size.Width-2, 1))
+	m.help.SetWidth(max(size.Width-2, 1))
+
+	mainWidth := m.layout.MainWidth
+	if m.layout.Mode == tui.ModeCompact {
+		mainWidth = size.Width
+	}
+
+	tableHeight := m.layout.BodyHeight - 2
+	if m.layout.ShowDetail {
+		tableHeight = (m.layout.BodyHeight - 4) / 2
+	}
+	tableHeight = max(tableHeight, 1)
+
+	m.messages.SetWidth(max(mainWidth-2, 1))
+	m.messages.SetHeight(tableHeight)
+	m.messages.SetColumns(messageColumns(max(mainWidth-2, 12)))
+
+	m.detail.SetWidth(max(detailWidth(m.layout)-2, 1))
+	m.detail.SetHeight(max(m.layout.BodyHeight-tableHeight-4, 1))
+
+	m.logView.SetWidth(max(size.Width-2, 1))
+	m.logView.SetHeight(max(m.layout.LogHeight-2, 1))
+
+	m.refreshMessages()
+}
+
+// detailWidth is the width the detail pane gets in the current layout.
+func detailWidth(layout tui.Layout) int {
+	if layout.ShowDetail && layout.DetailWidth > 0 {
+		return layout.DetailWidth
+	}
+	return layout.MainWidth
+}
+
+// Quitting reports whether the model has asked to exit, for tests.
+func (m *Model) Quitting() bool { return m.quit }
diff --git a/plc4go/tools/plc4xbrowser/browser/model_test.go b/plc4go/tools/plc4xbrowser/browser/model_test.go
new file mode 100644
index 00000000..76b125f5
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/model_test.go
@@ -0,0 +1,513 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession"
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/tui"
+)
+
+// The model is driven directly with typed messages: no terminal, no goroutines, no sleeps.
+// That is the point of the port. The implementation this replaces could not be tested at all,
+// because its state lived in global tview widgets mutated from goroutines.
+
+// testTheme is colourless and ASCII, so rendered output is stable and measurable.
+func testTheme() tui.Theme {
+	return tui.NewTheme(tui.Options{Dark: true, NoColor: true, ASCII: true})
+}
+
+// newTestModel builds a model over a connected demo session.
+func newTestModel(t *testing.T) *Model {
+	t.Helper()
+
+	session := plcsession.NewDemo(plcsession.DemoOptions{
+		SubscriptionInterval: time.Millisecond,
+		Now:                  steppingClock(),
+	})
+	t.Cleanup(func() { _ = session.Close() })
+	_, err := session.RegisterDriver(plcsession.DemoProtocol)
+	require.NoError(t, err)
+	_, err = session.Connect(t.Context(), plcsession.DemoDeviceOne)
+	require.NoError(t, err)
+
+	config := NewConfig()
+	theme := testTheme()
+	model := NewModel(Options{
+		Session: session,
+		Config:  &config,
+		Demo:    true,
+		Version: "1.0.0-TEST",
+		Theme:   &theme,
+		Now:     steppingClock(),
+	})
+	return model
+}
+
+// sized runs Init and drives a window-size message through the model, which is the sequence
+// the runtime performs before any user input. Skipping Init leaves the prompt unfocused, and
+// an unfocused text input silently discards every keystroke.
+func sized(t *testing.T, model *Model, width, height int) *Model {
+	t.Helper()
+	model.Init()
+	next, _ := model.Update(tea.WindowSizeMsg{Width: width, Height: height})
+	updated, ok := next.(*Model)
+	require.True(t, ok)
+	return updated
+}
+
+// press sends a key and returns the command it produced.
+func press(t *testing.T, model *Model, key tea.KeyPressMsg) tea.Cmd {
+	t.Helper()
+	_, cmd := model.Update(key)
+	return cmd
+}
+
+// runCmd executes a command and feeds its message back, which is what the runtime does.
+func runCmd(t *testing.T, model *Model, cmd tea.Cmd) {
+	t.Helper()
+	require.NotNil(t, cmd, "expected the model to return a command")
+	msg := cmd()
+	require.NotNil(t, msg, "expected the command to produce a message")
+	model.Update(msg)
+}
+
+// renderLines renders the screen and returns its lines.
+func renderLines(t *testing.T, model *Model) []string {
+	t.Helper()
+	view := model.View()
+	assert.True(t, view.AltScreen, "the browser is a full-screen application")
+	return strings.Split(view.Content, "\n")
+}
+
+// --- geometry ---
+
+// TestEveryRenderedLineIsExactlyTheTerminalWidth is the property every multi-pane layout rests
+// on. One line a cell too wide tears every row beneath it.
+func TestEveryRenderedLineIsExactlyTheTerminalWidth(t *testing.T) {
+	for _, size := range []tui.Size{
+		{Width: 120, Height: 40},
+		{Width: 100, Height: 30},
+		{Width: 100, Height: 24},
+		{Width: 90, Height: 24},
+		{Width: 80, Height: 24},
+		{Width: 70, Height: 20},
+		{Width: 60, Height: 12},
+	} {
+		t.Run(size.String(), func(t *testing.T) {
+			model := sized(t, newTestModel(t), size.Width, size.Height)
+			lines := renderLines(t, model)
+
+			assert.Len(t, lines, size.Height, "the screen must be exactly the terminal height")
+			for i, line := range lines {
+				assert.Equal(t, size.Width, lipgloss.Width(line),
+					"line %d is %d cells, want %d: %q", i, lipgloss.Width(line), size.Width, line)
+			}
+		})
+	}
+}
+
+// TestThePromptIsAlwaysVisible is the regression test for the defect that motivated the whole
+// port: below 100 columns the previous UI added its command area with a zero row and column
+// span, which the grid discarded, so the input was never drawn and the user typed blind.
+func TestThePromptIsAlwaysVisible(t *testing.T) {
+	for _, size := range []tui.Size{
+		{Width: 200, Height: 50},
+		{Width: 100, Height: 24},
+		{Width: 80, Height: 24},
+		{Width: 70, Height: 16},
+		{Width: 60, Height: 12},
+	} {
+		t.Run(size.String(), func(t *testing.T) {
+			model := sized(t, newTestModel(t), size.Width, size.Height)
+			joined := strings.Join(renderLines(t, model), "\n")
+			assert.Contains(t, joined, "$", "the command prompt must be on screen at %s", size)
+		})
+	}
+}
+
+// TestTheHelpFooterAlwaysAdvertisesQuit guards against bubbles/help ellipsizing the tail away
+// at narrow widths, which would hide the one binding a stuck user needs.
+func TestTheHelpFooterAlwaysAdvertisesQuit(t *testing.T) {
+	for _, width := range []int{120, 100, 80, 70, 60} {
+		t.Run(tui.Size{Width: width, Height: 24}.String(), func(t *testing.T) {
+			model := sized(t, newTestModel(t), width, 24)
+			joined := strings.Join(renderLines(t, model), "\n")
+			assert.Contains(t, joined, "quit", "the footer must still advertise quit at %d columns", width)
+		})
+	}
+}
+
+// TestTheStatusBarDegradesRatherThanOverflowing pins the fix for a header that is about 91
+// cells as designed and therefore does not fit the 80-column target.
+func TestTheStatusBarDegradesRatherThanOverflowing(t *testing.T) {
+	for _, width := range []int{120, 100, 80, 70, 60} {
+		t.Run(tui.Size{Width: width, Height: 24}.String(), func(t *testing.T) {
+			model := sized(t, newTestModel(t), width, 24)
+			status := renderLines(t, model)[0]
+			assert.Equal(t, width, lipgloss.Width(status))
+			assert.Contains(t, status, "PLC4X Browser", "the tool's name is the one part that never drops")
+		})
+	}
+}
+
+func TestATerminalBelowTheMinimumSaysSoRatherThanDrawingSomethingBroken(t *testing.T) {
+	model := sized(t, newTestModel(t), 40, 8)
+	joined := strings.Join(renderLines(t, model), "\n")
+	assert.Contains(t, joined, tui.TooSmallMessage())
+}
+
+// TestTheDemoBadgeIsShown matters because a user must never mistake simulated values for
+// readings from real hardware.
+func TestTheDemoBadgeIsShown(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	assert.Contains(t, renderLines(t, model)[0], "DEMO")
+}
+
+func TestWithoutDemoThereIsNoBadge(t *testing.T) {
+	session := plcsession.NewDemo(plcsession.DemoOptions{})
+	t.Cleanup(func() { _ = session.Close() })
+	config := NewConfig()
+	theme := testTheme()
+	model := NewModel(Options{Session: session, Config: &config, Theme: &theme, Now: steppingClock()})
+	model = sized(t, model, 120, 30)
+	assert.NotContains(t, renderLines(t, model)[0], "DEMO")
+}
+
+// --- focus ---
+
+// TestFocusStartsOnThePrompt: these are REPLs, so the command line has the keyboard by default.
+func TestFocusStartsOnThePrompt(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	_, promptFocused := model.Focused()
+	assert.True(t, promptFocused)
+}
+
+// TestTabWalksThePaneRingAndReturnsToThePrompt guards against a focus ring that can strand the
+// user away from the command line.
+func TestTabWalksThePaneRingAndReturnsToThePrompt(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+
+	// Shift+tab from the prompt enters the ring at the far end and walks back to the prompt.
+	press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
+	_, promptFocused := model.Focused()
+	require.False(t, promptFocused, "shift+tab should leave the prompt")
+
+	// Walking forward past the last pane must land back on the prompt rather than wrapping
+	// endlessly through the panes.
+	for range int(paneCount) + 1 {
+		press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
+	}
+	_, promptFocused = model.Focused()
+	assert.True(t, promptFocused, "the ring must return to the prompt")
+}
+
+// TestEscapeReturnsFocusToThePrompt covers the gap the design review found: no binding brought
+// the keyboard back from a pane.
+func TestEscapeReturnsFocusToThePrompt(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
+	require.False(t, mustPromptFocused(model))
+
+	press(t, model, tea.KeyPressMsg{Code: tea.KeyEscape})
+	assert.True(t, mustPromptFocused(model), "escape must bring the keyboard back to the prompt")
+}
+
+// mustPromptFocused reports whether the prompt holds the keyboard.
+func mustPromptFocused(model *Model) bool {
+	_, promptFocused := model.Focused()
+	return promptFocused
+}
+
+// TestTypingACommandNameDoesNotTriggerPaneActions is the scoping rule in practice: the letters
+// of "quit" include q, which is a pane binding.
+func TestTypingACommandNameDoesNotTriggerPaneActions(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	for _, r := range "quit" {
+		press(t, model, tea.KeyPressMsg{Code: r, Text: string(r)})
+	}
+	assert.False(t, model.Quitting(), "typing at the prompt must not fire pane bindings")
+	assert.Equal(t, "quit", model.PromptValue())
+}
+
+// --- commands ---
+
+func TestSubmittingACommandRunsItAndRecordsIt(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne})
+	runCmd(t, model, cmd)
+
+	shown, total := model.EventCount()
+	assert.Positive(t, total, "browsing the demo device should produce messages")
+	assert.Equal(t, shown, total)
+	assert.Contains(t, strings.Join(model.LogLines(), "\n"), "browse-direct")
+}
+
+func TestAFailedCommandRaisesAToastThatSurvivesUntilDismissed(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct demo://nowhere temp/1"})
+	runCmd(t, model, cmd)
+
+	require.NotEmpty(t, model.Toast(), "a failed command must leave a visible error")
+	assert.Contains(t, model.Toast(), "not connected")
+
+	// It must still be on screen: the old UI let errors scroll away in a ten-row console.
+	assert.Contains(t, strings.Join(renderLines(t, model), "\n"), "not connected")
+
+	press(t, model, tea.KeyPressMsg{Code: tea.KeyEscape})
+	assert.Empty(t, model.Toast(), "escape dismisses the toast")
+}
+
+func TestReadDirectShowsTheValueInTheDetailPane(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"})
+	runCmd(t, model, cmd)
+
+	event, ok := model.SelectedEvent()
+	require.True(t, ok, "the new message should be selected")
+	require.Len(t, event.Tags, 1)
+	assert.Equal(t, "temp/1", event.Tags[0].Address)
+
+	joined := strings.Join(renderLines(t, model), "\n")
+	assert.Contains(t, joined, "temp/1", "the tag should appear on screen")
+}
+
+func TestQuitCommandShutsDown(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "quit"})
+	runCmd(t, model, cmd)
+	assert.True(t, model.Quitting())
+}
+
+func TestClearEmptiesTheMessageTable(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne})
+	runCmd(t, model, cmd)
+	_, total := model.EventCount()
+	require.Positive(t, total)
+
+	_, cmd = model.Update(tui.PromptSubmitMsg{Line: "clear messages"})
+	runCmd(t, model, cmd)
+	_, total = model.EventCount()
+	assert.Zero(t, total)
+}
+
+func TestTheMessageFilterNarrowsTheTableWithoutLosingTheEvents(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne})
+	runCmd(t, model, cmd)
+	_, total := model.EventCount()
+	require.Greater(t, total, 1)
+
+	model.SetFilter("temp")
+	shown, stillTotal := model.EventCount()
+	assert.Equal(t, total, stillTotal, "filtering must not discard events")
+	assert.Less(t, shown, total, "the filter must actually narrow the table")
+	assert.Positive(t, shown)
+
+	model.SetFilter("")
+	shown, _ = model.EventCount()
+	assert.Equal(t, total, shown)
+}
+
+// --- subscriptions ---
+
+// TestASubscriptionStreamsIntoTheMessageTable exercises the re-arming command that drains a
+// channel without any goroutine touching model state.
+func TestASubscriptionStreamsIntoTheMessageTable(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "subscribe-direct " + plcsession.DemoDeviceOne + " temp/1"})
+	require.NotNil(t, cmd)
+	msg := cmd()
+	done, ok := msg.(commandDoneMsg)
+	require.True(t, ok)
+	require.NoError(t, done.err)
+	require.NotNil(t, done.result.Stream)
+
+	_, streamCmd := model.Update(done)
+	require.NotNil(t, streamCmd, "the model must arm a reader for the stream")
+
+	// One turn of the loop: the reader yields an event, which the model folds in and re-arms.
+	before, _ := model.EventCount()
+	eventMsg := streamCmd()
+	_, again := model.Update(eventMsg)
+	after, _ := model.EventCount()
+
+	assert.Greater(t, after, before, "a subscription event must reach the table")
+	assert.NotNil(t, again, "the reader must re-arm itself")
+
+	// Shutting down must cancel the subscription rather than leak it.
+	model.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
+	assert.True(t, model.Quitting())
+}
+
+// --- composer ---
+
+func TestTheComposerOpensForEachOfTheFourCommands(t *testing.T) {
+	for _, name := range []string{"read", "write", "browse", "subscribe"} {
+		t.Run(name, func(t *testing.T) {
+			model := sized(t, newTestModel(t), 120, 30)
+			_, cmd := model.Update(tui.PromptSubmitMsg{Line: name + " " + plcsession.DemoDeviceOne})
+			runCmd(t, model, cmd)
+			assert.True(t, model.ComposerOpen(), "%s must open the request composer", name)
+
+			joined := strings.Join(renderLines(t, model), "\n")
+			assert.Contains(t, strings.ToLower(joined), "request", "the form should be on screen")
+		})
+	}
+}
+
+func TestTheComposerValidatesBeforeSubmitting(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne})
+	runCmd(t, model, cmd)
+	require.True(t, model.ComposerOpen())
+
+	// The pre-filled row has no address yet, so submitting must complain rather than send.
+	press(t, model, tea.KeyPressMsg{Code: tea.KeyEnter})
+	assert.Contains(t, model.ComposerProblem(), "address")
+	assert.True(t, model.ComposerOpen(), "an invalid form stays open")
+}
+
+func TestTheComposerRunsAValidRequest(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne + " temp/1"})
+	runCmd(t, model, cmd)
+	require.True(t, model.ComposerOpen())
+
+	_, submitCmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+	require.NotNil(t, submitCmd)
+	model.Update(submitCmd())
+
+	shown, _ := model.EventCount()
+	assert.Positive(t, shown, "the composed read must produce a message")
+}
+
+func TestEscapeClosesTheComposerAndReturnsToThePrompt(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne})
+	runCmd(t, model, cmd)
+	require.True(t, model.ComposerOpen())
+
+	press(t, model, tea.KeyPressMsg{Code: tea.KeyEscape})
+	assert.False(t, model.ComposerOpen())
+	assert.True(t, mustPromptFocused(model))
+}
+
+func TestTheComposerCanGrowATagRow(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne + " temp/1"})
+	runCmd(t, model, cmd)
+	require.True(t, model.ComposerOpen())
+
+	before := len(model.composer.rows)
+	press(t, model, tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl})
+	assert.Equal(t, before+1, len(model.composer.rows), "ctrl+n adds a tag row")
+}
+
+// TestTheComposerOverlaysWithoutBreakingTheGeometry: the modal is composited over the panes,
+// and compositing must not change the screen's shape.
+func TestTheComposerOverlaysWithoutBreakingTheGeometry(t *testing.T) {
+	model := sized(t, newTestModel(t), 100, 30)
+	_, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne})
+	runCmd(t, model, cmd)
+	require.True(t, model.ComposerOpen())
+
+	lines := renderLines(t, model)
+	assert.Len(t, lines, 30)
+	for i, line := range lines {
+		assert.Equal(t, 100, lipgloss.Width(line), "line %d: %q", i, line)
+	}
+}
+
+// --- sidebar ---
+
+// TestSelectingASidebarRowDoesSomething is the regression test for the dead affordances: both
+// handlers in the previous UI contained only a TODO comment.
+func TestSelectingASidebarRowDoesSomething(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+
+	// Enter the pane ring and land on the sidebar.
+	press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
+	for {
+		focus, promptFocused := model.Focused()
+		if !promptFocused && focus == paneSidebar {
+			break
+		}
+		press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
+	}
+
+	row, ok := model.SidebarSelection()
+	require.True(t, ok)
+	require.True(t, row.selectable())
+
+	press(t, model, tea.KeyPressMsg{Code: tea.KeyEnter})
+	assert.NotEmpty(t, model.PromptValue(), "selecting a row must do something, not nothing")
+}
+
+func TestTheSidebarCursorSkipsSectionHeadings(t *testing.T) {
+	model := sized(t, newTestModel(t), 120, 30)
+	for range 20 {
+		row, ok := model.SidebarSelection()
+		require.True(t, ok)
+		assert.True(t, row.selectable(), "the cursor must never rest on a heading")
+		model.moveSidebar(1)
+	}
+}
+
+func TestTheSidebarShowsAnEmptyStateNamingTheNextCommand(t *testing.T) {
+	session := plcsession.NewDemo(plcsession.DemoOptions{})
+	t.Cleanup(func() { _ = session.Close() })
+	config := NewConfig()
+	theme := testTheme()
+	model := NewModel(Options{Session: session, Config: &config, Theme: &theme, Now: steppingClock()})
+	model = sized(t, model, 120, 30)
+
+	joined := strings.Join(renderLines(t, model), "\n")
+	assert.Contains(t, joined, "connect", "an empty pane should name the command that fills it")
+}
+
+// --- background colour ---
+
+// TestTheThemeFollowsTheTerminalBackground covers the only reliable way to stay legible.
+func TestTheThemeFollowsTheTerminalBackground(t *testing.T) {
+	session := plcsession.NewDemo(plcsession.DemoOptions{})
+	t.Cleanup(func() { _ = session.Close() })
+	config := NewConfig()
+	// No Theme override, so the model derives one from the reported background.
+	model := NewModel(Options{Session: session, Config: &config, Now: steppingClock()})
+	model = sized(t, model, 120, 30)
+
+	dark := model.theme.IsDark()
+	model.Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#ffffff")})
+	assert.NotEqual(t, dark, model.theme.IsDark(), "a light background must switch the palette")
+}
diff --git a/plc4go/tools/plc4xbrowser/browser/run.go b/plc4go/tools/plc4xbrowser/browser/run.go
new file mode 100644
index 00000000..8bd7872d
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/run.go
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"context"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/apache/plc4x/plc4go/spi/errors"
+	"github.com/charmbracelet/fang"
+	"github.com/rs/zerolog"
+	"github.com/rs/zerolog/log"
+	"github.com/spf13/cobra"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession"
+)
+
+// The command line.
+//
+// plc4xbrowser previously had none at all: main called straight into the tview application, so
+// there was nowhere to put a flag. It gains a cobra root here, wrapped in fang for the same
+// styled help and errors the pcap analyser now uses.
+
+// Settings are the command-line settings.
+type Settings struct {
+	// Demo runs against the simulated devices instead of real hardware.
+	Demo bool
+	// ASCII forces the ASCII glyph set for terminals that cannot render box-drawing characters.
+	ASCII bool
+	// LogLevel overrides the persisted log level.
+	LogLevel string
+	// Version labels the status bar.
+	Version string
+}
+
+// Execute runs the browser's command line.
+//
+// WithoutVersion is deliberate: fang offers a version flag, and adding one is out of scope, so
+// it is suppressed rather than acquired as a side effect of adopting fang.
+func Execute(version string) error {
+	settings := Settings{Version: version}
+	root := NewRootCommand(&settings)
+	return fang.Execute(context.Background(), root, fang.WithoutVersion())
+}
+
+// NewRootCommand builds the cobra root.
+func NewRootCommand(settings *Settings) *cobra.Command {
+	root := &cobra.Command{
+		Use:   "plc4xbrowser",
+		Short: "Browse and exercise PLC connections from the terminal",
+		Long: "plc4xbrowser connects to PLCs through plc4x and lets you discover devices, browse\n" +
+			"their tags, and read, write and subscribe to them from a terminal interface.\n\n" +
+			"Run it with --demo to try it against simulated devices, with no hardware attached.",
+		Example: "  plc4xbrowser --demo\n" +
+			"  plc4xbrowser --log-level debug\n" +
+			"  plc4xbrowser --ascii",
+		SilenceUsage: true,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			return Run(cmd.Context(), *settings)
+		},
+	}
+	root.Flags().BoolVar(&settings.Demo, "demo", false,
+		"run against simulated devices, for demos and manual debugging with no hardware attached")
+	root.Flags().BoolVar(&settings.ASCII, "ascii", false,
+		"force ASCII drawing characters instead of Unicode, for terminals that cannot render them")
+	root.Flags().StringVar(&settings.LogLevel, "log-level", "",
+		"log level (trace, debug, info, warn, error); defaults to the saved setting")
+	return root
+}
+
+// Run starts the terminal interface.
+func Run(ctx context.Context, settings Settings) error {
+	if ctx == nil {
+		ctx = context.Background()
+	}
+
+	config := NewConfig()
+	if path, err := ConfigPath(); err == nil {
+		if loaded, loadErr := LoadConfig(path); loadErr == nil {
+			config = loaded
+		}
+	}
+	if settings.LogLevel != "" {
+		config.LogLevel = settings.LogLevel
+	}
+	applyLogLevel(config.LogLevel)
+
+	session, demo, err := newSession(ctx, settings, &config)
+	if err != nil {
+		return err
+	}
+
+	model := NewModel(Options{
+		Session:    session,
+		Config:     &config,
+		Demo:       demo,
+		Version:    settings.Version,
+		ForceASCII: settings.ASCII,
+	})
+	if demo {
+		model.AppendLog("demo mode: simulated devices, no hardware attached")
+		model.AppendLog("connected " + plcsession.DemoDeviceOne + " — try: browse-direct " + plcsession.DemoDeviceOne)
+	}
+
+	program := tea.NewProgram(model, tea.WithContext(ctx))
+	_, err = program.Run()
+	return err
+}
+
+// newSession builds the session the interface runs on.
+//
+// Demo mode swaps this one value and nothing else: the model, the commands and the tests all
+// run against the same seam, so demo mode cannot drift away from the real thing.
+func newSession(ctx context.Context, settings Settings, config *Config) (plcsession.Session, bool, error) {
+	if settings.Demo {
+		session := plcsession.NewDemo(plcsession.DemoOptions{})
+		if _, err := session.RegisterDriver(plcsession.DemoProtocol); err != nil {
+			return nil, false, errors.Wrap(err, "error preparing the demo session")
+		}
+		// Open a connection at startup so the tool has something to show immediately; a demo
+		// that opens on an empty screen demonstrates nothing.
+		if _, err := session.Connect(ctx, plcsession.DemoDeviceOne); err != nil {
+			return nil, false, errors.Wrap(err, "error opening the demo connection")
+		}
+		return session, true, nil
+	}
+
+	session := plcsession.NewLive(plcsession.LiveOptions{})
+	for _, protocol := range config.AutoRegisterDrivers {
+		if _, err := session.RegisterDriver(protocol); err != nil {
+			log.Warn().Err(err).Str("protocol", protocol).Msg("Could not auto-register driver")
+		}
+	}
+	return session, false, nil
+}
+
+// applyLogLevel sets the global log level, falling back to info rather than exiting.
+//
+// The pcap analyser's equivalent called log.Fatal on an unparseable level, which kills the
+// process over a typo in a configuration file. A bad level here is worth a warning, not a
+// refusal to start.
+func applyLogLevel(level string) {
+	if level == "" {
+		zerolog.SetGlobalLevel(zerolog.InfoLevel)
+		return
+	}
+	parsed, err := zerolog.ParseLevel(level)
+	if err != nil {
+		log.Warn().Str("level", level).Msg("Unknown log level, using info")
+		parsed = zerolog.InfoLevel
+	}
+	zerolog.SetGlobalLevel(parsed)
+}
diff --git a/plc4go/tools/plc4xbrowser/browser/view.go b/plc4go/tools/plc4xbrowser/browser/view.go
new file mode 100644
index 00000000..c5c1681a
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/browser/view.go
@@ -0,0 +1,485 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package browser
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession"
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/tui"
+)
+
+// Rendering.
+//
+// Every line the model emits is exactly the terminal width, and the last three rows -- toast,
+// prompt and help -- are always present. That is not stylistic: below 100 columns the previous
+// implementation added its command area to the grid with a zero row and column span, which the
+// grid silently discarded, so the input field was never drawn and the user typed into
+// something invisible.
+
+// View renders the whole screen.
+func (m *Model) View() tea.View {
+	view := tea.NewView(m.render())
+	view.AltScreen = true
+	return view
+}
+
+// render builds the screen content.
+func (m *Model) render() string {
+	if !m.ready {
+		return ""
+	}
+	width, height := m.layout.Size.Width, m.layout.Size.Height
+
+	if m.layout.Mode == tui.ModeTooSmall {
+		return m.renderTooSmall(width, height)
+	}
+
+	var rows []string
+	rows = append(rows, m.renderStatus(width))
+	rows = append(rows, m.renderBody()...)
+	if m.layout.ShowLog {
+		rows = append(rows, splitLines(m.renderLogPane(width))...)
+	}
+	rows = append(rows, m.renderToast(width))
+	rows = append(rows, m.renderPrompt(width))
+	rows = append(rows, m.renderHelp(width))
+	rows = fitScreen(rows, width, height)
+
+	// Both overlays float over the panes rather than taking rows of their own. lipgloss v2 has
+	// a real compositor, so neither is spliced into strings by hand, and neither can push the
+	// prompt or the help footer off the bottom of the screen.
+	if completion := m.prompt.CompletionView(completionRows); completion != "" {
+		rows = m.overlayCompletion(rows, completion, width, height)
+	}
+	if m.composer != nil {
+		rows = m.overlayComposer(rows, width, height)
+	}
+
+	return strings.Join(fitScreen(rows, width, height), "\n")
+}
+
+// renderTooSmall says what the tool needs rather than drawing something broken.
+func (m *Model) renderTooSmall(width, height int) string {
+	message := tui.TooSmallMessage() + " (" + strconv.Itoa(tui.MinWidth) + "x" + strconv.Itoa(tui.MinHeight) + " minimum)"
+	lines := make([]string, 0, height)
+	for i := range height {
+		if i == height/2 {
+			lines = append(lines, centre(m.theme.Err.Render(message), width))
+			continue
+		}
+		lines = append(lines, strings.Repeat(" ", width))
+	}
+	return strings.Join(lines, "\n")
+}
+
+// renderStatus draws the top line: what the tool is, whether it is simulated, and the counts.
+//
+// It degrades rather than overflowing. At the widths this tool has to work at, the full line
+// does not fit -- the approved design is about 91 cells wide and the target is 80 -- so pieces
+// are dropped in order of how little they are missed.
+func (m *Model) renderStatus(width int) string {
+	theme := m.theme
+	glyphs := theme.Glyphs
+
+	name := "PLC4X Browser"
+	if m.options.Version != "" {
+		name += " " + m.options.Version
+	}
+	shown, total := m.EventCount()
+	counts := fmt.Sprintf("conn %d %s drv %d %s msg %d",
+		len(m.options.Session.Connections()), glyphs.Separator,
+		len(m.options.Session.Drivers()), glyphs.Separator, total)
+	if shown != total {
+		counts += fmt.Sprintf(" (%d shown)", shown)
+	}
+	logLevel := m.options.Config.LogLevel
+	if logLevel == "" {
+		logLevel = "info"
+	}
+
+	badge := ""
+	if m.options.Demo {
+		badge = theme.Badge.Render("DEMO")
+	}
+
+	// Widest first; each candidate drops the least useful remaining piece.
+	focusName := "pane " + m.focusName()
+	candidates := [][]string{
+		{name, badge, counts, "log " + logLevel, focusName},
+		{name, badge, counts, "log " + logLevel},
+		{name, badge, counts},
+		{name, badge},
+		{name},
+	}
+	separator := " " + theme.Muted.Render(glyphs.Separator) + " "
+	for _, candidate := range candidates {
+		parts := make([]string, 0, len(candidate))
+		for i, part := range candidate {
+			if part == "" {
+				continue
+			}
+			if i == 0 {
+				parts = append(parts, theme.Title.Render(part))
+				continue
+			}
+			if part == badge {
+				parts = append(parts, part)
+				continue
+			}
+			parts = append(parts, theme.Muted.Render(part))
+		}
+		line := " " + strings.Join(parts, separator)
+		if lipgloss.Width(line) <= width {
+			return padTo(line, width)
+		}
+	}
+	return padTo(" "+theme.Title.Render(name), width)
+}
+
+// focusName names the region holding the keyboard.
+func (m *Model) focusName() string {
+	if m.promptFocused {
+		return "prompt"
+	}
+	return strings.ToLower(m.focus.title())
+}
+
+// renderBody draws the pane area for the current layout regime.
+func (m *Model) renderBody() []string {
+	if m.layout.Mode == tui.ModeCompact {
+		return splitLines(m.renderMessagesPane(m.layout.Size.Width, m.layout.BodyHeight))
+	}
+
+	sidebar := m.renderSidebarPane(m.layout.SidebarWidth, m.layout.BodyHeight)
+	main := m.renderMainColumn(m.layout.MainWidth, m.layout.BodyHeight)
+	if !m.layout.ShowDetail {
+		return splitLines(lipgloss.JoinHorizontal(lipgloss.Top, sidebar, main))
+	}
+	detail := m.renderDetailPane(m.layout.DetailWidth, m.layout.BodyHeight)
+	return splitLines(lipgloss.JoinHorizontal(lipgloss.Top, sidebar, main, detail))
+}
+
+// renderMainColumn draws the message table, with the detail pane beneath it when the layout
+// puts detail in the main column rather than beside it.
+func (m *Model) renderMainColumn(width, height int) string {
+	return m.renderMessagesPane(width, height)
+}
+
+// renderSidebarPane draws the connections and drivers.
+func (m *Model) renderSidebarPane(width, height int) string {
+	theme := m.theme
+	glyphs := theme.Glyphs
+	inner := max(width-2, 1)
+
+	var lines []string
+	for i, row := range m.sidebarRows {
+		switch {
+		case row.heading != "":
+			if len(lines) > 0 {
+				lines = append(lines, "")
+			}
+			lines = append(lines, theme.Key.Render(row.heading))
+		default:
+			marker := " "
+			if i == m.sidebarIndex && m.focus == paneSidebar && !m.promptFocused {
+				marker = glyphs.Selected
+			}
+			label := row.label
+			if row.detail != "" {
+				label += " " + theme.Muted.Render(row.detail)
+			}
+			text := marker + " " + label
+			if row.marker != "" {
+				text = fitInline(text, inner-2) + " " + row.marker
+			}
+			if i == m.sidebarIndex && m.focus == paneSidebar && !m.promptFocused {
+				text = theme.SelectedRow.Render(marker + " " + row.label)
+				if row.marker != "" {
+					text = fitInline(text, inner-2) + " " + row.marker
+				}
+			}
+			lines = append(lines, text)
+		}
+	}
+
+	status := strconv.Itoa(len(m.options.Session.Connections()))
+	pane := tui.Pane{
+		Title:   "Session",
+		Status:  status,
+		Width:   width,
+		Height:  height,
+		Focused: m.focus == paneSidebar && !m.promptFocused,
+	}
+	return pane.Render(theme, strings.Join(lines, "\n"))
+}
+
+// renderMessagesPane draws the message table.
+func (m *Model) renderMessagesPane(width, height int) string {
+	theme := m.theme
+	shown, total := m.EventCount()
+
+	status := strconv.Itoa(total)
+	if shown != total {
+		status = strconv.Itoa(shown) + " of " + strconv.Itoa(total)
+	}
+	if m.filter != "" {
+		status = "/" + m.filter + " " + status
+	}
+
+	body := m.messages.View()
+	if total == 0 {
+		// An empty pane that names the next command beats an empty pane.
+		body = theme.Muted.Render("no messages yet") + "\n\n" +
+			theme.Muted.Render("try: read-direct <connection> <tag>") + "\n" +
+			theme.Muted.Render("  or: browse-direct <connection>")
+	}
+
+	pane := tui.Pane{
+		Title:   "Messages",
+		Status:  status,
+		Width:   width,
+		Height:  height,
+		Focused: m.focus == paneMessages && !m.promptFocused,
+	}
+	return pane.Render(theme, body)
+}
+
+// renderDetailPane draws the selected message.
+func (m *Model) renderDetailPane(width, height int) string {
+	theme := m.theme
+	status := ""
+	body := theme.Muted.Render("select a message")
+	if event, ok := m.SelectedEvent(); ok {
+		status = string(event.Kind)
+		body = m.detail.View()
+	}
+	pane := tui.Pane{
+		Title:   "Detail",
+		Status:  status,
+		Width:   width,
+		Height:  height,
+		Focused: m.focus == paneDetail && !m.promptFocused,
+	}
+	return pane.Render(theme, body)
+}
+
+// renderLogPane draws the console drawer.
+func (m *Model) renderLogPane(width int) string {
+	theme := m.theme
+	level := m.options.Config.LogLevel
+	if level == "" {
+		level = "info"
+	}
+	body := m.logView.View()
+	if len(m.logLines) == 0 {
+		body = theme.Muted.Render("no output yet")
+	}
+	pane := tui.Pane{
+		Title:   "Log " + theme.Glyphs.Separator + " " + level,
+		Status:  strconv.Itoa(len(m.logLines)),
+		Width:   width,
+		Height:  m.layout.LogHeight,
+		Focused: m.focus == paneLog && !m.promptFocused,
+	}
+	return pane.Render(theme, body)
+}
+
+// renderToast draws the held error line, or an empty row to keep the geometry stable.
+func (m *Model) renderToast(width int) string {
+	if m.toast == "" {
+		return strings.Repeat(" ", width)
+	}
+	theme := m.theme
+	hint := theme.Muted.Render("esc dismiss")
+	message := theme.Err.Render(theme.Glyphs.Err + " " + m.toast)
+	line := " " + message
+	// Right-align the hint when it fits; drop it rather than wrap when it does not.
+	if gap := width - lipgloss.Width(line) - lipgloss.Width(hint) - 1; gap > 0 {
+		line += strings.Repeat(" ", gap) + hint
+	}
+	return fitInline(line, width)
+}
+
+// renderPrompt draws the command line.
+//
+// It is emitted after everything droppable and before the help footer, so no layout decision
+// can remove it.
+func (m *Model) renderPrompt(width int) string {
+	return fitInline(" "+m.prompt.View(), width)
+}
+
+// overlayCompletion floats the completion list directly above the prompt.
+func (m *Model) overlayCompletion(rows []string, completion string, width, height int) []string {
+	lines := splitLines(completion)
+	// Anchor it so its last line sits just above the prompt, which is the second-to-last row,
+	// and cover whole rows: a partial-width overlay lets the pane underneath show through at
+	// the margins, which reads as a corrupted screen rather than as a popup.
+	y := max(height-2-len(lines), 0)
+
+	base := lipgloss.NewLayer(strings.Join(rows, "\n"))
+	overlay := lipgloss.NewLayer(m.opaqueBlock(indentBlock(completion, 3), width)).X(0).Y(y).Z(1)
+	return splitLines(lipgloss.NewCompositor(base, overlay).Render())
+}
+
+// opaqueBlock squares a block off to a single rectangle, clipped to a maximum width.
+//
+// A floating list has to be opaque. Left ragged, the pane underneath shows through the short
+// lines and the result reads as a corrupted screen rather than as a popup.
+func (m *Model) opaqueBlock(block string, maxWidth int) string {
+	lines := splitLines(block)
+	for i, line := range lines {
+		lines[i] = fitInline(line, maxWidth)
+	}
+	return strings.Join(lines, "\n")
+}
+
+// indentBlock shifts every line of a block right, so the completion list sits under the text
+// of the prompt rather than under its "$" marker.
+func indentBlock(block string, by int) string {
+	prefix := strings.Repeat(" ", by)
+	lines := splitLines(block)
+	for i, line := range lines {
+		lines[i] = prefix + line
+	}
+	return strings.Join(lines, "\n")
+}
+
+// completionRows bounds the completion popup so it cannot push the prompt off the screen.
+const completionRows = 4
+
+// renderHelp draws the key hints.
+func (m *Model) renderHelp(width int) string {
+	return fitInline(" "+m.help.View(m.keys.ForFocus(m.currentFocus())), width)
+}
+
+// overlayComposer composites the modal form over the pane area.
+func (m *Model) overlayComposer(rows []string, width, height int) []string {
+	form := m.renderComposer(width, height)
+	formWidth := lipgloss.Width(strings.SplitN(form, "\n", 2)[0])
+	formHeight := len(splitLines(form))
+
+	// Centre it over the panes, never over the prompt: the form is modal but the command line
+	// stays visible so the user can see what they were doing.
+	x := max((width-formWidth)/2, 0)
+	y := max((height-tui.ReservedRows-formHeight)/2, 1)
+
+	base := lipgloss.NewLayer(strings.Join(rows, "\n"))
+	overlay := lipgloss.NewLayer(form).X(x).Y(y).Z(1)
+	return splitLines(lipgloss.NewCompositor(base, overlay).Render())
+}
+
+// renderEventDetail renders one message for the detail pane.
+func renderEventDetail(theme tui.Theme, event plcsession.Event) string {
+	glyphs := theme.Glyphs
+	var lines []string
+
+	header := theme.Value.Render(string(event.Kind))
+	if event.Connection != "" {
+		header += " " + theme.Muted.Render(event.Connection)
+	}
+	lines = append(lines, header)
+	lines = append(lines, theme.Muted.Render("received "+event.Received.Format("15:04:05.000")))
+	if event.Summary != "" {
+		lines = append(lines, "", theme.Muted.Render(event.Summary))
+	}
+
+	if len(event.Tags) > 0 {
+		lines = append(lines, "")
+		for _, tag := range event.Tags {
+			marker := theme.Ok.Render(glyphs.Ok)
+			if !tag.Succeeded() {
+				marker = theme.Err.Render(glyphs.Err)
+			}
+			line := marker + " " + theme.Value.Render(tag.Address)
+			if tag.DataType != "" {
+				line += " " + theme.Muted.Render(tag.DataType)
+			}
+			if tag.Value != "" {
+				line += " " + theme.Value.Render(tag.Value)
+			}
+			if !tag.Succeeded() {
+				line += " " + theme.Err.Render(tag.Code)
+			}
+			lines = append(lines, line)
+		}
+	}
+	return strings.Join(lines, "\n")
+}
+
+// fitScreen forces the rendered rows into exactly height lines of exactly width cells.
+//
+// This is the last line of defence: a pane that miscounts by one cell would otherwise tear
+// every row beneath it.
+func fitScreen(rows []string, width, height int) []string {
+	out := make([]string, 0, height)
+	for i := range height {
+		if i < len(rows) {
+			out = append(out, fitInline(rows[i], width))
+			continue
+		}
+		out = append(out, strings.Repeat(" ", width))
+	}
+	return out
+}
+
+// fitInline pads or truncates one line to exactly width display cells.
+func fitInline(line string, width int) string {
+	actual := lipgloss.Width(line)
+	switch {
+	case actual == width:
+		return line
+	case actual < width:
+		return line + strings.Repeat(" ", width-actual)
+	default:
+		return lipgloss.NewStyle().MaxWidth(width).Render(line)
+	}
+}
+
+// padTo pads a line to a width without truncating it.
+func padTo(line string, width int) string {
+	if gap := width - lipgloss.Width(line); gap > 0 {
+		return line + strings.Repeat(" ", gap)
+	}
+	return line
+}
+
+// centre centres text in a width.
+func centre(text string, width int) string {
+	gap := width - lipgloss.Width(text)
+	if gap <= 0 {
+		return fitInline(text, width)
+	}
+	left := gap / 2
+	return strings.Repeat(" ", left) + text + strings.Repeat(" ", gap-left)
+}
+
+// splitLines splits rendered output into lines.
+func splitLines(s string) []string {
+	if s == "" {
+		return nil
+	}
+	return strings.Split(s, "\n")
+}
diff --git a/plc4go/tools/plc4xbrowser/main.go b/plc4go/tools/plc4xbrowser/main.go
index 7a1cd1bf..b1ce96b0 100644
--- a/plc4go/tools/plc4xbrowser/main.go
+++ b/plc4go/tools/plc4xbrowser/main.go
@@ -20,16 +20,19 @@
 package main
 
 import (
-	"github.com/apache/plc4x-extras/plc4go/tools/plc4xbrowser/ui"
+	"os"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xbrowser/browser"
 )
 
-func main() {
-	ui.LoadConfig()
-	application := ui.SetupApplication()
-	ui.InitSubsystem()
+// version labels the status bar. It is a build-time value; the tool deliberately has no
+// --version flag.
+var version = "1.0.0-SNAPSHOT"
 
-	if err := application.Run(); err != nil {
-		panic(err)
+func main() {
+	// Execute reports its own errors through fang's styled handler, so main only needs to set
+	// the exit status.
+	if err := browser.Execute(version); err != nil {
+		os.Exit(1)
 	}
-	ui.Shutdown()
 }
diff --git a/plc4go/tools/plc4xbrowser/ui/actions.go b/plc4go/tools/plc4xbrowser/ui/actions.go
deleted file mode 100644
index 5b5ad24c..00000000
--- a/plc4go/tools/plc4xbrowser/ui/actions.go
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package ui
-
-import (
-	"bytes"
-	"fmt"
-	"slices"
-	"strings"
-
-	plc4go "github.com/apache/plc4x/plc4go/pkg/api"
-	"github.com/apache/plc4x/plc4go/pkg/api/drivers"
-	"github.com/apache/plc4x/plc4go/pkg/api/transports"
-	"github.com/apache/plc4x/plc4go/spi/errors"
-	"github.com/rivo/tview"
-	"github.com/rs/zerolog"
-	"github.com/rs/zerolog/log"
-)
-
-func InitSubsystem() {
-	logLevel := zerolog.InfoLevel
-	if configuredLevel := config.LogLevel; configuredLevel != "" {
-		if parsedLevel, err := zerolog.ParseLevel(configuredLevel); err != nil {
-			panic(err)
-		} else {
-			logLevel = parsedLevel
-		}
-	}
-
-	log.Logger = log.
-		//// Enable below if you want to see the filenames
-		//With().Caller().Logger().
-		Output(zerolog.NewConsoleWriter(
-			func(w *zerolog.ConsoleWriter) {
-				w.Out = tview.ANSIWriter(consoleOutput)
-			},
-			func(w *zerolog.ConsoleWriter) {
-				w.FormatFieldValue = func(i any) string {
-					if aString, ok := i.(string); ok && strings.Contains(aString, "\\n") {
-						return fmt.Sprintf("\x1b[%dm%v\x1b[0m", 31, "see below")
-					}
-					return fmt.Sprintf("%s", i)
-				}
-				w.FormatExtra = func(m map[string]any, buffer *bytes.Buffer) error {
-					for key, i := range m {
-						if aString, ok := i.(string); ok && strings.Contains(aString, "\n") {
-							buffer.WriteString("\n")
-							buffer.WriteString(fmt.Sprintf("\x1b[%dm%v\x1b[0m", 32, "field "+key))
-							buffer.WriteString(":\n" + aString)
-						}
-					}
-					return nil
-				}
-			},
-		),
-		).
-		Level(logLevel)
-
-	driverManager = plc4go.NewPlcDriverManager()
-
-	// We offset the commands executed with the last commands
-	commandsExecuted = len(config.History.Last10Commands)
-	outputCommandHistory()
-
-	for _, driver := range config.AutoRegisterDrivers {
-		log.Info().Str("driver", driver).Msg("Auto register driver")
-		if err := validateDriverParam(driver); err != nil {
-			log.Err(err).Msg("Invalid configuration")
-			continue
-		}
-		_ = registerDriver(driver)
-	}
-}
-
-func outputCommandHistory() {
-	_, _ = fmt.Fprintln(commandOutput, "[#0000ff]Last 10 commands[white]")
-	for i, command := range config.History.Last10Commands {
-		_, _ = fmt.Fprintf(commandOutput, "   [#00ff00]%d[white]: [\"%d\"]%s[\"\"]\n", i, i, tview.Escape(command))
-	}
-}
-
-func validateDriverParam(driver string) error {
-	if slices.Contains(protocolList, driver) {
-		return nil
-	}
-	return errors.Errorf("protocol %s not found", driver)
-}
-
-var tcpRegistered, udpRegistered bool
-
-func registerDriver(driverId string) error {
-	if _, ok := registeredDrivers[driverId]; ok {
-		return errors.Errorf("%s already registered", driverId)
-	}
-	var driver plc4go.PlcDriver
-	switch driverId {
-	case "ads":
-		driver = drivers.RegisterAdsDriver(driverManager)
-		if !tcpRegistered {
-			transports.RegisterTcpTransport(driverManager)
-			tcpRegistered = true
-		}
-	case "bacnetip":
-		driver = drivers.RegisterBacnetDriver(driverManager)
-		if !udpRegistered {
-			transports.RegisterUdpTransport(driverManager)
-			udpRegistered = true
-		}
-	case "c-bus":
-		driver = drivers.RegisterCBusDriver(driverManager)
-		if !tcpRegistered {
-			transports.RegisterTcpTransport(driverManager)
-			tcpRegistered = true
-		}
-	case "s7":
-		driver = drivers.RegisterS7Driver(driverManager)
-		if !tcpRegistered {
-			transports.RegisterTcpTransport(driverManager)
-			tcpRegistered = true
-		}
-	case "opcua":
-		driver = drivers.RegisterOpcuaDriver(driverManager)
-		if !tcpRegistered {
-			transports.RegisterTcpTransport(driverManager)
-			tcpRegistered = true
-		}
-	default:
-		return errors.Errorf("Unknown driver %s", driverId)
-	}
-	registeredDrivers[driverId] = driver
-	go driverAdded(driver)
-	return nil
-}
diff --git a/plc4go/tools/plc4xbrowser/ui/commands.go b/plc4go/tools/plc4xbrowser/ui/commands.go
deleted file mode 100644
index 583b209b..00000000
--- a/plc4go/tools/plc4xbrowser/ui/commands.go
+++ /dev/null
@@ -1,803 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package ui
-
-import (
-	"context"
-	"fmt"
-	"net/url"
-	"runtime/debug"
-	"strings"
-	"time"
-
-	plc4xConfig "github.com/apache/plc4x/plc4go/pkg/api/config"
-	apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
-	"github.com/apache/plc4x/plc4go/spi/errors"
-	"github.com/rivo/tview"
-	"github.com/rs/zerolog"
-	"github.com/rs/zerolog/log"
-)
-
-const rootCommandIndicator = "rootCommand"
-
-var commands = map[inputMode]Command{
-	normalMode:        rootCommand,
-	readEditMode:      rootCommand,
-	writeEditMode:     rootCommand,
-	subscribeEditMode: rootCommand,
-}
-
-var rootCommand = Command{
-	Name: rootCommandIndicator,
-	subCommands: []Command{
-		{
-			Name:        "discover",
-			Description: "Discovers devices",
-			action: func(ctx context.Context, _ Command, driverId string) error {
-				if driver, ok := registeredDrivers[driverId]; ok {
-					if !driver.SupportsDiscovery() {
-						return errors.Errorf("%s doesn't support discovery", driverId)
-					}
-					return driver.Discover(ctx, func(event apiModel.PlcDiscoveryItem) {
-						_, _ = fmt.Fprintf(messageOutput, "%v\n", event)
-					})
-				} else {
-					return errors.Errorf("%s not registered", driverId)
-				}
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for _, protocol := range protocolList {
-					entries = append(entries, protocol)
-				}
-				return
-			},
-		},
-		{
-			Name:        "connect",
-			Description: "Connects to a device",
-			action: func(ctx context.Context, _ Command, connectionString string) error {
-				log.Info().Str("connectionString", connectionString).Msg("connect connectionString")
-				connectionUrl, err := url.Parse(connectionString)
-				if err != nil {
-					return errors.Wrapf(err, "can't parse connection url %s", connectionString)
-				}
-				addHostHistoryEntry(connectionUrl.Host)
-				connectionId := fmt.Sprintf("%s://%s", connectionUrl.Scheme, connectionUrl.Host)
-				if _, ok := connections[connectionId]; ok {
-					return errors.Errorf("%s already connected", connectionId)
-				}
-				connection, err := driverManager.GetConnection(ctx, connectionString)
-				if err != nil {
-					return errors.Wrapf(err, "%s can't connect to", connectionUrl.Host)
-				}
-				log.Info().Str("connectionId", connectionId).Msg("connected")
-				connections[connectionId] = connection
-				connectionsChanged()
-				return nil
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for _, protocol := range protocolList {
-					if strings.HasPrefix(currentText, protocol) {
-						for _, host := range config.History.Last10Hosts {
-							entries = append(entries, protocol+"://"+host)
-						}
-						entries = append(entries, currentText)
-					} else {
-						entries = append(entries, protocol)
-					}
-				}
-				return
-			},
-		},
-		{
-			Name:        "disconnect",
-			Description: "Disconnect a connection",
-			action: func(ctx context.Context, _ Command, connectionString string) error {
-				if connection, ok := connections[connectionString]; !ok {
-					return errors.Errorf("%s not connected", connectionString)
-				} else {
-					err := connection.Close()
-					log.Info().Str("connectionString", connectionString).Msg("connectionString disconnected")
-					delete(connections, connectionString)
-					connectionsChanged()
-					if err != nil {
-						return errors.Wrapf(err, "%s can't close", connectionString)
-					}
-				}
-				return nil
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					entries = append(entries, connectionsString)
-				}
-				return
-			},
-		},
-		{
-			Name:        "read",
-			Description: "Starts a read request (switched mode to read edit)",
-			action: func(ctx context.Context, _ Command, connectionsString string) error {
-				if connection, ok := connections[connectionsString]; !ok {
-					return errors.Errorf("%s not connected", connectionsString)
-				} else {
-					return errors.Errorf("%s mode switch not yet implemented", connection)
-				}
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					entries = append(entries, connectionsString)
-				}
-				return
-			},
-		},
-		{
-			Name:        "read-direct",
-			Description: "Builds a read request with the supplied field",
-			action: func(ctx context.Context, c Command, connectionsStringAndFieldQuery string) error {
-				split := strings.Split(connectionsStringAndFieldQuery, " ")
-				if len(split) != 2 {
-					return errors.Errorf("%s expects exactly two arguments [connection url] [fieldQuery]", c)
-				}
-				connectionsString := split[0]
-				if connection, ok := connections[connectionsString]; !ok {
-					return errors.Errorf("%s not connected", connectionsString)
-				} else {
-					start := time.Now()
-					readRequest, err := connection.ReadRequestBuilder().
-						AddTagAddress("readField", split[1]).
-						Build()
-					if err != nil {
-						return errors.Wrapf(err, "%s can't read", connectionsString)
-					}
-					readRequestResult := <-readRequest.Execute(ctx)
-					if err := readRequestResult.GetErr(); err != nil {
-						return errors.Wrapf(err, "%s can't read", connectionsString)
-					}
-					plc4xBrowserLog.Debug().TimeDiff("runtime", time.Now(), start).Msg("read took runtime")
-					if err := readRequestResult.GetErr(); err != nil {
-						return errors.Wrapf(err, "%s error reading", connectionsString)
-					}
-					numberOfMessagesReceived++
-					messageReceived(numberOfMessagesReceived, time.Now(), readRequestResult.GetResponse())
-				}
-				return nil
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					if strings.HasPrefix(currentText, connectionsString+"") {
-						parse, _ := url.Parse(connectionsString)
-						switch parse.Scheme {
-						// TODO: add to protocol suggestor so it can be reused.
-
-						}
-					} else {
-						entries = append(entries, connectionsString)
-					}
-				}
-				return
-			},
-		},
-		{
-			Name:        "write",
-			Description: "Starts a write request (switched mode to write edit)",
-			action: func(ctx context.Context, _ Command, connectionsString string) error {
-				if connection, ok := connections[connectionsString]; !ok {
-					return errors.Errorf("%s not connected", connectionsString)
-				} else {
-					return errors.Errorf("%s mode switch not yet implemented", connection)
-				}
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					entries = append(entries, connectionsString)
-				}
-				return
-			},
-		},
-		{
-			Name:        "write-direct",
-			Description: "Builds a write request with the supplied field",
-			action: func(ctx context.Context, c Command, connectionsStringAndFieldQuery string) error {
-				split := strings.Split(connectionsStringAndFieldQuery, " ")
-				if len(split) != 3 {
-					return errors.Errorf("%s expects exactly three arguments [connection url] [fieldQuery] [value]", c)
-				}
-				connectionsString := split[0]
-				if connection, ok := connections[connectionsString]; !ok {
-					return errors.Errorf("%s not connected", connectionsString)
-				} else {
-					start := time.Now()
-					writeRequest, err := connection.WriteRequestBuilder().
-						AddTagAddress("writeField", split[1], split[2]).
-						Build()
-					if err != nil {
-						return errors.Wrapf(err, "%s can't write", connectionsString)
-					}
-					writeRequestResult := <-writeRequest.Execute(ctx)
-					if err := writeRequestResult.GetErr(); err != nil {
-						return errors.Wrapf(err, "%s can't write", connectionsString)
-					}
-					plc4xBrowserLog.Debug().TimeDiff("runtime", time.Now(), start).Msg("write took runtime")
-					if err := writeRequestResult.GetErr(); err != nil {
-						return errors.Wrapf(err, "%s error writing", connectionsString)
-					}
-					numberOfMessagesReceived++
-					messageReceived(numberOfMessagesReceived, time.Now(), writeRequestResult.GetResponse())
-				}
-				return nil
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					if strings.HasPrefix(currentText, connectionsString+"") {
-						parse, _ := url.Parse(connectionsString)
-						switch parse.Scheme {
-						// TODO: add to protocol suggestor so it can be reused.
-
-						}
-					} else {
-						entries = append(entries, connectionsString)
-					}
-				}
-				return
-			},
-		},
-		{
-			Name:        "browse",
-			Description: "Starts a browse request (switched mode to browse edit)",
-			action: func(ctx context.Context, _ Command, connectionsString string) error {
-				if connection, ok := connections[connectionsString]; !ok {
-					return errors.Errorf("%s not connected", connectionsString)
-				} else {
-					return errors.Errorf("%s mode switch not yet implemented", connection)
-				}
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					entries = append(entries, connectionsString)
-				}
-				return
-			},
-		},
-		{
-			Name:        "browse-direct",
-			Description: "Builds a browse request with the supplied field",
-			action: func(ctx context.Context, c Command, connectionsStringAndFieldQuery string) error {
-				split := strings.Split(connectionsStringAndFieldQuery, " ")
-				if len(split) != 2 {
-					return errors.Errorf("%s expects exactly three arguments [connection url] [fieldQuery]", c)
-				}
-				connectionsString := split[0]
-				if connection, ok := connections[connectionsString]; !ok {
-					return errors.Errorf("%s not connected", connectionsString)
-				} else {
-					start := time.Now()
-					browseRequest, err := connection.BrowseRequestBuilder().
-						AddQuery("browseField", split[1]).
-						Build()
-					if err != nil {
-						return errors.Wrapf(err, "%s can't browse", connectionsString)
-					}
-					browseRequestResult := <-browseRequest.ExecuteWithInterceptor(ctx, func(result apiModel.PlcBrowseItem) bool {
-						// TODO: Disabled for now ... not quite sure what this is for ...
-						//numberOfMessagesReceived++
-						//messageReceived(numberOfMessagesReceived, time.Now(), result)
-						return true
-					})
-					if err := browseRequestResult.GetErr(); err != nil {
-						return errors.Wrapf(err, "%s can't browse", connectionsString)
-					}
-					plc4xBrowserLog.Debug().TimeDiff("runtime", time.Now(), start).Msg("write took runtime")
-					if err := browseRequestResult.GetErr(); err != nil {
-						return errors.Wrapf(err, "%s error browse", connectionsString)
-					}
-					numberOfMessagesReceived++
-					messageReceived(numberOfMessagesReceived, time.Now(), browseRequestResult.GetResponse())
-				}
-				return nil
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					if strings.HasPrefix(currentText, connectionsString+"") {
-						parse, _ := url.Parse(connectionsString)
-						switch parse.Scheme {
-						// TODO: add to protocol suggestor so it can be reused.
-						case "c-bus":
-							entries = append(entries, connectionsString+" info/*/*")
-						}
-					} else {
-						entries = append(entries, connectionsString)
-					}
-				}
-				return
-			},
-		},
-		{
-			Name:        "register",
-			Description: "register a driver in the subsystem",
-			action: func(ctx context.Context, _ Command, driver string) error {
-				return registerDriver(driver)
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for _, protocol := range protocolList {
-					if strings.HasPrefix(protocol, currentText) {
-						entries = append(entries, protocol)
-					}
-				}
-				return
-			},
-		},
-		{
-			Name:        "subscribe",
-			Description: "Starts a subscription request (switched mode to subscribe edit)",
-			action: func(ctx context.Context, _ Command, connectionsString string) error {
-				if connection, ok := connections[connectionsString]; !ok {
-					return errors.Errorf("%s not connected", connectionsString)
-				} else {
-					return errors.Errorf("%s mode switch not yet implemented", connection)
-				}
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					entries = append(entries, connectionsString)
-				}
-				return
-			},
-		},
-		{
-			Name:        "subscribe-direct",
-			Description: "Builds a subscriptions request with the supplied field",
-			action: func(ctx context.Context, c Command, connectionsStringAndFieldQuery string) error {
-				split := strings.Split(connectionsStringAndFieldQuery, " ")
-				if len(split) != 2 {
-					return errors.Errorf("%s expects exactly two arguments [connection url] [fieldQuery]", c)
-				}
-				connectionsString := split[0]
-				if connection, ok := connections[connectionsString]; !ok {
-					return errors.Errorf("%s not connected", connectionsString)
-				} else {
-					subscriptionRequest, err := connection.SubscriptionRequestBuilder().
-						AddEventTagAddress("subscriptionField", split[1]).
-						AddPreRegisteredConsumer("subscriptionField", func(event apiModel.PlcSubscriptionEvent) {
-							numberOfMessagesReceived++
-							messageReceived(numberOfMessagesReceived, time.Now(), event)
-						}).
-						Build()
-					if err != nil {
-						return errors.Wrapf(err, "%s can't subscribe", connectionsString)
-					}
-					subscriptionRequestResult := <-subscriptionRequest.Execute(ctx)
-					if err := subscriptionRequestResult.GetErr(); err != nil {
-						return errors.Wrapf(err, "%s can't subscribe", connectionsString)
-					}
-					log.Info().Stringer("response", subscriptionRequestResult.GetResponse()).Msg("subscription result")
-				}
-				return nil
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for connectionsString := range connections {
-					entries = append(entries, connectionsString)
-					if strings.HasPrefix(currentText, connectionsString) {
-						parse, _ := url.Parse(connectionsString)
-						switch parse.Scheme {
-						// TODO: add to protocol suggestor so it can be reused.
-						case "c-bus":
-							entries = append(entries, connectionsString+" salmonitor/*/*")
-							entries = append(entries, connectionsString+" mmimonitor/*/*")
-						}
-					}
-				}
-				return
-			},
-		},
-		{
-			Name:        "quit",
-			Description: "Quits the application",
-		},
-		{
-			Name:        "log",
-			Description: "Log related operations",
-			subCommands: []Command{
-				{
-					Name:        "get",
-					Description: "Get a log level",
-					action: func(ctx context.Context, _ Command, _ string) error {
-						_, _ = fmt.Fprintf(commandOutput, "Current log level %s", log.Logger.GetLevel())
-						return nil
-					},
-				},
-				{
-					Name:        "set",
-					Description: "Sets a log level",
-					action: func(ctx context.Context, _ Command, level string) error {
-						parseLevel, err := zerolog.ParseLevel(level)
-						if err != nil {
-							return errors.Wrapf(err, "Error setting log level")
-						}
-						setLevel(parseLevel)
-						log.Logger = log.Logger.Level(parseLevel)
-						return nil
-					},
-					parameterSuggestions: func(currentText string) (entries []string) {
-						levels := []string{
-							zerolog.LevelTraceValue,
-							zerolog.LevelDebugValue,
-							zerolog.LevelInfoValue,
-							zerolog.LevelWarnValue,
-							zerolog.LevelErrorValue,
-							zerolog.LevelFatalValue,
-							zerolog.LevelPanicValue,
-						}
-						for _, level := range levels {
-							entries = append(entries, level)
-						}
-						return
-					},
-				},
-			},
-		},
-		{
-			Name:        "plc4x-conf",
-			Description: "plc4x related settings",
-			subCommands: []Command{
-				{
-					Name:        "TraceTransactionManagerWorkers",
-					Description: "print information about transaction manager workers",
-					subCommands: []Command{
-						{
-							Name:        "on",
-							Description: "trace on",
-							action: func(ctx context.Context, _ Command, _ string) error {
-								plc4xConfig.TraceTransactionManagerWorkers = true
-								return nil
-							},
-						},
-						{
-							Name:        "off",
-							Description: "trace off",
-							action: func(ctx context.Context, _ Command, _ string) error {
-								plc4xConfig.TraceTransactionManagerWorkers = false
-								return nil
-							},
-						},
-					},
-				},
-				{
-					Name:        "TraceTransactionManagerTransactions",
-					Description: "print information about transaction manager transactions",
-					subCommands: []Command{
-						{
-							Name:        "on",
-							Description: "trace on",
-							action: func(ctx context.Context, _ Command, _ string) error {
-								plc4xConfig.TraceTransactionManagerTransactions = true
-								return nil
-							},
-						},
-						{
-							Name:        "off",
-							Description: "trace off",
-							action: func(ctx context.Context, _ Command, _ string) error {
-								plc4xConfig.TraceTransactionManagerTransactions = false
-								return nil
-							},
-						},
-					},
-				},
-				{
-					Name:        "TraceDefaultMessageCodecWorker",
-					Description: "print information about message codec workers",
-					subCommands: []Command{
-						{
-							Name:        "on",
-							Description: "trace on",
-							action: func(ctx context.Context, _ Command, _ string) error {
-								plc4xConfig.TraceDefaultMessageCodecWorker = true
-								return nil
-							},
-						},
-						{
-							Name:        "off",
-							Description: "trace off",
-							action: func(ctx context.Context, _ Command, _ string) error {
-								plc4xConfig.TraceDefaultMessageCodecWorker = false
-								return nil
-							},
-						},
-					},
-				},
-				{
-					Name:        "plc4xbrowser-debug",
-					Description: "Prints out debug information of the browser itself",
-					subCommands: []Command{
-						{
-							Name:        "on",
-							Description: "debug on",
-							action: func(ctx context.Context, _ Command, _ string) error {
-								plc4xBrowserLog = zerolog.New(zerolog.ConsoleWriter{Out: tview.ANSIWriter(consoleOutput)})
-								return nil
-							},
-						},
-						{
-							Name:        "off",
-							Description: "debug off",
-							action: func(ctx context.Context, _ Command, _ string) error {
-								plc4xBrowserLog = zerolog.Nop()
-								return nil
-							},
-						},
-					},
-				},
-				{
-					Name:        "auto-register",
-					Description: "autoregister driver at startup",
-					subCommands: []Command{
-						{
-							Name: "list",
-							action: func(ctx context.Context, currentCommand Command, argument string) error {
-								_, _ = fmt.Fprintf(commandOutput, "Auto-register enabled drivers:\n  %s\n", strings.Join(config.AutoRegisterDrivers, "\n  "))
-								return nil
-							},
-						},
-						{
-							Name: "enable",
-							action: func(ctx context.Context, _ Command, argument string) error {
-								return enableAutoRegister(argument)
-							},
-							parameterSuggestions: func(currentText string) (entries []string) {
-								for _, protocol := range protocolList {
-									if strings.HasPrefix(protocol, currentText) {
-										entries = append(entries, protocol)
-									}
-								}
-								return
-							},
-						},
-						{
-							Name: "disable",
-							action: func(ctx context.Context, _ Command, argument string) error {
-								return disableAutoRegister(argument)
-							},
-							parameterSuggestions: func(currentText string) (entries []string) {
-								for _, protocol := range protocolList {
-									if strings.HasPrefix(protocol, currentText) {
-										entries = append(entries, protocol)
-									}
-								}
-								return
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			Name:        "history",
-			Description: "outputs the last commands",
-			action: func(ctx context.Context, _ Command, _ string) error {
-				outputCommandHistory()
-				return nil
-			},
-		},
-		{
-			Name:        "clear",
-			Description: "clear all outputs",
-			action: func(ctx context.Context, _ Command, _ string) error {
-				messageOutputClear()
-				consoleOutputClear()
-				commandOutputClear()
-				return nil
-			},
-			subCommands: []Command{
-				{
-					Name:        "message",
-					Description: "clears message output",
-					action: func(ctx context.Context, _ Command, _ string) error {
-						messageOutputClear()
-						return nil
-					},
-				},
-				{
-					Name:        "console",
-					Description: "clears console output",
-					action: func(ctx context.Context, _ Command, _ string) error {
-						consoleOutputClear()
-						return nil
-					},
-				},
-				{
-					Name:        "command",
-					Description: "clears command output",
-					action: func(ctx context.Context, _ Command, _ string) error {
-						commandOutputClear()
-						return nil
-					},
-				},
-			},
-		},
-	},
-}
-
-func init() {
-	// Because of the cycle we need to define the help command here as it needs access to the to command
-	rootCommand.subCommands = append(rootCommand.subCommands, Command{
-		Name:        "help",
-		Description: "prints out this help",
-		action: func(_ context.Context, _ Command, _ string) error {
-			_, _ = fmt.Fprintf(commandOutput, "[#0000ff]Available commands[white]\n")
-			rootCommand.visit(0, func(currentIndent int, command Command) {
-				indentString := strings.Repeat("  ", currentIndent)
-				description := command.Description
-				if description == "" {
-					description = command.Name + "s"
-				}
-				_, _ = fmt.Fprintf(commandOutput, "%s [#00ff00]%s[white]: %s\n", indentString, command.Name, description)
-			})
-			return nil
-		},
-	})
-}
-
-var NotDirectlyExecutable = errors.New("Not directly executable")
-
-type Command struct {
-	Name                 string
-	Description          string
-	action               func(ctx context.Context, currentCommand Command, argument string) error
-	subCommands          []Command
-	parameterSuggestions func(currentText string) (entries []string)
-}
-
-func (c Command) Completions(currentCommandText string) (entries []string) {
-	if c.Name == rootCommandIndicator && len(currentCommandText) == 0 {
-		// We don't return anything here to not pollute the command text by default
-		return
-	}
-	if c.acceptsCurrentText(currentCommandText) {
-		currentCommandPrefix := c.currentCommandPrefix()
-		doesCommandTextTargetSubCommand := c.doesCommandTextTargetSubCommand(currentCommandPrefix)
-		if c.hasDirectExecution() && !doesCommandTextTargetSubCommand {
-			if c.parameterSuggestions != nil {
-				preparedForParameters := c.prepareForParameters(currentCommandText)
-				for _, parameterSuggestion := range c.parameterSuggestions(preparedForParameters) {
-					entries = append(entries, currentCommandPrefix+parameterSuggestion)
-				}
-			} else {
-				entries = append(entries, c.Name)
-			}
-		}
-		if doesCommandTextTargetSubCommand {
-			remainder := c.prepareForSubCommand(currentCommandText)
-			for _, command := range c.subCommands {
-				for _, subCommandCompletions := range command.Completions(remainder) {
-					entries = append(entries, currentCommandPrefix+subCommandCompletions)
-				}
-			}
-		}
-	} else if strings.HasPrefix(c.Name, currentCommandText) {
-		// Suggest ourselves if we start with the current letter
-		entries = append(entries, c.Name)
-	}
-	return
-}
-
-func (c Command) acceptsCurrentText(currentCommandText string) bool {
-	if c.Name == rootCommandIndicator {
-		return true
-	}
-	hasThePrefix := strings.HasPrefix(currentCommandText, c.Name)
-	hasNoMatchingAlternative := !strings.HasPrefix(currentCommandText, c.Name+"-")
-	accepts := hasThePrefix && hasNoMatchingAlternative
-	plc4xBrowserLog.Debug().
-		Stringer("c", c).
-		Bool("accepts", accepts).
-		Msg("c accepts")
-	return accepts
-}
-
-func (c Command) doesCommandTextTargetSubCommand(currentCommandText string) bool {
-	if c.Name == rootCommandIndicator {
-		return true
-	}
-	if len(c.subCommands) == 0 {
-		return false
-	}
-	return strings.HasPrefix(currentCommandText, c.currentCommandPrefix())
-}
-
-func (c Command) prepareForParameters(currentCommandText string) string {
-	return strings.TrimPrefix(currentCommandText, c.currentCommandPrefix())
-}
-func (c Command) prepareForSubCommand(currentCommandText string) string {
-	return strings.TrimPrefix(currentCommandText, c.currentCommandPrefix())
-}
-
-func (c Command) currentCommandPrefix() string {
-	if c.Name == rootCommandIndicator {
-		return ""
-	}
-	return c.Name + " "
-}
-
-func (c Command) hasDirectExecution() bool {
-	return c.action != nil
-}
-
-func Execute(commandText string) error {
-	err := rootCommand.Execute(commandText)
-	if err == nil {
-		addCommandHistoryEntry(commandText)
-	}
-	return err
-}
-
-func (c Command) Execute(commandText string) (err error) {
-	defer func() {
-		if recoveredErr := recover(); recoveredErr != nil {
-			if log.Debug().Enabled() {
-				log.Error().
-					Str("stack", string(debug.Stack())).
-					Interface("err", err).
-					Msg("panic-ed")
-			}
-			err = errors.Errorf("panic occurred: %v.", recoveredErr)
-		}
-	}()
-	plc4xBrowserLog.Debug().
-		Stringer("c", c).
-		Str("commandText", commandText).
-		Msg("%s executes %s")
-	if !c.acceptsCurrentText(commandText) {
-		return errors.Errorf("%s doesn't understand %s", c.Name, commandText)
-	}
-	if c.doesCommandTextTargetSubCommand(commandText) {
-		prepareForSubCommandForSubCommand := c.prepareForSubCommand(commandText)
-		for _, command := range c.subCommands {
-			if command.acceptsCurrentText(prepareForSubCommandForSubCommand) {
-				plc4xBrowserLog.Debug().
-					Stringer("c", c).
-					Stringer("command", command).
-					Msg("c delegates to sub command")
-				return command.Execute(prepareForSubCommandForSubCommand)
-			}
-		}
-		return errors.Errorf("%s not accepted by any subcommands of %s", commandText, c.Name)
-	} else {
-		if c.action == nil {
-			return NotDirectlyExecutable
-		}
-		plc4xBrowserLog.Debug().
-			Stringer("c", c).
-			Str("commandText", commandText).
-			Msg("c executes commandText directly")
-		preparedForParameters := c.prepareForParameters(commandText)
-		return c.action(context.TODO(), c, preparedForParameters)
-	}
-}
-
-func (c Command) visit(i int, f func(currentIndent int, command Command)) {
-	f(i, c)
-	for _, subCommand := range c.subCommands {
-		subCommand.visit(i+1, f)
-	}
-}
-
-func (c Command) String() string {
-	return c.Name
-}
diff --git a/plc4go/tools/plc4xbrowser/ui/common.go b/plc4go/tools/plc4xbrowser/ui/common.go
deleted file mode 100644
index 935219ce..00000000
--- a/plc4go/tools/plc4xbrowser/ui/common.go
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package ui
-
-import (
-	"io"
-	"strings"
-	"sync"
-	"time"
-
-	plc4go "github.com/apache/plc4x/plc4go/pkg/api"
-	plc4goModel "github.com/apache/plc4x/plc4go/pkg/api/model"
-	"github.com/rs/zerolog"
-)
-
-const protocols = "ads,bacnetip,c-bus,opcua,s7"
-
-var protocolList = strings.Split(protocols, ",")
-
-var plc4xBrowserLog = zerolog.Nop()
-
-var driverManager plc4go.PlcDriverManager
-var registeredDrivers map[string]plc4go.PlcDriver
-var driverAdded func(driver plc4go.PlcDriver)
-var connections map[string]plc4go.PlcConnection
-var connectionsChanged func()
-
-var messageReceived func(messageNumber int, receiveTime time.Time, message plc4goModel.PlcMessage)
-var numberOfMessagesReceived int
-var messageOutput io.Writer
-var messageOutputClear func()
-
-var consoleOutput io.Writer
-var consoleOutputClear func()
-
-var commandsExecuted int
-var commandOutput io.Writer
-var commandOutputClear func()
-
-type inputMode int
-
-const (
-	normalMode inputMode = iota
-	readEditMode
-	writeEditMode
-	subscribeEditMode
-)
-
-func init() {
-	hasShutdown = false
-	registeredDrivers = make(map[string]plc4go.PlcDriver)
-	connections = make(map[string]plc4go.PlcConnection)
-}
-
-var shutdownMutex sync.Mutex
-var hasShutdown bool
-
-func Shutdown() {
-	shutdownMutex.Lock()
-	defer shutdownMutex.Unlock()
-	if hasShutdown {
-		return
-	}
-	for _, connection := range connections {
-		connection.Close()
-	}
-	hasShutdown = true
-	saveConfig()
-}
diff --git a/plc4go/tools/plc4xbrowser/ui/config.go b/plc4go/tools/plc4xbrowser/ui/config.go
deleted file mode 100644
index d2a52491..00000000
--- a/plc4go/tools/plc4xbrowser/ui/config.go
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package ui
-
-import (
-	"os"
-	"path"
-	"slices"
-	"sync"
-	"time"
-
-	"github.com/apache/plc4x/plc4go/spi/errors"
-	"github.com/rs/zerolog"
-	"github.com/rs/zerolog/log"
-	"gopkg.in/yaml.v3"
-)
-
-var plc4xBrowserConfigDir string
-var configFile string
-var config = Config{
-	MaxConsoleLines: 500,
-	MaxOutputLines:  500,
-}
-
-type Config struct {
-	History struct {
-		Last10Hosts    []string `yaml:"last_hosts"`
-		Last10Commands []string `yaml:"last_commands"`
-	}
-	AutoRegisterDrivers []string  `yaml:"auto_register_driver"`
-	LastUpdated         time.Time `yaml:"last_updated"`
-	LogLevel            string    `yaml:"log_level"`
-	MaxConsoleLines     int       `yaml:"max_console_lines"`
-	MaxOutputLines      int       `yaml:"max_output_lines"`
-}
-
-func init() {
-	userConfigDir, err := os.UserConfigDir()
-	if err != nil {
-		panic(err)
-	}
-	plc4xBrowserConfigDir = path.Join(userConfigDir, "plc4xbrowser")
-	if _, err := os.Stat(plc4xBrowserConfigDir); os.IsNotExist(err) {
-		err := os.Mkdir(plc4xBrowserConfigDir, os.ModeDir|os.ModePerm)
-		if err != nil {
-			panic(err)
-		}
-	}
-	configFile = path.Join(plc4xBrowserConfigDir, "config.yml")
-}
-
-func LoadConfig() {
-	f, err := os.Open(configFile)
-	if err != nil {
-		log.Info().Err(err).Msg("No config file found")
-		return
-	}
-	defer func(f *os.File) {
-		err := f.Close()
-		if err != nil {
-			log.Error().Err(err).Msg("Error closing config file")
-		}
-	}(f)
-
-	decoder := yaml.NewDecoder(f)
-	if err = decoder.Decode(&config); err != nil {
-		log.Warn().Err(err).Msg("Can't decode config file")
-		return
-	}
-}
-
-var saveMutex sync.Mutex
-
-func saveConfig() {
-	saveMutex.Lock()
-	defer saveMutex.Unlock()
-	config.LastUpdated = time.Now()
-	f, err := os.OpenFile(configFile, os.O_RDWR|os.O_CREATE, 0755)
-	if err != nil {
-		log.Warn().Err(err).Msg("Can't save config file")
-		return
-	}
-	encoder := yaml.NewEncoder(f)
-	defer func(encoder *yaml.Encoder) {
-		err := encoder.Close()
-		if err != nil {
-			log.Error().Err(err).Msg("Error closing config file")
-		}
-	}(encoder)
-	if err := encoder.Encode(config); err != nil {
-		log.Warn().Err(err).Msg("Can't encode config file")
-		panic(err)
-	}
-}
-
-func addHostHistoryEntry(host string) {
-	existingIndex := -1
-	for i, lastHost := range config.History.Last10Hosts {
-		if lastHost == host {
-			existingIndex = i
-			break
-		}
-	}
-	if existingIndex >= 0 {
-		config.History.Last10Hosts = append(config.History.Last10Hosts[:existingIndex], config.History.Last10Hosts[existingIndex+1:]...)
-	}
-	if len(config.History.Last10Hosts) >= 10 {
-		config.History.Last10Hosts = config.History.Last10Hosts[1:]
-	}
-	config.History.Last10Hosts = append(config.History.Last10Hosts, host)
-}
-
-func addCommandHistoryEntry(command string) {
-	switch command {
-	case "clear":
-		return
-	case "history":
-		return
-	}
-	existingIndex := -1
-	for i, lastCommand := range config.History.Last10Commands {
-		if lastCommand == command {
-			existingIndex = i
-			break
-		}
-	}
-	if existingIndex >= 0 {
-		config.History.Last10Commands = append(config.History.Last10Commands[:existingIndex], config.History.Last10Commands[existingIndex+1:]...)
-	}
-	if len(config.History.Last10Commands) >= 10 {
-		config.History.Last10Commands = config.History.Last10Commands[1:]
-	}
-	config.History.Last10Commands = append(config.History.Last10Commands, command)
-}
-
-func setLevel(level zerolog.Level) {
-	config.LogLevel = level.String()
-}
-
-func enableAutoRegister(driver string) error {
-	if err := validateDriverParam(driver); err != nil {
-		return err
-	}
-	if slices.Contains(config.AutoRegisterDrivers, driver) {
-		return errors.Errorf("%s already registered for auto register", driver)
-	}
-	config.AutoRegisterDrivers = append(config.AutoRegisterDrivers, driver)
-	log.Info().Str("driver", driver).Msg("Auto register enabled")
-	return nil
-}
-
-func disableAutoRegister(driver string) error {
-	if err := validateDriverParam(driver); err != nil {
-		return err
-	}
-	index := -1
-	for i, autoRegisterDriver := range config.AutoRegisterDrivers {
-		if autoRegisterDriver == driver {
-			index = i
-			break
-		}
-	}
-	if index < 0 {
-		return errors.Errorf("%s not registered for auto register", driver)
-	}
-	config.AutoRegisterDrivers = append(config.AutoRegisterDrivers[:index], config.AutoRegisterDrivers[index+1:]...)
-	log.Info().Str("driver", driver).Msg("Auto register disabled")
-	return nil
-}
diff --git a/plc4go/tools/plc4xbrowser/ui/ui.go b/plc4go/tools/plc4xbrowser/ui/ui.go
deleted file mode 100644
index e4c77e28..00000000
--- a/plc4go/tools/plc4xbrowser/ui/ui.go
+++ /dev/null
@@ -1,299 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package ui
-
-import (
-	"fmt"
-	"regexp"
-	"strconv"
-	"time"
-
-	plc4go "github.com/apache/plc4x/plc4go/pkg/api"
-	apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
-	"github.com/apache/plc4x/plc4go/spi/errors"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-func SetupApplication() *tview.Application {
-	application := tview.NewApplication()
-
-	newPrimitive := func(text string) tview.Primitive {
-		return tview.NewTextView().
-			SetTextAlign(tview.AlignCenter).
-			SetText(text)
-	}
-	connectionArea := buildConnectionArea(newPrimitive, application)
-	outputArea := buildOutputArea(newPrimitive, application)
-	commandArea := buildCommandArea(newPrimitive, application)
-
-	grid := tview.NewGrid().
-		SetRows(1, 0, 1).
-		SetColumns(30, 0, 30).
-		SetBorders(true).
-		AddItem(newPrimitive("PLC4X Browser"), 0, 0, 1, 3, 0, 0, false).
-		AddItem(newPrimitive("https://github.com/apache/plc4x"), 2, 0, 1, 3, 0, 0, false)
-
-	// Layout for screens narrower than 100 cells (connectionArea and side bar are hidden).
-	grid.AddItem(connectionArea, 0, 0, 0, 0, 0, 0, false).
-		AddItem(outputArea, 1, 0, 1, 3, 0, 0, false).
-		AddItem(commandArea, 0, 0, 0, 0, 0, 0, true)
-
-	// Layout for screens wider than 100 cells.
-	grid.AddItem(connectionArea, 1, 0, 1, 1, 0, 100, false).
-		AddItem(outputArea, 1, 1, 1, 1, 0, 100, false).
-		AddItem(commandArea, 1, 2, 1, 1, 0, 100, false)
-
-	application.SetRoot(grid, true).EnableMouse(true)
-
-	return application
-}
-
-func buildConnectionArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) tview.Primitive {
-	connectionAreaHeader := newPrimitive("Connections")
-	connectionArea := tview.NewGrid().
-		SetRows(3, 0, 10).
-		SetColumns(0).
-		AddItem(connectionAreaHeader, 0, 0, 1, 1, 0, 0, false)
-	{
-		connectionList := tview.NewList()
-		connectionsChanged = func() {
-			application.QueueUpdateDraw(func() {
-				connectionList.Clear()
-				for connectionString, connection := range connections {
-					connectionList.AddItem(connectionString, "", 0x0, func() {
-						//TODO: disconnect popup
-						_ = connection
-					})
-				}
-			})
-		}
-		connectionArea.AddItem(connectionList, 1, 0, 1, 1, 0, 0, false)
-		{
-			registeredDriverAreaHeader := newPrimitive("Registered drivers")
-			registeredDriverArea := tview.NewGrid().
-				SetRows(3, 0).
-				SetColumns(0).
-				AddItem(registeredDriverAreaHeader, 0, 0, 1, 1, 0, 0, false)
-			{
-				driverList := tview.NewList()
-				driverAdded = func(driver plc4go.PlcDriver) {
-					application.QueueUpdateDraw(func() {
-						driverList.AddItem(driver.GetProtocolCode(), tview.Escape(fmt.Sprintf("%s", driver)), 0x0, func() {
-							//TODO: disconnect popup
-						})
-					})
-				}
-				registeredDriverArea.AddItem(driverList, 1, 0, 1, 1, 0, 0, false)
-			}
-			connectionArea.AddItem(registeredDriverArea, 2, 0, 1, 1, 0, 0, false)
-		}
-
-	}
-	return connectionArea
-}
-
-func buildCommandArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) tview.Primitive {
-	commandAreaHeader := newPrimitive("Commands")
-	commandArea := tview.NewGrid().
-		SetRows(3, 0, 3).
-		SetColumns(0).
-		AddItem(commandAreaHeader, 0, 0, 1, 1, 0, 0, false)
-	{
-		enteredCommandsView := tview.NewTextView().
-			SetDynamicColors(true).
-			SetRegions(true).
-			SetWordWrap(true).
-			SetChangedFunc(func() {
-				application.Draw()
-			})
-		commandOutput = enteredCommandsView
-		commandOutputClear = func() {
-			enteredCommandsView.SetText("")
-		}
-
-		commandArea.AddItem(enteredCommandsView, 1, 0, 1, 1, 0, 0, false)
-
-		commandInputField := tview.NewInputField().
-			SetLabel("$").
-			SetFieldWidth(30)
-		application.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
-			switch event.Key() {
-			case tcell.KeyCtrlC:
-				commandInputField.SetText("")
-				application.SetFocus(commandInputField)
-				return nil
-			case tcell.KeyCtrlD:
-				// TODO: maybe add a modal here
-				application.Stop()
-				return nil
-			}
-			return event
-		})
-		commandInputField.
-			SetDoneFunc(func(key tcell.Key) {
-				commandText := commandInputField.GetText()
-				if commandText == "quit" {
-					// TODO: maybe add a modal here
-					application.Stop()
-					return
-				}
-				commandsExecuted++
-				go func() {
-					commandHistoryShortcut, _ := regexp.Compile("^[0-9]$")
-					if commandHistoryShortcut.MatchString(commandText) {
-						atoi, _ := strconv.Atoi(commandHistoryShortcut.FindString(commandText))
-						if atoi < len(config.History.Last10Commands) {
-							commandText = config.History.Last10Commands[atoi]
-						} else {
-							_, _ = fmt.Fprintf(enteredCommandsView, "[#ff0000]%s %s[white]\n", time.Now().Format("04:05"), errors.Errorf("No such elements %d in command history", atoi))
-							return
-						}
-					}
-					_, _ = fmt.Fprintf(enteredCommandsView, "%s [\"%d\"]%s[\"\"]\n", time.Now().Format("04:05"), commandsExecuted, commandText)
-					if err := Execute(commandText); err != nil {
-						_, _ = fmt.Fprintf(enteredCommandsView, "[#ff0000]%s %s[white]\n", time.Now().Format("04:05"), err)
-						return
-					}
-					application.QueueUpdateDraw(func() {
-						commandInputField.SetText("")
-					})
-				}()
-			})
-		commandInputField.SetAutocompleteFunc(rootCommand.Completions)
-
-		enteredCommandsView.SetDoneFunc(func(key tcell.Key) {
-			currentSelection := enteredCommandsView.GetHighlights()
-			if key == tcell.KeyEnter {
-				if len(currentSelection) > 0 {
-					enteredCommandsView.Highlight()
-				} else {
-					enteredCommandsView.Highlight("0").ScrollToHighlight()
-				}
-				if len(currentSelection) == 1 {
-					commandInputField.SetText(enteredCommandsView.GetRegionText(currentSelection[0]))
-					application.SetFocus(commandInputField)
-				}
-			} else if len(currentSelection) > 0 {
-				index, _ := strconv.Atoi(currentSelection[0])
-				if key == tcell.KeyTab {
-					index = (index + 1) % commandsExecuted
-				} else if key == tcell.KeyBacktab {
-					index = (index - 1 + commandsExecuted) % commandsExecuted
-				} else {
-					return
-				}
-				enteredCommandsView.Highlight(strconv.Itoa(index)).ScrollToHighlight()
-			}
-		})
-
-		commandArea.AddItem(commandInputField, 2, 0, 1, 1, 0, 0, true)
-	}
-	return commandArea
-}
-
-func buildOutputArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) *tview.Grid {
-	outputAreaHeader := newPrimitive("Output")
-	outputArea := tview.NewGrid().
-		SetRows(3, 0, 10).
-		SetColumns(0, 30).
-		AddItem(outputAreaHeader, 0, 0, 1, 1, 0, 0, false)
-	{
-		var jumpToMessageItem func(messageNumber int) bool
-		{
-			outputView := tview.NewTextView().
-				SetDynamicColors(true).
-				SetRegions(true).
-				SetWordWrap(false).
-				SetWrap(false).
-				SetChangedFunc(func() {
-					application.Draw()
-				})
-			jumpToMessageItem = func(messageNumber int) bool {
-				regionId := strconv.Itoa(messageNumber)
-				if outputView.GetRegionText(regionId) == "" {
-					return false
-				}
-				outputView.Highlight(regionId).ScrollToHighlight()
-				return true
-			}
-			messageOutput = outputView
-			messageOutputClear = func() {
-				outputView.SetText("")
-			}
-
-			outputView.SetDoneFunc(func(key tcell.Key) {
-				currentSelection := outputView.GetHighlights()
-				if key == tcell.KeyEnter {
-					if len(currentSelection) > 0 {
-						outputView.Highlight()
-					} else {
-						outputView.Highlight("0").ScrollToHighlight()
-					}
-				} else if len(currentSelection) > 0 {
-					index, _ := strconv.Atoi(currentSelection[0])
-					if key == tcell.KeyTab {
-						index = (index + 1) % numberOfMessagesReceived
-					} else if key == tcell.KeyBacktab {
-						index = (index - 1 + numberOfMessagesReceived) % numberOfMessagesReceived
-					} else {
-						return
-					}
-					outputView.Highlight(strconv.Itoa(index)).ScrollToHighlight()
-				}
-			})
-			outputView.SetBorder(false)
-			outputArea.AddItem(outputView, 1, 0, 1, 1, 0, 0, false)
-		}
-
-		{
-			consoleView := tview.NewTextView().
-				SetDynamicColors(true).
-				SetMaxLines(config.MaxConsoleLines).
-				SetChangedFunc(func() {
-					application.Draw()
-				})
-			consoleOutput = consoleView
-			consoleOutputClear = func() {
-				consoleView.SetText("")
-			}
-
-			consoleView.SetBorder(false)
-			outputArea.AddItem(consoleView, 2, 0, 1, 1, 0, 0, false)
-		}
-
-		{
-			receivedMessagesList := tview.NewList()
-			messageReceived = func(messageNumber int, receiveTime time.Time, message apiModel.PlcMessage) {
-				application.QueueUpdateDraw(func() {
-					receivedMessagesList.AddItem(fmt.Sprintf("No %d @%s", messageNumber, receiveTime.Format("15:04:05.999999")), "", 0x0, func() {
-						if ok := jumpToMessageItem(messageNumber); !ok {
-							plc4xBrowserLog.Debug().Msg("Adding new message to console output")
-							_, _ = fmt.Fprintf(tview.ANSIWriter(messageOutput), "Message nr: %[1]d\n[\"%[1]d\"]%s[\"\"]\n", messageNumber, message)
-							jumpToMessageItem(messageNumber)
-						}
-					})
-				})
-			}
-			outputArea.AddItem(receivedMessagesList, 0, 1, 3, 1, 0, 0, false)
-		}
-	}
-	return outputArea
-}
diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go b/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go
index 20b81dc8..fcd34b44 100644
--- a/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go
+++ b/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go
@@ -23,9 +23,9 @@ import (
 	"os"
 
 	"github.com/apache/plc4x/plc4go/spi/errors"
-	"github.com/rs/zerolog/log"
 	"github.com/spf13/cobra"
 
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
 	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/ui"
 )
 
@@ -36,7 +36,7 @@ var uiCmd = &cobra.Command{
 	Long: `Analyzes a pcap file using a bacnet driver
 TODO: document me
 `,
-	Args: func(cmd *cobra.Command, args []string) error {
+	Args: func(_ *cobra.Command, args []string) error {
 		if len(args) < 1 {
 			return nil
 		}
@@ -46,22 +46,22 @@ TODO: document me
 		}
 		return nil
 	},
+	// RunE, not Run: the terminal UI reports a failure to start by returning it, so that cobra
+	// prints it and the process exits non-zero. The version this replaced could only panic.
 	RunE: func(cmd *cobra.Command, args []string) error {
-		ui.LoadConfig()
-		application := ui.SetupApplication()
-		ui.InitSubsystem()
+		var pcapFile string
 		if len(args) > 0 {
-			pcapFile := args[0]
-			go func() {
-				err := ui.OpenFile(pcapFile)
-				if err != nil {
-					log.Error().Err(err).Msg("Error opening argument file")
-				}
-			}()
+			pcapFile = args[0]
 		}
-
-		defer ui.Shutdown()
-		return application.Run()
+		return ui.Run(cmd.Context(), ui.RunOptions{
+			PcapFile: pcapFile,
+			// --demo generates a small capture of real C-Bus traffic and analyses it, so the
+			// tool can be demonstrated and manually debugged with no capture to hand.
+			Demo: config.RootConfigInstance.Demo,
+			// --ascii forces the ASCII glyph set; without it the glyph set is guessed from the
+			// locale.
+			Ascii: config.RootConfigInstance.Ascii,
+		})
 	},
 }
 
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/actions.go b/plc4go/tools/plc4xpcapanalyzer/ui/actions.go
deleted file mode 100644
index c29cbc89..00000000
--- a/plc4go/tools/plc4xpcapanalyzer/ui/actions.go
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package ui
-
-import (
-	"bytes"
-	"fmt"
-	"os"
-	"path"
-	"slices"
-	"strings"
-
-	plc4go "github.com/apache/plc4x/plc4go/pkg/api"
-	"github.com/apache/plc4x/plc4go/pkg/api/drivers"
-	"github.com/apache/plc4x/plc4go/spi"
-	"github.com/apache/plc4x/plc4go/spi/errors"
-	"github.com/apache/plc4x/plc4go/spi/transports/pcap"
-	"github.com/rivo/tview"
-	"github.com/rs/zerolog"
-	"github.com/rs/zerolog/log"
-)
-
-func InitSubsystem() {
-	logLevel := zerolog.InfoLevel
-	if configuredLevel := config.LogLevel; configuredLevel != "" {
-		if parsedLevel, err := zerolog.ParseLevel(configuredLevel); err != nil {
-			panic(err)
-		} else {
-			logLevel = parsedLevel
-		}
-	}
-	driverManager = plc4go.NewPlcDriverManager()
-
-	log.Logger = log.
-		//// Enable below if you want to see the filenames
-		//With().Caller().Logger().
-		Output(zerolog.NewConsoleWriter(
-			func(w *zerolog.ConsoleWriter) {
-				w.Out = tview.ANSIWriter(consoleOutput)
-			},
-			func(w *zerolog.ConsoleWriter) {
-				w.FormatFieldValue = func(i any) string {
-					if aString, ok := i.(string); ok && strings.Contains(aString, "\\n") {
-						return fmt.Sprintf("\x1b[%dm%v\x1b[0m", 31, "see below")
-					}
-					return fmt.Sprintf("%s", i)
-				}
-				w.FormatExtra = func(m map[string]any, buffer *bytes.Buffer) error {
-					for key, i := range m {
-						if aString, ok := i.(string); ok && strings.Contains(aString, "\n") {
-							buffer.WriteString("\n")
-							buffer.WriteString(fmt.Sprintf("\x1b[%dm%v\x1b[0m", 32, "field "+key))
-							buffer.WriteString(":\n" + aString)
-						}
-					}
-					return nil
-				}
-			},
-		)).
-		Level(logLevel)
-
-	// We offset the commands executed with the last commands
-	commandsExecuted = len(config.History.Last10Commands)
-	outputCommandHistory()
-
-	for _, driver := range config.AutoRegisterDrivers {
-		log.Info().Str("driver", driver).Msg("Auto register driver")
-		if err := validateDriverParam(driver); err != nil {
-			log.Err(err).Msg("Invalid configuration")
-			continue
-		}
-		_ = registerDriver(driver)
-	}
-}
-
-func OpenFile(pcapFile string) error {
-	if !strings.HasPrefix(pcapFile, string(os.PathSeparator)) {
-		pcapFile = path.Join(currentDir, pcapFile)
-	}
-	var name string
-	if stat, err := os.Stat(pcapFile); err != nil {
-		return err
-	} else if stat.IsDir() {
-		stat.Name()
-		return errors.Errorf("%s is a dir", pcapFile)
-	} else {
-		name = stat.Name()
-	}
-	for _, loadedPcapFile := range loadedPcapFiles {
-		if loadedPcapFile.path == pcapFile {
-			return errors.Errorf("%s already loaded", name)
-		}
-	}
-	addRecentFilesEntry(pcapFile)
-	loadedPcapFiles = append(loadedPcapFiles, loadedPcapFile{
-		name: name,
-		path: pcapFile,
-	})
-	loadedPcapFilesChanged()
-	return nil
-}
-
-func outputCommandHistory() {
-	_, _ = fmt.Fprintln(commandOutput, "[#0000ff]Last 10 commands[white]")
-	for i, command := range config.History.Last10Commands {
-		_, _ = fmt.Fprintf(commandOutput, "   [#00ff00]%d[white]: [\"%d\"]%s[\"\"]\n", i, i, tview.Escape(command))
-	}
-}
-
-func validateDriverParam(driver string) error {
-	if slices.Contains(protocolList, driver) {
-		return nil
-	}
-	return errors.Errorf("protocol %s not found", driver)
-}
-
-func registerDriver(driver string) error {
-	switch driver {
-	case "ads":
-		drivers.RegisterAdsDriver(driverManager)
-	case "bacnetip":
-		drivers.RegisterBacnetDriver(driverManager)
-	case "c-bus":
-		drivers.RegisterCBusDriver(driverManager)
-	case "s7":
-		drivers.RegisterS7Driver(driverManager)
-	default:
-		return errors.Errorf("Unknown driver %s", driver)
-	}
-	driverManager.(spi.TransportAware).RegisterTransport(pcap.NewTransport())
-	go driverAdded(driver)
-	return nil
-}
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go b/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go
new file mode 100644
index 00000000..7b2f9d86
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go
@@ -0,0 +1,669 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package ui
+
+import (
+	"bytes"
+	"context"
+	"encoding/hex"
+	"fmt"
+	"net"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/apache/plc4x/plc4go/spi"
+	"github.com/apache/plc4x/plc4go/spi/errors"
+	"github.com/gopacket/gopacket"
+	"github.com/gopacket/gopacket/layers"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/progress"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
+)
+
+// The analysis this UI draws.
+//
+// internal/analyzer performs the same parse, re-serialize and byte-compare loop, but it
+// reports its findings only by logging them: it returns nothing but an error, and its
+// per-packet detail — the original payload, the re-serialized payload and where the two first
+// differ — reaches the outside world exclusively as a hex dump embedded in a zerolog field.
+// A table of packets and a detail pane showing the byte difference cannot be built from that,
+// and reconstructing bytes by parsing log lines would be worse than doing the work again. So
+// this file walks the capture itself, through the SAME collaborators the analyzer uses —
+// pcaphandler for the handle and the packet index, the per-protocol analyzers for parse and
+// serialize, common for the packet metadata — and returns values instead of writing them out.
+//
+// Nothing here touches the Bubble Tea model. Run is called from a tea.Cmd, reports its advance
+// through a progress.Reporter, and hands back one Result.
+
+// Phase names the stage of a run. They are what the run panel's breadcrumb shows, and they
+// travel to the model as the Description of a progress.Update.
+const (
+	// PhaseIndex is the pass that opens the capture and numbers its packets. pcaphandler needs
+	// this to give a packet the same number Wireshark would show it under, even when a filter
+	// has removed the packets before it.
+	PhaseIndex = "index"
+	// PhaseFilter is the pass that streams the capture through the BPF filter and the
+	// protocol's own packet mapping, keeping the payloads that survive.
+	PhaseFilter = "filter"
+	// PhaseAnalyze is the pass that parses, re-serializes and byte-compares each payload.
+	PhaseAnalyze = "analyze"
+)
+
+// Phases is the breadcrumb, in order.
+var Phases = []string{PhaseIndex, PhaseFilter, PhaseAnalyze}
+
+// Verdict is what an analysed packet turned out to be.
+type Verdict int
+
+const (
+	// VerdictOK is a packet that parsed, re-serialized and compared equal.
+	VerdictOK Verdict = iota
+	// VerdictBytesDiffer is the finding this tool exists to produce: the codec parsed the
+	// packet and then wrote back something else.
+	VerdictBytesDiffer
+	// VerdictParseFail is a payload the codec could not read.
+	VerdictParseFail
+	// VerdictSerializeFail is a message the codec could read but not write.
+	VerdictSerializeFail
+	// VerdictSkipped is a payload the protocol itself says is not a whole message: a split
+	// transmission, an empty packet or an echo. Not a defect, so not a finding.
+	VerdictSkipped
+	// VerdictFiltered is a packet the protocol mapping removed.
+	VerdictFiltered
+	// VerdictNoPayload is a packet with no application layer at all.
+	VerdictNoPayload
+)
+
+// String is the label the packets table shows in its verdict column. The labels are short
+// because that column is the first to lose width on a narrow terminal.
+func (v Verdict) String() string {
+	switch v {
+	case VerdictOK:
+		return "ok"
+	case VerdictBytesDiffer:
+		return "bytes"
+	case VerdictParseFail:
+		return "parse"
+	case VerdictSerializeFail:
+		return "serial"
+	case VerdictSkipped:
+		return "skip"
+	case VerdictFiltered:
+		return "filter"
+	case VerdictNoPayload:
+		return "empty"
+	default:
+		return "?"
+	}
+}
+
+// IsIssue reports whether this verdict belongs in the Findings tab. A skipped, filtered or
+// payload-less packet is normal traffic, not a defect, and counting it as one would bury the
+// three real findings among two hundred uninteresting ones.
+func (v Verdict) IsIssue() bool {
+	switch v {
+	case VerdictBytesDiffer, VerdictParseFail, VerdictSerializeFail:
+		return true
+	default:
+		return false
+	}
+}
+
+// Direction says which way a packet travelled, relative to the configured client address.
+type Direction int
+
+const (
+	// DirectionRequest is a packet sent by the client.
+	DirectionRequest Direction = iota
+	// DirectionResponse is a packet sent to the client.
+	DirectionResponse
+	// DirectionUnknown is a packet whose endpoints do not include the configured client, or a
+	// capture analysed with no client configured at all.
+	DirectionUnknown
+)
+
+// Record is one analysed packet, and everything the UI shows about it.
+type Record struct {
+	// Number is the packet's position in the unfiltered capture, which is the number a user
+	// comparing against Wireshark expects to see.
+	Number int
+	// Offset is how long after the first packet of the capture this one arrived.
+	Offset time.Duration
+	// Direction is which way it travelled.
+	Direction Direction
+	// Protocol is the canonical protocol name it was analysed as.
+	Protocol string
+	// Summary is the message type the codec parsed it into, empty when it did not parse.
+	Summary string
+	// Verdict is the outcome.
+	Verdict Verdict
+	// Reason carries the error or the explanation behind a non-OK verdict.
+	Reason string
+	// Original is the captured application payload.
+	Original []byte
+	// Reserialized is what the codec wrote back, nil when it never got that far.
+	Reserialized []byte
+	// DiffOffset is the offset of the first differing byte, or -1 when the two agree or when
+	// there was nothing to compare.
+	DiffOffset int
+	// Tree is the parsed message rendered as text, for the detail pane's tree view.
+	Tree string
+}
+
+// Counters are the running totals the run panel shows. They are the same four numbers the
+// analyzer logs in its summary line, plus the packets walked.
+type Counters struct {
+	Walked         int
+	Parsed         int
+	ParseFail      int
+	SerializeFail  int
+	CompareFail    int
+	Skipped        int
+	TotalInCapture int
+}
+
+// Issues is how many findings the run produced.
+func (c Counters) Issues() int { return c.ParseFail + c.SerializeFail + c.CompareFail }
+
+// Request is one analysis, fully specified.
+//
+// It is a value rather than a read of the config singletons because the singletons are
+// process-global: a run has to be reproducible, and a test has to be able to describe a run
+// without mutating state that outlives it.
+type Request struct {
+	Protocol protocol.Protocol
+	PcapFile string
+	// Client is the address requests originate from. Protocols where a request and a response
+	// are encoded differently — C-Bus is one — cannot be parsed without it.
+	Client string
+	// Filter is a BPF filter expression. Empty means the protocol's own default.
+	Filter string
+	// NoFilter disables filtering entirely, default included.
+	NoFilter bool
+	// OnlyParse stops after parsing, skipping the re-serialize and the byte compare.
+	OnlyParse bool
+	// NoBytesCompare re-serializes but does not compare.
+	NoBytesCompare bool
+	// StartPacket skips everything before this packet number. Zero starts at the beginning.
+	StartPacket uint
+	// PacketLimit caps how many packets are walked. Zero means no limit — unlike the analyzer's
+	// config singleton, where zero means "walk nothing", which is why the old UI's analyze
+	// command produced an empty run unless the CLI's analyze flags had been parsed first.
+	PacketLimit uint
+}
+
+// Label is the command line that would reproduce this run. It titles the run panel.
+func (r Request) Label() string {
+	return "analyze " + r.Protocol.Name + " " + filepath.Base(r.PcapFile)
+}
+
+// filterExpression resolves the BPF filter this run should use.
+func (r Request) filterExpression() string {
+	if r.NoFilter {
+		return ""
+	}
+	return r.Filter
+}
+
+// Result is the outcome of one run.
+type Result struct {
+	Request  Request
+	Records  []Record
+	Counters Counters
+	// Err is set when the run could not be performed at all: an unreadable capture, an
+	// unsupported protocol. A packet that failed to parse is a Record, not an Err.
+	Err error
+	// Aborted says the caller's context was cancelled part way through. The records collected
+	// before that point are still returned, because a half-finished analysis of a large
+	// capture is worth keeping.
+	Aborted bool
+	// Elapsed is how long the run took.
+	Elapsed time.Duration
+}
+
+// codec is the per-protocol half of a run.
+type codec struct {
+	parse     func(common.PacketInformation, []byte) (spi.Message, error)
+	serialize func(spi.Message) ([]byte, error)
+	// mapPackets wraps the packet stream in whatever re-assembly the protocol needs. C-Bus
+	// splits messages across TCP segments, so it merges them here.
+	mapPackets func(chan gopacket.Packet, func(gopacket.Packet) common.PacketInformation) chan gopacket.Packet
+	// wait blocks until any goroutine the mapping started has finished, so that nothing is
+	// still running once Run returns.
+	wait func()
+}
+
+// codecFor builds the codec for a request. cancel is called when the run finishes, to release
+// the mapping goroutine; the caller must arrange for it to run on every exit path.
+func codecFor(ctx context.Context, request Request) (codec, context.CancelFunc, error) {
+	switch request.Protocol.Name {
+	case protocol.BacnetIP.Name:
+		return codec{
+			parse:      bacnetanalyzer.PackageParse,
+			serialize:  bacnetanalyzer.SerializePackage,
+			mapPackets: passthroughMapping,
+			wait:       func() {},
+		}, func() {}, nil
+
+	case protocol.CBus.Name:
+		analyzer := &cbusanalyzer.Analyzer{Client: net.ParseIP(request.Client)}
+		analyzer.Init()
+		// A context of our own so the mapping goroutine is released on every early exit — the
+		// packet limit, an abort, a read error. Without it that goroutine blocks on a send for
+		// the life of the process and keeps logging into the UI's log pane.
+		mappingCtx, cancel := context.WithCancel(ctx)
+		return codec{
+			parse:     analyzer.PackageParse,
+			serialize: analyzer.SerializePackage,
+			mapPackets: func(in chan gopacket.Packet, info func(gopacket.Packet) common.PacketInformation) chan gopacket.Packet {
+				return analyzer.MapPackets(mappingCtx, in, info)
+			},
+			wait: analyzer.WaitForMapping,
+		}, cancel, nil
+
+	default:
+		return codec{}, func() {}, errors.Errorf("protocol %s is registered but not implemented by the analyzer", request.Protocol.Name)
+	}
+}
+
+// passthroughMapping is the mapping for protocols that need no re-assembly.
+func passthroughMapping(in chan gopacket.Packet, _ func(gopacket.Packet) common.PacketInformation) chan gopacket.Packet {
+	return in
+}
+
+// collected is one packet that survived the filter pass, held until the analyze pass.
+type collected struct {
+	info    common.PacketInformation
+	payload []byte
+	// filtered is the reason the protocol mapping rejected the packet, nil when it did not.
+	filtered error
+}
+
+// Sink is where a run publishes what it learns while it is still running.
+//
+// Every member is optional and every member is called from the analysis goroutine, so a
+// consumer inside a Bubble Tea program must do nothing in them but hand the value to a
+// channel. Nothing here may touch model state.
+type Sink struct {
+	// Progress receives the advance of each phase.
+	Progress progress.Reporter
+	// Observe is handed each record as it is produced, in packet order, so that the table
+	// fills while the run is in flight rather than all at once at the end.
+	Observe func(Record)
+	// Clock measures the elapsed time. Tests inject one they control.
+	Clock func() time.Time
+}
+
+// Analyze performs one analysis and returns what it found.
+//
+// It is deliberately synchronous and free of any UI type: the model calls it from a tea.Cmd
+// and receives the Result as a message.
+func Analyze(ctx context.Context, request Request, sink Sink) Result {
+	reporter := sink.Progress
+	if reporter == nil {
+		reporter = progress.Nop()
+	}
+	clock := sink.Clock
+	if clock == nil {
+		clock = time.Now
+	}
+	started := clock()
+	result := Result{Request: request}
+	finish := func() Result {
+		result.Elapsed = clock().Sub(started)
+		return result
+	}
+
+	defer reporter.Done()
+
+	// aborted records a cancellation and says whether to stop. It is checked between the
+	// phases as well as inside their loops: a context cancelled before a phase starts makes
+	// that phase's producer close its channel immediately, so the loop body never runs and
+	// the cancellation would otherwise go unnoticed and the run would look complete.
+	aborted := func() bool {
+		if ctx.Err() == nil {
+			return false
+		}
+		result.Aborted = true
+		return true
+	}
+
+	codec, cancelMapping, err := codecFor(ctx, request)
+	if err != nil {
+		result.Err = err
+		return finish()
+	}
+	defer func() {
+		cancelMapping()
+		codec.wait()
+	}()
+
+	if aborted() {
+		return finish()
+	}
+
+	// Index. The total is not known until this pass has run, so the bar starts indeterminate.
+	reporter.Start(0, PhaseIndex)
+	handle, total, timestampToIndex, err := pcaphandler.GetIndexedPcapHandle(request.PcapFile, request.filterExpression())
+	if err != nil {
+		result.Err = errors.Wrapf(err, "error opening capture %s", request.PcapFile)
+		return finish()
+	}
+	defer handle.Close()
+	result.Counters.TotalInCapture = total
+
+	// Filter. Everything that survives is copied out of the capture buffer, because the analyze
+	// pass reads it after the handle has moved on.
+	reporter.Start(total, PhaseFilter)
+	packetInformationFor := func(packet gopacket.Packet) common.PacketInformation {
+		return packetInformation(request.PcapFile, packet, timestampToIndex)
+	}
+	var packets []collected
+	walked := uint(0)
+	if aborted() {
+		return finish()
+	}
+	for packet := range codec.mapPackets(pcaphandler.GetPacketSource(handle).Packets(), packetInformationFor) {
+		if aborted() {
+			break
+		}
+		if packet == nil {
+			break
+		}
+		walked++
+		// The same boundary the analyzer uses: StartPacket is the first packet KEPT, not the
+		// last one skipped.
+		if request.StartPacket > 0 && walked < request.StartPacket {
+			continue
+		}
+		if request.PacketLimit > 0 && walked > request.PacketLimit {
+			break
+		}
+		reporter.Advance(1)
+
+		info := packetInformationFor(packet)
+		if filtered, ok := packet.(common.FilteredPackage); ok {
+			packets = append(packets, collected{info: info, filtered: filtered.FilterReason()})
+			continue
+		}
+		applicationLayer := packet.ApplicationLayer()
+		if applicationLayer == nil {
+			packets = append(packets, collected{info: info})
+			continue
+		}
+		packets = append(packets, collected{info: info, payload: bytes.Clone(applicationLayer.Payload())})
+	}
+
+	// Analyze.
+	if aborted() {
+		return finish()
+	}
+	reporter.Start(len(packets), PhaseAnalyze)
+	var base time.Time
+	for i, packet := range packets {
+		if aborted() {
+			break
+		}
+		reporter.Advance(1)
+		if i == 0 {
+			base = packet.info.PacketTimestamp
+		}
+		record := analyseOne(request, codec, packet, base)
+		result.Counters.Walked++
+		switch record.Verdict {
+		case VerdictParseFail:
+			result.Counters.ParseFail++
+		case VerdictSerializeFail:
+			result.Counters.Parsed++
+			result.Counters.SerializeFail++
+		case VerdictBytesDiffer:
+			result.Counters.Parsed++
+			result.Counters.CompareFail++
+		case VerdictOK:
+			result.Counters.Parsed++
+		default:
+			result.Counters.Skipped++
+		}
+		result.Records = append(result.Records, record)
+		if sink.Observe != nil {
+			sink.Observe(record)
+		}
+	}
+	return finish()
+}
+
+// analyseOne turns one collected packet into a record.
+func analyseOne(request Request, codec codec, packet collected, base time.Time) Record {
+	record := Record{
+		Number:     packet.info.PacketNumber,
+		Offset:     packet.info.PacketTimestamp.Sub(base),
+		Direction:  directionOf(packet.info, request.Client),
+		Protocol:   request.Protocol.Name,
+		Original:   packet.payload,
+		DiffOffset: -1,
+	}
+	switch {
+	case packet.filtered != nil:
+		record.Verdict = VerdictFiltered
+		record.Reason = packet.filtered.Error()
+		return record
+	case len(packet.payload) == 0:
+		record.Verdict = VerdictNoPayload
+		record.Reason = "no application layer"
+		return record
+	}
+
+	parsed, err := codec.parse(packet.info, packet.payload)
+	if err != nil {
+		record.Reason = err.Error()
+		switch {
+		case errors.Is(err, common.ErrUnterminatedPackage):
+			record.Verdict, record.Reason = VerdictSkipped, "unterminated"
+		case errors.Is(err, common.ErrEmptyPackage):
+			record.Verdict, record.Reason = VerdictSkipped, "empty"
+		case errors.Is(err, common.ErrEcho):
+			record.Verdict, record.Reason = VerdictSkipped, "echo"
+		default:
+			record.Verdict = VerdictParseFail
+		}
+		return record
+	}
+
+	record.Summary = messageName(parsed)
+	record.Tree = fmt.Sprintf("%v", parsed)
+	if request.OnlyParse {
+		record.Verdict = VerdictOK
+		return record
+	}
+
+	serialized, err := codec.serialize(parsed)
+	if err != nil {
+		record.Verdict = VerdictSerializeFail
+		record.Reason = err.Error()
+		return record
+	}
+	record.Reserialized = serialized
+	if request.NoBytesCompare {
+		record.Verdict = VerdictOK
+		return record
+	}
+	if offset := FirstDifference(record.Original, record.Reserialized); offset >= 0 {
+		record.Verdict = VerdictBytesDiffer
+		record.DiffOffset = offset
+		record.Reason = strconv.Itoa(differingBytes(record.Original, record.Reserialized)) + " bytes differ"
+		return record
+	}
+	record.Verdict = VerdictOK
+	return record
+}
+
+// FirstDifference returns the offset of the first byte at which a and b disagree, treating a
+// prefix as differing at the point where the shorter one ends. It returns -1 when the two are
+// identical.
+//
+// This is the number the detail pane points its caret at, and it is the single most useful
+// piece of information the tool produces, so it is a named, tested function rather than an
+// expression buried in a loop.
+func FirstDifference(a, b []byte) int {
+	limit := min(len(a), len(b))
+	for i := range limit {
+		if a[i] != b[i] {
+			return i
+		}
+	}
+	if len(a) != len(b) {
+		return limit
+	}
+	return -1
+}
+
+// differingBytes counts how many byte positions disagree, counting the tail of the longer
+// slice as differing.
+func differingBytes(a, b []byte) int {
+	limit := min(len(a), len(b))
+	count := max(len(a), len(b)) - limit
+	for i := range limit {
+		if a[i] != b[i] {
+			count++
+		}
+	}
+	return count
+}
+
+// directionOf decides which way a packet travelled.
+func directionOf(info common.PacketInformation, client string) Direction {
+	if client == "" || info.SrcIp == nil {
+		return DirectionUnknown
+	}
+	clientIP := net.ParseIP(client)
+	if clientIP == nil {
+		return DirectionUnknown
+	}
+	switch {
+	case info.SrcIp.Equal(clientIP):
+		return DirectionRequest
+	case info.DstIp != nil && info.DstIp.Equal(clientIP):
+		return DirectionResponse
+	default:
+		return DirectionUnknown
+	}
+}
+
+// messageName is the short type name of a parsed message, which is what the packets table
+// shows. The codecs return pointers to generated types whose names carry an underscore
+// prefix, so both are trimmed.
+func messageName(message spi.Message) string {
+	name := fmt.Sprintf("%T", message)
+	if index := strings.LastIndex(name, "."); index >= 0 {
+		name = name[index+1:]
+	}
+	return strings.TrimPrefix(strings.TrimPrefix(name, "*"), "_")
+}
+
+// packetInformation builds the metadata the codecs need, the same way the analyzer does.
+func packetInformation(pcapFile string, packet gopacket.Packet, timestampToIndex map[time.Time]int) common.PacketInformation {
+	timestamp := packet.Metadata().Timestamp
+	number := timestampToIndex[timestamp]
+	information := common.PacketInformation{
+		PacketNumber:    number,
+		PacketTimestamp: timestamp,
+		Description:     fmt.Sprintf("No.[%d] timestamp: %v, %s", number, timestamp, pcapFile),
+	}
+	if network, ok := packet.NetworkLayer().(*layers.IPv4); ok {
+		information.SrcIp = network.SrcIP
+		information.DstIp = network.DstIP
+	}
+	return information
+}
+
+// HexDumpLines renders data as offset-prefixed rows of bytesPerRow bytes, with the printable
+// rendering on the right. It is hex.Dump's shape at a configurable row width, because the
+// detail pane has to fit two dumps side by side in a pane that is often under sixty cells.
+func HexDumpLines(data []byte, bytesPerRow, maxRows int) []string {
+	if bytesPerRow < 1 {
+		bytesPerRow = 8
+	}
+	var lines []string
+	for offset := 0; offset < len(data) && len(lines) < maxRows; offset += bytesPerRow {
+		end := min(offset+bytesPerRow, len(data))
+		lines = append(lines, hexRow(data, offset, end, bytesPerRow))
+	}
+	if len(lines) == 0 {
+		lines = append(lines, "(empty)")
+	}
+	return lines
+}
+
+// hexRow renders one row of a hex dump.
+func hexRow(data []byte, from, to, bytesPerRow int) string {
+	var row strings.Builder
+	row.WriteString(fmt.Sprintf("%08x  ", from))
+	for i := range bytesPerRow {
+		if from+i < to {
+			row.WriteString(hex.EncodeToString(data[from+i : from+i+1]))
+			row.WriteString(" ")
+		} else {
+			row.WriteString("   ")
+		}
+	}
+	row.WriteString(" |")
+	for i := from; i < to; i++ {
+		row.WriteByte(printable(data[i]))
+	}
+	row.WriteString("|")
+	return row.String()
+}
+
+// hexRowCaretColumn is the column a caret has to sit in to point at the byte at offset within
+// its row, given the row layout hexRow produces.
+func hexRowCaretColumn(offset, bytesPerRow int) int {
+	const offsetPrefix = len("00000000  ")
+	return offsetPrefix + (offset%bytesPerRow)*3
+}
+
+// printable maps a byte onto something safe to draw in a terminal.
+func printable(b byte) byte {
+	if b < 0x20 || b > 0x7e {
+		return '.'
+	}
+	return b
+}
+
+// Preview renders a payload the way the detail pane's first two rows show it: as a quoted Go
+// string, truncated to width cells. Quoting rather than dumping is what makes a C-Bus
+// exchange readable at a glance, since C-Bus is an ASCII protocol.
+func Preview(data []byte, width int, ellipsis string) string {
+	if len(data) == 0 {
+		return `""`
+	}
+	quoted := strconv.Quote(string(data))
+	if width <= 0 || len([]rune(quoted)) <= width {
+		return quoted
+	}
+	runes := []rune(quoted)
+	keep := max(width-len([]rune(ellipsis)), 0)
+	return string(runes[:keep]) + ellipsis
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/analysis_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/analysis_test.go
new file mode 100644
index 00000000..24817050
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/ui/analysis_test.go
@@ -0,0 +1,420 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package ui
+
+import (
+	"context"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/progress"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
+)
+
+// These tests drive the analysis with real protocol bytes, generated by the same fixture
+// builder demo mode uses. Nothing here needs a capture file on disk beforehand, a network, or
+// a device.
+
+// recordingReporter records what an analysis told it, so a test can assert the reporter is
+// actually driven. It is mutex-guarded because the reporter is called from whichever goroutine
+// is doing the work and the race detector runs over this package.
+type recordingReporter struct {
+	mu           sync.Mutex
+	phases       []string
+	totals       []int
+	advanced     int
+	doneCalls    int
+	lastProgress progress.Update
+}
+
+func (r *recordingReporter) Start(total int, description string) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.phases = append(r.phases, description)
+	r.totals = append(r.totals, total)
+}
+
+func (r *recordingReporter) Advance(delta int) {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.advanced += delta
+}
+
+func (r *recordingReporter) SetDescription(string) {}
+
+func (r *recordingReporter) Done() {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	r.doneCalls++
+}
+
+func (r *recordingReporter) snapshot() recordingReporter {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return recordingReporter{
+		phases:       append([]string(nil), r.phases...),
+		totals:       append([]int(nil), r.totals...),
+		advanced:     r.advanced,
+		doneCalls:    r.doneCalls,
+		lastProgress: r.lastProgress,
+	}
+}
+
+// writeCBus writes a C-Bus capture into a temporary directory and returns the request that
+// analyses it.
+func writeCBus(t *testing.T, packets []pcapfixture.Packet) Request {
+	t.Helper()
+	path := filepath.Join(t.TempDir(), "cbus.pcap")
+	require.NoError(t, pcapfixture.WriteCBus(path, packets))
+	return Request{Protocol: protocol.CBus, PcapFile: path, Client: pcapfixture.ClientIP}
+}
+
+func TestAnalyzeProducesARecordPerPacket(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+
+	result := Analyze(t.Context(), request, Sink{})
+
+	require.NoError(t, result.Err)
+	assert.Len(t, result.Records, len(pcapfixture.CBusSession()),
+		"every packet in the capture has to reach the table, whatever the codec made of it")
+	assert.Equal(t, len(pcapfixture.CBusSession()), result.Counters.Walked)
+	for i, record := range result.Records {
+		assert.Positive(t, record.Number, "record %d must carry the packet number a user can compare with Wireshark", i)
+		assert.Equal(t, protocol.CBus.Name, record.Protocol)
+		assert.NotEmpty(t, record.Original, "record %d must keep the captured payload for the detail pane", i)
+	}
+}
+
+// TestAnalyzeRoundTripsTheCleanSession is the property that makes the fixtures worth having:
+// the analyzer's whole job is parse, re-serialize, compare, and a session built from real
+// protocol bytes has to come back byte-identical.
+func TestAnalyzeRoundTripsTheCleanSession(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+
+	result := Analyze(t.Context(), request, Sink{})
+
+	require.NoError(t, result.Err)
+	assert.Zero(t, result.Counters.ParseFail)
+	assert.Zero(t, result.Counters.SerializeFail)
+	assert.Zero(t, result.Counters.CompareFail)
+	assert.Zero(t, result.Counters.Issues(), "a clean session must produce no findings")
+	for _, record := range result.Records {
+		if record.Verdict == VerdictOK {
+			assert.Equal(t, -1, record.DiffOffset, "packet %d compared equal, so it has no difference offset", record.Number)
+			assert.Equal(t, record.Original, record.Reserialized)
+		}
+	}
+}
+
+// TestAnalyzeReportsTheBrokenPayloads is the other half: the deliberately broken payloads have
+// to be counted as findings rather than quietly skipped.
+func TestAnalyzeReportsTheBrokenPayloads(t *testing.T) {
+	pinGlobals(t)
+	clean := Analyze(t.Context(), writeCBus(t, pcapfixture.CBusSession()), Sink{})
+	broken := Analyze(t.Context(), writeCBus(t, pcapfixture.CBusSessionWithFailures()), Sink{})
+
+	require.NoError(t, clean.Err)
+	require.NoError(t, broken.Err)
+	assert.Positive(t, broken.Counters.Issues(),
+		"the failure fixture exists so that the findings tab and the failure counters have something in them")
+	assert.Greater(t, broken.Counters.Issues(), clean.Counters.Issues())
+
+	var findings []Record
+	for _, record := range broken.Records {
+		if record.Verdict.IsIssue() {
+			findings = append(findings, record)
+		}
+	}
+	require.NotEmpty(t, findings)
+	for _, finding := range findings {
+		assert.NotEmpty(t, finding.Reason, "a finding with no reason tells the user nothing")
+	}
+}
+
+func TestAnalyzeHandlesBacnet(t *testing.T) {
+	pinGlobals(t)
+	path := filepath.Join(t.TempDir(), "bacnet.pcap")
+	session := pcapfixture.BacnetSession()
+	require.NoError(t, pcapfixture.WriteBacnet(path, session))
+
+	result := Analyze(t.Context(), Request{Protocol: protocol.BacnetIP, PcapFile: path, NoFilter: true}, Sink{})
+
+	require.NoError(t, result.Err)
+	assert.Len(t, result.Records, len(session))
+	assert.Zero(t, result.Counters.Issues(), "the BACnet fixture round-trips, so it must produce no findings")
+}
+
+// TestAnalyzeDrivesEveryPhase pins the breadcrumb the run panel draws. Deleting a phase would
+// leave the panel showing a stage the run never reaches.
+func TestAnalyzeDrivesEveryPhase(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+	reporter := &recordingReporter{}
+
+	result := Analyze(t.Context(), request, Sink{Progress: reporter})
+	require.NoError(t, result.Err)
+
+	recorded := reporter.snapshot()
+	assert.Equal(t, Phases, recorded.phases, "the run panel's breadcrumb is these phases, in this order")
+	assert.Positive(t, recorded.advanced, "a bar that never advances is a bar that says nothing")
+	assert.Positive(t, recorded.doneCalls, "the reporter must be finished, not abandoned")
+	require.Len(t, recorded.totals, len(Phases))
+	assert.Equal(t, len(pcapfixture.CBusSession()), recorded.totals[len(recorded.totals)-1],
+		"the analyze phase must announce how many packets it is about to walk")
+}
+
+// TestAnalyzeObservesEveryRecord pins the streaming that fills the table while a run is in
+// flight. Without it the table would stay empty until the very end of a long capture.
+func TestAnalyzeObservesEveryRecord(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+
+	var mu sync.Mutex
+	var observed []Record
+	result := Analyze(t.Context(), request, Sink{Observe: func(record Record) {
+		mu.Lock()
+		defer mu.Unlock()
+		observed = append(observed, record)
+	}})
+
+	require.NoError(t, result.Err)
+	mu.Lock()
+	defer mu.Unlock()
+	assert.Equal(t, result.Records, observed, "what was streamed must be exactly what was returned, in order")
+}
+
+// TestAnalyzeStopsWhenTheContextIsCancelled is what makes the esc-abort binding real.
+func TestAnalyzeStopsWhenTheContextIsCancelled(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSessionWithFailures())
+
+	ctx, cancel := context.WithCancel(t.Context())
+	cancel()
+	result := Analyze(ctx, request, Sink{})
+
+	assert.True(t, result.Aborted, "a cancelled run must say it was cancelled rather than look complete")
+	assert.NoError(t, result.Err, "an abort is not a failure")
+}
+
+// TestAnalyzePacketLimitZeroMeansUnlimited is the fix for a measured defect: the analyzer reads
+// a limit of zero as "stop before the first packet", and the ui subcommand never parses the
+// analyze flags that would set it to MaxUint, so the old UI's analyze command reported success
+// having walked nothing.
+func TestAnalyzePacketLimitZeroMeansUnlimited(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+	request.PacketLimit = 0
+
+	result := Analyze(t.Context(), request, Sink{})
+
+	require.NoError(t, result.Err)
+	assert.Len(t, result.Records, len(pcapfixture.CBusSession()))
+}
+
+func TestAnalyzePacketLimitCutsTheRunShort(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+	request.PacketLimit = 3
+
+	result := Analyze(t.Context(), request, Sink{})
+
+	require.NoError(t, result.Err)
+	assert.Len(t, result.Records, 3)
+}
+
+func TestAnalyzeOnlyParseSkipsTheComparison(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+	request.OnlyParse = true
+
+	result := Analyze(t.Context(), request, Sink{})
+
+	require.NoError(t, result.Err)
+	for _, record := range result.Records {
+		assert.Nil(t, record.Reserialized, "packet %d was only parsed, so nothing was written back", record.Number)
+	}
+}
+
+func TestAnalyzeReportsAMissingCapture(t *testing.T) {
+	pinGlobals(t)
+	result := Analyze(t.Context(), Request{
+		Protocol: protocol.CBus,
+		PcapFile: filepath.Join(t.TempDir(), "absent.pcap"),
+	}, Sink{})
+
+	require.Error(t, result.Err)
+	assert.Contains(t, result.Err.Error(), "absent.pcap")
+	assert.Empty(t, result.Records)
+}
+
+func TestAnalyzeMeasuresElapsedWithTheInjectedClock(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+
+	var calls int
+	result := Analyze(t.Context(), request, Sink{Clock: func() time.Time {
+		calls++
+		return time.Unix(0, 0).Add(time.Duration(calls) * time.Second)
+	}})
+
+	require.NoError(t, result.Err)
+	assert.Equal(t, time.Second, result.Elapsed, "elapsed must come from the injected clock, not the wall clock")
+}
+
+func TestRequestLabelIsTheCommandThatWouldReproduceIt(t *testing.T) {
+	request := Request{Protocol: protocol.CBus, PcapFile: "/tmp/captures/plant.pcap"}
+	assert.Equal(t, "analyze c-bus plant.pcap", request.Label())
+}
+
+func TestFirstDifference(t *testing.T) {
+	for name, testCase := range map[string]struct {
+		a, b []byte
+		want int
+	}{
+		"identical":         {[]byte("abc"), []byte("abc"), -1},
+		"both empty":        {nil, nil, -1},
+		"differs at start":  {[]byte("abc"), []byte("xbc"), 0},
+		"differs in middle": {[]byte("abcd"), []byte("abxd"), 2},
+		"a is a prefix":     {[]byte("ab"), []byte("abc"), 2},
+		"b is a prefix":     {[]byte("abc"), []byte("ab"), 2},
+		"one empty":         {[]byte{}, []byte("a"), 0},
+	} {
+		t.Run(name, func(t *testing.T) {
+			assert.Equal(t, testCase.want, FirstDifference(testCase.a, testCase.b))
+		})
+	}
+}
+
+func TestDifferingBytesCountsTheTailOfTheLongerSlice(t *testing.T) {
+	assert.Equal(t, 0, differingBytes([]byte("abc"), []byte("abc")))
+	assert.Equal(t, 1, differingBytes([]byte("abc"), []byte("abx")))
+	assert.Equal(t, 2, differingBytes([]byte("abc"), []byte("a")))
+	assert.Equal(t, 3, differingBytes([]byte("abc"), []byte("xyz")))
+}
+
+// TestHexRowCaretColumnPointsAtTheRightByte is what makes the detail pane's marker truthful.
+func TestHexRowCaretColumnPointsAtTheRightByte(t *testing.T) {
+	data := []byte{0x5c, 0x33, 0x31, 0x33, 0x38, 0x30, 0x30, 0x30}
+	row := hexRow(data, 0, len(data), 8)
+
+	for offset := range len(data) {
+		column := hexRowCaretColumn(offset, 8)
+		assert.Equal(t, hexOf(data[offset]), row[column:column+2],
+			"the caret for offset %d must land on that byte's two hex digits", offset)
+	}
+}
+
+// hexOf renders one byte as its two lowercase hex digits.
+func hexOf(b byte) string {
+	const digits = "0123456789abcdef"
+	return string([]byte{digits[b>>4], digits[b&0xf]})
+}
+
+func TestHexDumpLinesRendersOffsetsAndPrintables(t *testing.T) {
+	lines := HexDumpLines([]byte("hello world"), 8, 10)
+	require.Len(t, lines, 2)
+	assert.True(t, strings.HasPrefix(lines[0], "00000000  "), "got %q", lines[0])
+	assert.Contains(t, lines[0], "|hello wo|")
+	assert.True(t, strings.HasPrefix(lines[1], "00000008  "), "got %q", lines[1])
+	assert.Contains(t, lines[1], "|rld|")
+}
+
+func TestHexDumpLinesSaysWhenThereIsNothing(t *testing.T) {
+	assert.Equal(t, []string{"(empty)"}, HexDumpLines(nil, 8, 4))
+}
+
+func TestPreviewQuotesAndTruncates(t *testing.T) {
+	assert.Equal(t, `""`, Preview(nil, 20, "."))
+	assert.Equal(t, `"~~~\r"`, Preview([]byte("~~~\r"), 20, "."))
+	assert.Equal(t, `"abcd.`, Preview([]byte("abcdefghij"), 6, "."))
+}
+
+func TestMessageNameStripsThePointerAndTheGeneratedPrefix(t *testing.T) {
+	pinGlobals(t)
+	result := Analyze(t.Context(), writeCBus(t, pcapfixture.CBusSession()), Sink{})
+	require.NoError(t, result.Err)
+
+	var named int
+	for _, record := range result.Records {
+		if record.Summary == "" {
+			continue
+		}
+		named++
+		assert.NotContains(t, record.Summary, "*", "%q must not carry the pointer marker", record.Summary)
+		assert.NotContains(t, record.Summary, ".", "%q must be the bare type name", record.Summary)
+		assert.False(t, strings.HasPrefix(record.Summary, "_"), "%q must not carry the generated-type prefix", record.Summary)
+	}
+	assert.Positive(t, named, "the fixture parses, so at least one record must be named")
+}
+
+func TestDirectionFollowsTheConfiguredClient(t *testing.T) {
+	pinGlobals(t)
+	result := Analyze(t.Context(), writeCBus(t, pcapfixture.CBusSession()), Sink{})
+	require.NoError(t, result.Err)
+
+	var requests, responses int
+	for _, record := range result.Records {
+		switch record.Direction {
+		case DirectionRequest:
+			requests++
+		case DirectionResponse:
+			responses++
+		}
+	}
+	assert.Positive(t, requests, "the fixture interleaves both directions")
+	assert.Positive(t, responses)
+}
+
+func TestDirectionIsUnknownWithoutAClient(t *testing.T) {
+	pinGlobals(t)
+	request := writeCBus(t, pcapfixture.CBusSession())
+	request.Client = ""
+
+	result := Analyze(t.Context(), request, Sink{})
+
+	require.NoError(t, result.Err)
+	for _, record := range result.Records {
+		assert.Equal(t, DirectionUnknown, record.Direction,
+			"with no client configured the tool must not guess which way a packet went")
+	}
+}
+
+func TestVerdictIsIssueOnlyForDefects(t *testing.T) {
+	for verdict, want := range map[Verdict]bool{
+		VerdictOK:            false,
+		VerdictSkipped:       false,
+		VerdictFiltered:      false,
+		VerdictNoPayload:     false,
+		VerdictBytesDiffer:   true,
+		VerdictParseFail:     true,
+		VerdictSerializeFail: true,
+	} {
+		assert.Equal(t, want, verdict.IsIssue(), "verdict %s", verdict)
+	}
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/commands.go b/plc4go/tools/plc4xpcapanalyzer/ui/commands.go
index 776c2fca..5008a6d5 100644
--- a/plc4go/tools/plc4xpcapanalyzer/ui/commands.go
+++ b/plc4go/tools/plc4xpcapanalyzer/ui/commands.go
@@ -23,704 +23,962 @@ import (
 	"context"
 	"fmt"
 	"os"
-	"path"
+	"path/filepath"
 	"reflect"
-	"runtime/debug"
+	"sort"
+	"strconv"
 	"strings"
-	"time"
 
 	plc4xconfig "github.com/apache/plc4x/plc4go/pkg/api/config"
-	"github.com/apache/plc4x/plc4go/spi"
 	"github.com/apache/plc4x/plc4go/spi/errors"
-	"github.com/rivo/tview"
 	"github.com/rs/zerolog"
-	"github.com/rs/zerolog/log"
 
-	cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
-	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer"
-	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/extractor"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
 )
 
-const rootCommandIndicator = "rootCommand"
-
-var rootCommand = Command{
-	Name: rootCommandIndicator,
-	subCommands: []Command{
-		{
-			Name:        "ls",
-			Description: "list directories",
-			action: func(_ context.Context, _ Command, dir string) error {
-				if dir == "" {
-					dir = currentDir
-				}
-				_, _ = fmt.Fprintf(commandOutput, "dir cotents of %s\n", dir)
-				readDir, err := os.ReadDir(dir)
-				if err != nil {
-					return err
+// The command layer.
+//
+// Every command is a function of the session state that RETURNS what it did. Nothing here
+// writes into a widget, formats an escape code or knows that a terminal exists — which is the
+// whole difference from the version this replaces, where each action printed markup straight
+// into a tview.TextView through a package-level io.Writer, and so could be neither tested nor
+// re-rendered when the terminal resized.
+//
+// Commands run on the Bubble Tea event loop, because they mutate State and State belongs to
+// the model. The two commands that can take a long time — analyze and extract — do not do the
+// work themselves: they return a Request in their Outcome and the model starts it as a
+// tea.Cmd.
+
+// Outcome is what running a command produced.
+//
+// It is a record of effects rather than a set of callbacks so that a test can assert on it
+// directly, and so that the model stays the only place that decides what an effect looks like
+// on screen.
+type Outcome struct {
+	// Lines is the text the command produced, for the log pane.
+	Lines []string
+	// Err is set when the command failed. A failed command still produces its Lines.
+	Err error
+
+	// Quit asks the program to exit.
+	Quit bool
+	// Abort asks for the running analysis to be cancelled.
+	Abort bool
+	// ClearPackets, ClearLog and ClearTranscript ask for the corresponding pane to be emptied.
+	ClearPackets    bool
+	ClearLog        bool
+	ClearTranscript bool
+	// Analysis and Extraction ask for a long-running run to be started.
+	Analysis   *Request
+	Extraction *Request
+}
+
+// Failed reports whether the command failed.
+func (o Outcome) Failed() bool { return o.Err != nil }
+
+// say builds an outcome carrying text.
+func say(lines ...string) Outcome { return Outcome{Lines: lines} }
+
+// fail builds a failed outcome.
+func fail(err error) Outcome { return Outcome{Err: err} }
+
+// failf builds a failed outcome from a format string.
+func failf(format string, args ...any) Outcome { return Outcome{Err: errors.Errorf(format, args...)} }
+
+// Command is one node of the command tree. A node with a Run is executable; a node with Sub
+// dispatches to its children; a node may have both, as host and conf do not but clear does.
+type Command struct {
+	Name        string
+	Description string
+	// Run performs the command. arg is everything after the command's own name.
+	Run func(ctx context.Context, state *State, arg string) Outcome
+	// Sub are the subcommands.
+	Sub []Command
+	// Suggest offers completions for the argument, given what has been typed of it so far.
+	// The returned values are argument fragments; the caller prefixes the command path.
+	Suggest func(state *State, partial string) []string
+}
+
+// child finds the subcommand with this exact name.
+func (c Command) child(name string) (Command, bool) {
+	for _, sub := range c.Sub {
+		if sub.Name == name {
+			return sub, true
+		}
+	}
+	return Command{}, false
+}
+
+// resolve walks the tree for a command line and returns the node to run together with its
+// argument. A word is only treated as a subcommand name once it is finished — followed by a
+// space or by more words — so that completing a half-typed name still sees the partial word.
+func (c Command) resolve(line string) (Command, string, bool) {
+	node := c
+	rest := line
+	matchedAny := false
+	for {
+		trimmed := strings.TrimLeft(rest, " ")
+		if trimmed == "" {
+			break
+		}
+		name, remainder, _ := strings.Cut(trimmed, " ")
+		child, ok := node.child(name)
+		if !ok {
+			break
+		}
+		node = child
+		rest = remainder
+		matchedAny = true
+	}
+	if !matchedAny {
+		return Command{}, "", false
+	}
+	return node, strings.TrimSpace(rest), true
+}
+
+// Execute runs a command line against the session state.
+func (c Command) Execute(ctx context.Context, state *State, line string) Outcome {
+	line = strings.TrimSpace(line)
+	if line == "" {
+		return Outcome{}
+	}
+	node, arg, ok := c.resolve(line)
+	if !ok {
+		word, _, _ := strings.Cut(line, " ")
+		return failf("unknown command %q, try help", word)
+	}
+	if node.Run == nil {
+		return failf("%s needs one of: %s", node.Name, strings.Join(node.childNames(), ", "))
+	}
+	return node.Run(ctx, state, arg)
+}
+
+// childNames lists the subcommand names, for an error message.
+func (c Command) childNames() []string {
+	names := make([]string, 0, len(c.Sub))
+	for _, sub := range c.Sub {
+		names = append(names, sub.Name)
+	}
+	return names
+}
+
+// Completions returns whole replacement lines for what has been typed so far.
+//
+// Whole lines, not fragments, because that is what bubbles/textinput matches: it tests each
+// candidate against the ENTIRE field value as a case-insensitive prefix, so returning "c-bus"
+// for the line "analyze c-" would match nothing.
+//
+// An empty line returns nothing. Offering all twenty-odd commands the moment the prompt is
+// focused fills the bottom of the screen with a menu the user did not ask for.
+func (c Command) Completions(state *State, line string) []string {
+	if strings.TrimSpace(line) == "" {
+		return nil
+	}
+
+	node := c
+	var consumed strings.Builder
+	rest := line
+	for {
+		trimmed := strings.TrimLeft(rest, " ")
+		if trimmed == "" {
+			break
+		}
+		name, remainder, hasMore := strings.Cut(trimmed, " ")
+		child, ok := node.child(name)
+		if !ok {
+			break
+		}
+		// The last word is still being typed unless something follows it. Consuming it would
+		// complete against the wrong node: "log" would offer log's subcommands rather than
+		// finishing the word "log" itself.
+		if !hasMore {
+			break
+		}
+		node = child
+		consumed.WriteString(name)
+		consumed.WriteString(" ")
+		rest = remainder
+	}
+	prefix := consumed.String()
+	partial := strings.TrimLeft(rest, " ")
+
+	var candidates []string
+	firstWord, _, _ := strings.Cut(partial, " ")
+	for _, sub := range node.Sub {
+		if strings.HasPrefix(sub.Name, firstWord) && !strings.Contains(partial, " ") {
+			candidates = append(candidates, prefix+sub.Name)
+		}
+	}
+	if node.Suggest != nil {
+		for _, suggestion := range node.Suggest(state, partial) {
+			candidates = append(candidates, prefix+suggestion)
+		}
+	}
+	return candidates
+}
+
+// Walk visits every command in the tree, depth first, carrying the indentation depth.
+func (c Command) Walk(depth int, visit func(depth int, command Command)) {
+	visit(depth, c)
+	for _, sub := range c.Sub {
+		sub.Walk(depth+1, visit)
+	}
+}
+
+// Root builds the command tree.
+//
+// It is a function rather than a package variable because part of the tree is derived by
+// reflection from the CLI configuration singletons, and a package variable would freeze that
+// at import time — which is also what made the old tree impossible to exercise from a test.
+func Root() Command {
+	root := Command{
+		Name:        "",
+		Description: "plc4xpcapanalyzer commands",
+		Sub: []Command{
+			commandLs(),
+			commandCd(),
+			commandPwd(),
+			commandOpen(),
+			commandAnalyze(),
+			commandExtract(),
+			commandHost(),
+			commandRegister(),
+			commandUnregister(),
+			commandQuit(),
+			commandLog(),
+			commandConf(),
+			commandPlc4xConf(),
+			commandHistory(),
+			commandClear(),
+			commandAbort(),
+		},
+	}
+	// help closes over the finished tree, so it has to be appended after it is built.
+	root.Sub = append(root.Sub, Command{
+		Name:        "help",
+		Description: "prints out this help",
+		Run: func(_ context.Context, _ *State, _ string) Outcome {
+			lines := []string{"Available commands"}
+			root.Walk(0, func(depth int, command Command) {
+				if command.Name == "" {
+					return
 				}
-				for _, dirEntry := range readDir {
-					isDir := dirEntry.IsDir()
-					name := dirEntry.Name()
-					name = strings.TrimPrefix(name, dir)
-					if isDir {
-						name = fmt.Sprintf("[#0000ff]%s[white]", name)
-					} else if strings.HasSuffix(name, ".pcap") || strings.HasSuffix(name, ".pcapng") {
-						name = fmt.Sprintf("[#00ff00]%s[white]", name)
-					}
-					_, _ = fmt.Fprintf(commandOutput, "%s\n", name)
+				description := command.Description
+				if description == "" {
+					description = command.Name + "s"
 				}
-				return nil
-			},
-			// TODO: add parameter suggestions
+				lines = append(lines, strings.Repeat("  ", depth-1)+"  "+command.Name+": "+description)
+			})
+			return say(lines...)
 		},
-		{
-			Name:        "cd",
-			Description: "changes directory",
-			action: func(_ context.Context, _ Command, newDir string) error {
-				var proposedCurrentDir string
-				if newDir == "" {
-					var err error
-					proposedCurrentDir, err = os.UserHomeDir()
-					if err != nil {
-						return err
-					}
-				} else if strings.HasPrefix(newDir, "."+string(os.PathSeparator)) {
-					proposedCurrentDir = currentDir + strings.TrimPrefix(newDir, ".")
-				} else if strings.HasPrefix(newDir, ""+string(os.PathSeparator)) {
-					proposedCurrentDir = newDir
-				} else {
-					proposedCurrentDir = currentDir + string(os.PathSeparator) + newDir
+	})
+	return root
+}
+
+func commandLs() Command {
+	return Command{
+		Name:        "ls",
+		Description: "list directories",
+		Run: func(_ context.Context, state *State, dir string) Outcome {
+			if dir == "" {
+				dir = state.CurrentDir
+			} else if !filepath.IsAbs(dir) {
+				dir = filepath.Join(state.CurrentDir, dir)
+			}
+			entries, err := os.ReadDir(dir)
+			if err != nil {
+				return fail(errors.Wrapf(err, "error listing %s", dir))
+			}
+			lines := []string{"contents of " + dir}
+			for _, entry := range entries {
+				name := entry.Name()
+				if entry.IsDir() {
+					name += string(os.PathSeparator)
 				}
-				stat, err := os.Stat(proposedCurrentDir)
+				lines = append(lines, "  "+name)
+			}
+			if len(entries) == 0 {
+				lines = append(lines, "  (empty)")
+			}
+			return say(lines...)
+		},
+		Suggest: suggestDirectories,
+	}
+}
+
+func commandCd() Command {
+	return Command{
+		Name:        "cd",
+		Description: "changes directory",
+		Run: func(_ context.Context, state *State, newDir string) Outcome {
+			var proposed string
+			switch {
+			case newDir == "":
+				home, err := os.UserHomeDir()
 				if err != nil {
-					return err
+					return fail(errors.Wrap(err, "error resolving the home directory"))
 				}
-				if !stat.IsDir() {
-					return errors.Errorf("%s is not a dir", newDir)
-				}
-				currentDir = proposedCurrentDir
-				_, _ = fmt.Fprintf(commandOutput, "current directory: %s\n", currentDir)
-				return nil
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				if strings.HasPrefix(currentText, string(os.PathSeparator)) {
-					dirEntries, err := os.ReadDir(currentText)
-					if err != nil {
-						plc4xpcapanalyzerLog.Warn().Err(err).Msg("Error suggesting directories")
-						return
-					}
-					for _, dirEntry := range dirEntries {
-						entry := path.Join(currentText, dirEntry.Name())
-						entries = append(entries, entry)
-					}
-				} else {
-					dirEntries, err := os.ReadDir(currentDir)
-					if err != nil {
-						plc4xpcapanalyzerLog.Warn().Err(err).Msg("Error suggesting directories")
-						return
-					}
-					for _, dirEntry := range dirEntries {
-						entry := path.Join(".", dirEntry.Name())
-						entries = append(entries, entry)
-					}
-				}
-				return
-			},
+				proposed = home
+			case filepath.IsAbs(newDir):
+				proposed = newDir
+			default:
+				proposed = filepath.Join(state.CurrentDir, newDir)
+			}
+			stat, err := os.Stat(proposed)
+			if err != nil {
+				return fail(errors.Wrapf(err, "error changing to %s", proposed))
+			}
+			if !stat.IsDir() {
+				return failf("%s is not a directory", newDir)
+			}
+			state.CurrentDir = filepath.Clean(proposed)
+			return say("current directory: " + state.CurrentDir)
 		},
-		{
-			Name:        "pwd",
-			Description: "shows current directory",
-			action: func(_ context.Context, _ Command, _ string) error {
-				_, _ = fmt.Fprintf(commandOutput, "current directory: %s\n", currentDir)
-				return nil
-			},
+		Suggest: suggestDirectories,
+	}
+}
+
+func commandPwd() Command {
+	return Command{
+		Name:        "pwd",
+		Description: "shows current directory",
+		Run: func(_ context.Context, state *State, _ string) Outcome {
+			return say("current directory: " + state.CurrentDir)
 		},
-		{
-			Name:        "open",
-			Description: "open file",
-			action: func(_ context.Context, _ Command, pcapFile string) error {
-				return OpenFile(pcapFile)
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				entries = append(entries, config.History.Last10Files...)
-				readDir, err := os.ReadDir(currentDir)
-				if err != nil {
-					return
-				}
-				for _, dirEntry := range readDir {
-					name := dirEntry.Name()
-					name = strings.TrimPrefix(name, currentDir)
-					if strings.HasSuffix(dirEntry.Name(), ".cap") || strings.HasSuffix(dirEntry.Name(), ".pcap") || strings.HasSuffix(name, ".pcapng") {
-						entries = append(entries, name)
-					}
-				}
-				return
-			},
+	}
+}
+
+func commandOpen() Command {
+	return Command{
+		Name:        "open",
+		Description: "open a pcap file",
+		Run: func(_ context.Context, state *State, pcapFile string) Outcome {
+			capture, err := state.Open(pcapFile)
+			if err != nil {
+				return fail(err)
+			}
+			return say("opened " + capture.Path)
 		},
-		{
-			Name:        "analyze",
-			Description: "Analyzes a pcap file using a driver",
-			action: func(ctx context.Context, _ Command, protocolTypeAndPcapFile string) error {
-				split := strings.Split(protocolTypeAndPcapFile, " ")
-				if len(split) != 2 {
-					return errors.Errorf("expect protocol and pcapfile")
-				}
-				protocolType := split[0]
-				pcapFile := strings.TrimPrefix(protocolTypeAndPcapFile, protocolType+" ")
-				cliConfig.PcapConfigInstance.Client = config.HostIp
-				cliConfig.RootConfigInstance.HideProgressBar = true
-				// disabled as we get this output anyway with the message call back
-				//cliConfig.RootConfigInstance.Verbosity = 4
-				return analyzer.AnalyzeWithOutputAndCallback(ctx, pcapFile, protocolType, tview.ANSIWriter(messageOutput), tview.ANSIWriter(messageOutput), func(parsed spi.Message) {
-					spiNumberOfMessagesReceived++
-					spiMessageReceived(spiNumberOfMessagesReceived, time.Now(), parsed)
-				})
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for _, file := range loadedPcapFiles {
-					for _, protocol := range protocolList {
-						entries = append(entries, protocol+" "+file.path)
-					}
-				}
-				return
-			},
+		Suggest: suggestCaptureFiles,
+	}
+}
+
+func commandAnalyze() Command {
+	return Command{
+		Name:        "analyze",
+		Description: "analyzes a pcap file using a driver",
+		Run: func(_ context.Context, state *State, arg string) Outcome {
+			request, err := analysisRequest(state, arg)
+			if err != nil {
+				return fail(err)
+			}
+			return Outcome{Analysis: &request}
 		},
-		{
-			Name:        "extract",
-			Description: "Extract a pcap file using a driver",
-			action: func(ctx context.Context, _ Command, protocolTypeAndPcapFile string) error {
-				split := strings.Split(protocolTypeAndPcapFile, " ")
-				if len(split) != 2 {
-					return errors.Errorf("expect protocol and pcapfile")
-				}
-				protocolType := split[0]
-				pcapFile := strings.TrimPrefix(protocolTypeAndPcapFile, protocolType+" ")
-				cliConfig.PcapConfigInstance.Client = config.HostIp
-				cliConfig.RootConfigInstance.HideProgressBar = true
-				cliConfig.RootConfigInstance.Verbosity = 4
-				return extractor.ExtractWithOutput(ctx, pcapFile, protocolType, tview.ANSIWriter(messageOutput), tview.ANSIWriter(messageOutput))
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for _, file := range loadedPcapFiles {
-					for _, protocol := range protocolList {
-						entries = append(entries, protocol+" "+file.path)
-					}
-				}
-				return
-			},
+		Suggest: suggestProtocolAndFile,
+	}
+}
+
+func commandExtract() Command {
+	return Command{
+		Name:        "extract",
+		Description: "extract a pcap file using a driver",
+		Run: func(_ context.Context, state *State, arg string) Outcome {
+			request, err := analysisRequest(state, arg)
+			if err != nil {
+				return fail(err)
+			}
+			return Outcome{Extraction: &request}
 		},
-		{
-			Name:        "host",
-			Description: "The host which is assumed to be the sender (important for protocols that are directional)",
-			subCommands: []Command{
-				{
-					Name: "set",
-					action: func(_ context.Context, _ Command, host string) error {
-						config.HostIp = host
-						return nil
-					},
+		Suggest: suggestProtocolAndFile,
+	}
+}
+
+// analysisRequest parses "<protocol> <pcapfile>" into a request, falling back to the session's
+// current protocol and capture when either half is omitted.
+//
+// The fallback is what makes the sidebar and the "a" shortcut work: they run "analyze" with
+// nothing after it, and the user has already said which capture is selected.
+func analysisRequest(state *State, arg string) (Request, error) {
+	arg = strings.TrimSpace(arg)
+	if arg == "" {
+		return state.Request()
+	}
+	name, file, hasFile := strings.Cut(arg, " ")
+	proto, err := protocol.Resolve(name)
+	if err != nil {
+		return Request{}, err
+	}
+	if !hasFile || strings.TrimSpace(file) == "" {
+		capture, ok := state.CurrentCapture()
+		if !ok {
+			return Request{}, errors.Errorf("analyze %s needs a file: analyze %s <pcapfile>", name, name)
+		}
+		return state.RequestFor(proto, capture.Path), nil
+	}
+	file = strings.TrimSpace(file)
+	if !filepath.IsAbs(file) {
+		file = filepath.Join(state.CurrentDir, file)
+	}
+	if _, err := os.Stat(file); err != nil {
+		return Request{}, errors.Wrapf(err, "error reading %s", file)
+	}
+	return state.RequestFor(proto, file), nil
+}
+
+func commandHost() Command {
+	return Command{
+		Name:        "host",
+		Description: "the host assumed to be the sender (matters for directional protocols)",
+		Sub: []Command{
+			{
+				Name:        "set",
+				Description: "sets the client address",
+				Run: func(_ context.Context, state *State, host string) Outcome {
+					host = strings.TrimSpace(host)
+					if host == "" {
+						return failf("host set needs an address: host set <ip>")
+					}
+					state.HostIP = host
+					state.Config.HostIp = host
+					return say("client address: " + host)
 				},
-				{
-					Name: "get",
-					action: func(_ context.Context, _ Command, host string) error {
-						_, _ = fmt.Fprintf(commandOutput, "current set host %s", config.HostIp)
-						return nil
-					},
+			},
+			{
+				Name:        "get",
+				Description: "shows the client address",
+				Run: func(_ context.Context, state *State, _ string) Outcome {
+					if state.HostIP == "" {
+						return say("no client address set: host set <ip>")
+					}
+					return say("client address: " + state.HostIP)
 				},
 			},
 		},
-		{
-			Name:        "register",
-			Description: "register a driver in the subsystem",
-			action: func(_ context.Context, _ Command, driver string) error {
-				return registerDriver(driver)
-			},
-			parameterSuggestions: func(currentText string) (entries []string) {
-				for _, protocol := range protocolList {
-					if strings.HasPrefix(protocol, currentText) {
-						entries = append(entries, protocol)
-					}
+	}
+}
+
+func commandRegister() Command {
+	return Command{
+		Name:        "register",
+		Description: "register a driver in the subsystem",
+		Run: func(_ context.Context, state *State, driver string) Outcome {
+			driver = strings.TrimSpace(driver)
+			if err := state.RegisterDriver(driver); err != nil {
+				return fail(err)
+			}
+			return say("registered driver " + driver)
+		},
+		Suggest: suggestDrivers,
+	}
+}
+
+// commandUnregister is new. The sidebar lists the drivers and lets the cursor act on a row, so
+// there has to be something for the row of an already-registered driver to do; leaving it
+// inert would be exactly the dead affordance this port set out to remove.
+func commandUnregister() Command {
+	return Command{
+		Name:        "unregister",
+		Description: "forget a driver registered in the subsystem",
+		Run: func(_ context.Context, state *State, driver string) Outcome {
+			driver = strings.TrimSpace(driver)
+			if err := ValidateDriver(driver); err != nil {
+				return fail(err)
+			}
+			if !state.IsRegistered(driver) {
+				return failf("%s is not registered", driver)
+			}
+			remaining := make([]string, 0, len(state.Registered))
+			for _, registered := range state.Registered {
+				if registered != driver {
+					remaining = append(remaining, registered)
 				}
-				return
-			},
+			}
+			// plc4x offers no way to remove a driver from a manager, so the manager is rebuilt
+			// from what is left. Saying so here is cheaper than surprising the next reader.
+			state.Registered, state.DriverManager = nil, nil
+			for _, registered := range remaining {
+				_ = state.RegisterDriver(registered)
+			}
+			return say("unregistered driver " + driver)
 		},
-		{
-			Name:        "quit",
-			Description: "Quits the application",
+		Suggest: suggestDrivers,
+	}
+}
+
+func commandQuit() Command {
+	return Command{
+		Name:        "quit",
+		Description: "quits the application",
+		Run: func(_ context.Context, _ *State, _ string) Outcome {
+			return Outcome{Quit: true}
 		},
-		{
-			Name:        "log",
-			Description: "Log related operations",
-			subCommands: []Command{
-				{
-					Name:        "get",
-					Description: "Get a log level",
-					action: func(_ context.Context, _ Command, _ string) error {
-						_, _ = fmt.Fprintf(commandOutput, "Current log level %s", log.Logger.GetLevel())
-						return nil
-					},
-				},
-				{
-					Name:        "set",
-					Description: "Sets a log level",
-					action: func(_ context.Context, _ Command, level string) error {
-						parseLevel, err := zerolog.ParseLevel(level)
-						if err != nil {
-							return errors.Wrapf(err, "Error setting log level")
-						}
-						setLevel(parseLevel)
-						log.Logger = log.Logger.Level(parseLevel)
-						return nil
-					},
-					parameterSuggestions: func(currentText string) (entries []string) {
-						levels := []string{
-							zerolog.LevelTraceValue,
-							zerolog.LevelDebugValue,
-							zerolog.LevelInfoValue,
-							zerolog.LevelWarnValue,
-							zerolog.LevelErrorValue,
-							zerolog.LevelFatalValue,
-							zerolog.LevelPanicValue,
-						}
-						for _, level := range levels {
-							entries = append(entries, level)
-						}
-						return
-					},
+	}
+}
+
+func commandLog() Command {
+	return Command{
+		Name:        "log",
+		Description: "log related operations",
+		Sub: []Command{
+			{
+				Name:        "get",
+				Description: "get the log level",
+				Run: func(_ context.Context, state *State, _ string) Outcome {
+					return say("current log level " + state.LogLevel.String())
 				},
 			},
-		},
-		{
-			Name:        "conf",
-			Description: "Various settings for plc4xpcapanalyzer",
-			subCommands: []Command{
-				{
-					Name:        "list",
-					Description: "list config values with their current settings",
-					action: func(_ context.Context, _ Command, _ string) error {
-						allCliConfigsValue := reflect.ValueOf(allCliConfigsInstances)
-						for i := 0; i < allCliConfigsValue.NumField(); i++ {
-							allConfigField := allCliConfigsValue.Field(i)
-							allConfigFieldType := allCliConfigsValue.Type().Field(i)
-							_, _ = fmt.Fprintf(commandOutput, "%s:\n", allConfigFieldType.Name)
-							configInstanceReflectValue := reflect.ValueOf(allConfigField.Interface())
-							if configInstanceReflectValue.Kind() == reflect.Pointer {
-								configInstanceReflectValue = configInstanceReflectValue.Elem()
-							}
-							for j := 0; j < configInstanceReflectValue.NumField(); j++ {
-								configField := configInstanceReflectValue.Field(j)
-								configFieldType := configInstanceReflectValue.Type().Field(j)
-								if configFieldType.Tag.Get("json") == "-" {
-									// Ignore those
-									continue
-								}
-								_, _ = fmt.Fprintf(commandOutput, "  %s: %s\t= %v\n", configFieldType.Name, configFieldType.Type, configField.Interface())
-							}
-						}
-						return nil
-					},
+			{
+				Name:        "set",
+				Description: "sets the log level",
+				Run: func(_ context.Context, state *State, level string) Outcome {
+					parsed, err := zerolog.ParseLevel(strings.TrimSpace(level))
+					if err != nil {
+						return fail(errors.Wrapf(err, "error setting log level"))
+					}
+					state.LogLevel = parsed
+					state.Config.LogLevel = parsed.String()
+					return say("log level " + parsed.String())
 				},
-				{
-					Name:        "set",
-					Description: "sets a config value",
-					subCommands: func() []Command {
-						var configCommand []Command
-						allCliConfigsValue := reflect.ValueOf(allCliConfigsInstances)
-						for i := 0; i < allCliConfigsValue.NumField(); i++ {
-							allConfigField := allCliConfigsValue.Field(i)
-							allConfigFieldType := allCliConfigsValue.Type().Field(i)
-							configCommand = append(configCommand, Command{
-								Name:        allConfigFieldType.Name,
-								Description: fmt.Sprintf("Setting for %s", allConfigFieldType.Name),
-								subCommands: func() []Command {
-									var configElementCommands []Command
-									configInstanceReflectValue := reflect.ValueOf(allConfigField.Interface())
-									if configInstanceReflectValue.Kind() == reflect.Pointer {
-										configInstanceReflectValue = configInstanceReflectValue.Elem()
-									}
-									for i := 0; i < configInstanceReflectValue.NumField(); i++ {
-										field := configInstanceReflectValue.Field(i)
-										fieldOfType := configInstanceReflectValue.Type().Field(i)
-										if fieldOfType.Tag.Get("json") == "-" {
-											// Ignore those
-											continue
-										}
-										configElementCommands = append(configElementCommands, Command{
-											Name:        fieldOfType.Name,
-											Description: fmt.Sprintf("Sets value for %s", fieldOfType.Name),
-											action: func(_ context.Context, _ Command, argument string) error {
-												field.SetString(argument)
-												return nil
-											},
-										})
-									}
-									return configElementCommands
-								}(),
-							})
+				Suggest: func(_ *State, partial string) []string {
+					var levels []string
+					for _, level := range logLevels {
+						if strings.HasPrefix(level, partial) {
+							levels = append(levels, level)
 						}
-						return configCommand
-					}(),
-				},
-				{
-					Name:        "plc4xpcapanalyzer-debug",
-					Description: "Prints out debug information of the pcap analyzer itself",
-					subCommands: []Command{
-						{
-							Name:        "on",
-							Description: "debug on",
-							action: func(_ context.Context, _ Command, _ string) error {
-								plc4xpcapanalyzerLog = zerolog.New(zerolog.ConsoleWriter{Out: tview.ANSIWriter(consoleOutput)})
-								return nil
-							},
-						},
-						{
-							Name:        "off",
-							Description: "debug off",
-							action: func(_ context.Context, _ Command, _ string) error {
-								plc4xpcapanalyzerLog = zerolog.Nop()
-								return nil
-							},
-						},
-					},
-				},
-				{
-					Name:        "auto-register",
-					Description: "autoregister driver at startup",
-					subCommands: []Command{
-						{
-							Name: "list",
-							action: func(_ context.Context, currentCommand Command, argument string) error {
-								_, _ = fmt.Fprintf(commandOutput, "Auto-register enabled drivers:\n  %s\n", strings.Join(config.AutoRegisterDrivers, "\n  "))
-								return nil
-							},
-						},
-						{
-							Name: "enable",
-							action: func(_ context.Context, _ Command, argument string) error {
-								return enableAutoRegister(argument)
-							},
-							parameterSuggestions: func(currentText string) (entries []string) {
-								for _, protocol := range protocolList {
-									if strings.HasPrefix(protocol, currentText) {
-										entries = append(entries, protocol)
-									}
-								}
-								return
-							},
-						},
-						{
-							Name: "disable",
-							action: func(_ context.Context, _ Command, argument string) error {
-								return disableAutoRegister(argument)
-							},
-							parameterSuggestions: func(currentText string) (entries []string) {
-								for _, protocol := range protocolList {
-									if strings.HasPrefix(protocol, currentText) {
-										entries = append(entries, protocol)
-									}
-								}
-								return
-							},
-						},
-					},
+					}
+					return levels
 				},
 			},
 		},
-		{
-			Name:        "plc4x-conf",
-			Description: "plc4x related settings",
-			subCommands: []Command{
-				{
-					Name:        "TraceTransactionManagerWorkers",
-					Description: "print information about transaction manager workers",
-					subCommands: []Command{
-						{
-							Name:        "on",
-							Description: "trace on",
-							action: func(_ context.Context, _ Command, _ string) error {
-								plc4xconfig.TraceTransactionManagerWorkers = true
-								return nil
-							},
-						},
-						{
-							Name:        "off",
-							Description: "trace off",
-							action: func(_ context.Context, _ Command, _ string) error {
-								plc4xconfig.TraceTransactionManagerWorkers = false
-								return nil
-							},
-						},
-					},
+	}
+}
+
+// logLevels are the levels log set accepts.
+var logLevels = []string{
+	zerolog.LevelTraceValue,
+	zerolog.LevelDebugValue,
+	zerolog.LevelInfoValue,
+	zerolog.LevelWarnValue,
+	zerolog.LevelErrorValue,
+	zerolog.LevelFatalValue,
+	zerolog.LevelPanicValue,
+}
+
+func commandConf() Command {
+	return Command{
+		Name:        "conf",
+		Description: "various settings for plc4xpcapanalyzer",
+		Sub: []Command{
+			{
+				Name:        "list",
+				Description: "list config values with their current settings",
+				Run: func(_ context.Context, _ *State, _ string) Outcome {
+					return say(describeConfigs(CliConfigInstances())...)
 				},
-				{
-					Name:        "TraceTransactionManagerTransactions",
-					Description: "print information about transaction manager transactions",
-					subCommands: []Command{
-						{
-							Name:        "on",
-							Description: "trace on",
-							action: func(_ context.Context, _ Command, _ string) error {
-								plc4xconfig.TraceTransactionManagerTransactions = true
-								return nil
-							},
-						},
-						{
-							Name:        "off",
-							Description: "trace off",
-							action: func(_ context.Context, _ Command, _ string) error {
-								plc4xconfig.TraceTransactionManagerTransactions = false
-								return nil
-							},
+			},
+			commandConfSet(),
+			{
+				Name:        "plc4xpcapanalyzer-debug",
+				Description: "prints out debug information of the pcap analyzer itself",
+				Sub: []Command{
+					{
+						Name:        "on",
+						Description: "debug on",
+						Run: func(_ context.Context, state *State, _ string) Outcome {
+							state.Debug = true
+							return say("plc4xpcapanalyzer debug on")
 						},
 					},
-				},
-				{
-					Name:        "TraceDefaultMessageCodecWorker",
-					Description: "print information about message codec workers",
-					subCommands: []Command{
-						{
-							Name:        "on",
-							Description: "trace on",
-							action: func(_ context.Context, _ Command, _ string) error {
-								plc4xconfig.TraceDefaultMessageCodecWorker = true
-								return nil
-							},
-						},
-						{
-							Name:        "off",
-							Description: "trace off",
-							action: func(_ context.Context, _ Command, _ string) error {
-								plc4xconfig.TraceDefaultMessageCodecWorker = false
-								return nil
-							},
+					{
+						Name:        "off",
+						Description: "debug off",
+						Run: func(_ context.Context, state *State, _ string) Outcome {
+							state.Debug = false
+							return say("plc4xpcapanalyzer debug off")
 						},
 					},
 				},
 			},
+			commandAutoRegister(),
 		},
-		{
-			Name:        "history",
-			Description: "outputs the last commands",
-			action: func(_ context.Context, _ Command, _ string) error {
-				outputCommandHistory()
-				return nil
+	}
+}
+
+func commandAutoRegister() Command {
+	return Command{
+		Name:        "auto-register",
+		Description: "autoregister drivers at startup",
+		Sub: []Command{
+			{
+				Name:        "list",
+				Description: "lists the drivers registered at startup",
+				Run: func(_ context.Context, state *State, _ string) Outcome {
+					if len(state.Config.AutoRegisterDrivers) == 0 {
+						return say("no drivers auto-registered: conf auto-register enable <driver>")
+					}
+					lines := []string{"auto-register enabled drivers:"}
+					for _, driver := range state.Config.AutoRegisterDrivers {
+						lines = append(lines, "  "+driver)
+					}
+					return say(lines...)
+				},
 			},
-		},
-		{
-			Name:        "clear",
-			Description: "clear all outputs",
-			action: func(_ context.Context, _ Command, _ string) error {
-				messageOutputClear()
-				consoleOutputClear()
-				commandOutputClear()
-				return nil
+			{
+				Name:        "enable",
+				Description: "auto-registers a driver at startup",
+				Run: func(_ context.Context, state *State, driver string) Outcome {
+					driver = strings.TrimSpace(driver)
+					if err := ValidateDriver(driver); err != nil {
+						return fail(err)
+					}
+					if err := state.Config.EnableAutoRegister(driver); err != nil {
+						return fail(err)
+					}
+					return say("auto-register enabled for " + driver)
+				},
+				Suggest: suggestDrivers,
 			},
-			subCommands: []Command{
-				{
-					Name:        "message",
-					Description: "clears message output",
-					action: func(_ context.Context, _ Command, _ string) error {
-						messageOutputClear()
-						return nil
-					},
+			{
+				Name:        "disable",
+				Description: "stops auto-registering a driver at startup",
+				Run: func(_ context.Context, state *State, driver string) Outcome {
+					driver = strings.TrimSpace(driver)
+					if err := ValidateDriver(driver); err != nil {
+						return fail(err)
+					}
+					if err := state.Config.DisableAutoRegister(driver); err != nil {
+						return fail(err)
+					}
+					return say("auto-register disabled for " + driver)
 				},
+				Suggest: suggestDrivers,
+			},
+		},
+	}
+}
+
+func commandPlc4xConf() Command {
+	toggle := func(name, description string, set func(bool)) Command {
+		return Command{
+			Name:        name,
+			Description: description,
+			Sub: []Command{
 				{
-					Name:        "console",
-					Description: "clears console output",
-					action: func(_ context.Context, _ Command, _ string) error {
-						consoleOutputClear()
-						return nil
+					Name:        "on",
+					Description: "trace on",
+					Run: func(_ context.Context, _ *State, _ string) Outcome {
+						set(true)
+						return say(name + " on")
 					},
 				},
 				{
-					Name:        "command",
-					Description: "clears command output",
-					action: func(_ context.Context, _ Command, _ string) error {
-						commandOutputClear()
-						return nil
+					Name:        "off",
+					Description: "trace off",
+					Run: func(_ context.Context, _ *State, _ string) Outcome {
+						set(false)
+						return say(name + " off")
 					},
 				},
 			},
+		}
+	}
+	return Command{
+		Name:        "plc4x-conf",
+		Description: "plc4x related settings",
+		Sub: []Command{
+			toggle("TraceTransactionManagerWorkers", "print information about transaction manager workers",
+				func(on bool) { plc4xconfig.TraceTransactionManagerWorkers = on }),
+			toggle("TraceTransactionManagerTransactions", "print information about transaction manager transactions",
+				func(on bool) { plc4xconfig.TraceTransactionManagerTransactions = on }),
+			toggle("TraceDefaultMessageCodecWorker", "print information about message codec workers",
+				func(on bool) { plc4xconfig.TraceDefaultMessageCodecWorker = on }),
 		},
-		{
-			Name:        "abort",
-			Description: "abort currently running jobs",
-			action: func(_ context.Context, _ Command, _ string) error {
-				for _, cancelFunc := range cancelFunctions {
-					cancelFunc()
-				}
-				return nil
-			},
+	}
+}
+
+func commandHistory() Command {
+	return Command{
+		Name:        "history",
+		Description: "outputs the last commands",
+		Run: func(_ context.Context, state *State, _ string) Outcome {
+			if len(state.Config.History.Last10Commands) == 0 {
+				return say("no commands remembered yet")
+			}
+			lines := []string{"last commands"}
+			for i, command := range state.Config.History.Last10Commands {
+				lines = append(lines, "  "+strconv.Itoa(i)+": "+command)
+			}
+			return say(lines...)
 		},
-	},
+	}
 }
 
-func init() {
-	// Because of the cycle we need to define the help command here as it needs access to the to command
-	rootCommand.subCommands = append(rootCommand.subCommands, Command{
-		Name:        "help",
-		Description: "prints out this help",
-		action: func(_ context.Context, _ Command, _ string) error {
-			_, _ = fmt.Fprintf(commandOutput, "[#0000ff]Available commands[white]\n")
-			rootCommand.visit(0, func(currentIndent int, command Command) {
-				indentString := strings.Repeat("  ", currentIndent)
-				description := command.Description
-				if description == "" {
-					description = command.Name + "s"
-				}
-				_, _ = fmt.Fprintf(commandOutput, "%s [#00ff00]%s[white]: %s\n", indentString, command.Name, description)
-			})
-			return nil
+func commandClear() Command {
+	return Command{
+		Name:        "clear",
+		Description: "clear all outputs",
+		Run: func(_ context.Context, _ *State, _ string) Outcome {
+			return Outcome{ClearPackets: true, ClearLog: true, ClearTranscript: true}
 		},
-	})
+		Sub: []Command{
+			{
+				Name:        "message",
+				Description: "clears the analysed packets",
+				Run: func(_ context.Context, _ *State, _ string) Outcome {
+					return Outcome{ClearPackets: true}
+				},
+			},
+			{
+				Name:        "console",
+				Description: "clears the log",
+				Run: func(_ context.Context, _ *State, _ string) Outcome {
+					return Outcome{ClearLog: true}
+				},
+			},
+			{
+				Name:        "command",
+				Description: "clears the command transcript",
+				Run: func(_ context.Context, _ *State, _ string) Outcome {
+					return Outcome{ClearTranscript: true}
+				},
+			},
+		},
+	}
 }
 
-var NotDirectlyExecutable = errors.New("Not directly executable")
+func commandAbort() Command {
+	return Command{
+		Name:        "abort",
+		Description: "abort currently running jobs",
+		Run: func(_ context.Context, _ *State, _ string) Outcome {
+			return Outcome{Abort: true}
+		},
+	}
+}
 
-type Command struct {
-	Name                 string
-	Description          string
-	action               func(ctx context.Context, currentCommand Command, argument string) error
-	subCommands          []Command
-	parameterSuggestions func(currentText string) (entries []string)
-}
-
-func (c Command) Completions(currentCommandText string) (entries []string) {
-	if c.Name == rootCommandIndicator && len(currentCommandText) == 0 {
-		// We don't return anything here to not pollute the command text by default
-		return
-	}
-	if c.acceptsCurrentText(currentCommandText) {
-		currentCommandPrefix := c.currentCommandPrefix()
-		doesCommandTextTargetSubCommand := c.doesCommandTextTargetSubCommand(currentCommandPrefix)
-		if c.hasDirectExecution() && !doesCommandTextTargetSubCommand {
-			if c.parameterSuggestions != nil {
-				preparedForParameters := c.prepareForParameters(currentCommandText)
-				for _, parameterSuggestion := range c.parameterSuggestions(preparedForParameters) {
-					entries = append(entries, currentCommandPrefix+parameterSuggestion)
-				}
-			} else if currentCommandText == "" {
-				entries = append(entries, c.Name)
-			}
+// commandConfSet builds "conf set <Config> <Field> <value>" by reflecting over the CLI
+// configuration singletons, the way the previous version did.
+//
+// Unlike the previous version it does not call reflect.Value.SetString unconditionally. Every
+// non-string field — and most of them are bool or uint — made that call panic, and the panic
+// was swallowed by a recover in the command dispatcher and reported as "panic occurred". Each
+// kind is now converted properly and an unsupported one is a plain error.
+func commandConfSet() Command {
+	set := Command{
+		Name:        "set",
+		Description: "sets a config value",
+	}
+	configs := reflect.ValueOf(CliConfigInstances())
+	for i := range configs.NumField() {
+		configValue := configs.Field(i)
+		configType := configs.Type().Field(i)
+		fields := elemOf(reflect.ValueOf(configValue.Interface()))
+		if !fields.IsValid() || fields.Kind() != reflect.Struct {
+			continue
 		}
-		if doesCommandTextTargetSubCommand {
-			remainder := c.prepareForSubCommand(currentCommandText)
-			for _, command := range c.subCommands {
-				for _, subCommandCompletions := range command.Completions(remainder) {
-					entries = append(entries, currentCommandPrefix+subCommandCompletions)
-				}
+		group := Command{
+			Name:        configType.Name,
+			Description: "setting for " + configType.Name,
+		}
+		for j := range fields.NumField() {
+			field := fields.Field(j)
+			fieldType := fields.Type().Field(j)
+			if fieldType.Tag.Get("json") == "-" || !field.CanSet() {
+				continue
 			}
+			group.Sub = append(group.Sub, Command{
+				Name:        fieldType.Name,
+				Description: "sets " + configType.Name + "." + fieldType.Name,
+				Run: func(_ context.Context, _ *State, argument string) Outcome {
+					if err := assign(field, strings.TrimSpace(argument)); err != nil {
+						return fail(errors.Wrapf(err, "error setting %s.%s", configType.Name, fieldType.Name))
+					}
+					return say(fmt.Sprintf("%s.%s = %v", configType.Name, fieldType.Name, field.Interface()))
+				},
+			})
+		}
+		if len(group.Sub) > 0 {
+			set.Sub = append(set.Sub, group)
 		}
-	} else if strings.HasPrefix(c.Name, currentCommandText) {
-		// Suggest ourselves if we start with the current letter
-		entries = append(entries, c.Name)
 	}
-	return
+	return set
 }
 
-func (c Command) acceptsCurrentText(currentCommandText string) bool {
-	if c.Name == rootCommandIndicator {
-		return true
+// elemOf dereferences a pointer value, leaving anything else alone.
+func elemOf(value reflect.Value) reflect.Value {
+	if value.Kind() == reflect.Pointer {
+		return value.Elem()
 	}
-	hasThePrefix := strings.HasPrefix(currentCommandText, c.Name)
-	hasNoMatchingAlternative := !strings.HasPrefix(currentCommandText, c.Name+"-")
-	accepts := hasThePrefix && hasNoMatchingAlternative
-	plc4xpcapanalyzerLog.Debug().
-		Stringer("c", c).
-		Bool("accepts", accepts).
-		Msg("c accepts accepts")
-	return accepts
+	return value
 }
 
-func (c Command) doesCommandTextTargetSubCommand(currentCommandText string) bool {
-	if c.Name == rootCommandIndicator {
-		return true
-	}
-	if len(c.subCommands) == 0 {
-		return false
+// assign writes a textual value into a config field, converting it to the field's type.
+func assign(field reflect.Value, value string) error {
+	switch field.Kind() {
+	case reflect.String:
+		field.SetString(value)
+		return nil
+	case reflect.Bool:
+		parsed, err := strconv.ParseBool(value)
+		if err != nil {
+			return errors.Errorf("%q is not a boolean, use true or false", value)
+		}
+		field.SetBool(parsed)
+		return nil
+	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+		parsed, err := strconv.ParseInt(value, 10, 64)
+		if err != nil {
+			return errors.Errorf("%q is not a number", value)
+		}
+		field.SetInt(parsed)
+		return nil
+	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+		parsed, err := strconv.ParseUint(value, 10, 64)
+		if err != nil {
+			return errors.Errorf("%q is not a positive number", value)
+		}
+		field.SetUint(parsed)
+		return nil
+	default:
+		return errors.Errorf("fields of type %s cannot be set from the prompt", field.Type())
 	}
-	return strings.HasPrefix(currentCommandText, c.currentCommandPrefix())
 }
 
-func (c Command) prepareForParameters(currentCommandText string) string {
-	if currentCommandText == c.Name {
-		return ""
+// describeConfigs renders every configuration value, for conf list.
+func describeConfigs(configs AllCliConfigs) []string {
+	value := reflect.ValueOf(configs)
+	var lines []string
+	for i := range value.NumField() {
+		lines = append(lines, value.Type().Field(i).Name+":")
+		fields := elemOf(reflect.ValueOf(value.Field(i).Interface()))
+		if !fields.IsValid() || fields.Kind() != reflect.Struct {
+			continue
+		}
+		for j := range fields.NumField() {
+			fieldType := fields.Type().Field(j)
+			if fieldType.Tag.Get("json") == "-" {
+				continue
+			}
+			lines = append(lines, fmt.Sprintf("  %s: %s = %v", fieldType.Name, fieldType.Type, fields.Field(j).Interface()))
+		}
 	}
-	return strings.TrimPrefix(currentCommandText, c.currentCommandPrefix())
-}
-func (c Command) prepareForSubCommand(currentCommandText string) string {
-	return strings.TrimPrefix(currentCommandText, c.currentCommandPrefix())
+	return lines
 }
 
-func (c Command) currentCommandPrefix() string {
-	if c.Name == rootCommandIndicator {
-		return ""
+// suggestDrivers completes a driver name.
+func suggestDrivers(_ *State, partial string) []string {
+	var out []string
+	for _, driver := range DriverNames {
+		if strings.HasPrefix(driver, partial) {
+			out = append(out, driver)
+		}
 	}
-	return c.Name + " "
+	return out
 }
 
-func (c Command) hasDirectExecution() bool {
-	return c.action != nil
-}
-
-func Execute(ctx context.Context, commandText string) error {
-	err := rootCommand.Execute(ctx, commandText)
-	if err == nil {
-		addCommandHistoryEntry(commandText)
+// suggestDirectories completes a directory, relative to the session's current directory unless
+// the partial is already absolute.
+func suggestDirectories(state *State, partial string) []string {
+	base, prefix := completionBase(state, partial)
+	entries, err := os.ReadDir(base)
+	if err != nil {
+		return nil
 	}
-	return err
+	var out []string
+	for _, entry := range entries {
+		if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
+			continue
+		}
+		out = append(out, completionJoin(partial, prefix, entry.Name()))
+	}
+	sort.Strings(out)
+	return out
 }
 
-func (c Command) Execute(ctx context.Context, commandText string) (err error) {
-	defer func() {
-		if recoveredErr := recover(); recoveredErr != nil {
-			if log.Debug().Enabled() {
-				log.Error().
-					Str("stack", string(debug.Stack())).
-					Interface("err", err).
-					Msg("panic-ed")
+// captureExtensions are the file types open completes.
+var captureExtensions = []string{".pcap", ".pcapng", ".cap"}
+
+// suggestCaptureFiles completes a capture file, offering the recently opened ones first
+// because those are what a returning session usually wants.
+func suggestCaptureFiles(state *State, partial string) []string {
+	var out []string
+	for _, recent := range state.Config.History.Last10Files {
+		if strings.HasPrefix(recent, partial) {
+			out = append(out, recent)
+		}
+	}
+	base, prefix := completionBase(state, partial)
+	entries, err := os.ReadDir(base)
+	if err != nil {
+		return out
+	}
+	for _, entry := range entries {
+		name := entry.Name()
+		if !strings.HasPrefix(name, prefix) {
+			continue
+		}
+		if entry.IsDir() {
+			out = append(out, completionJoin(partial, prefix, name))
+			continue
+		}
+		for _, extension := range captureExtensions {
+			if strings.HasSuffix(strings.ToLower(name), extension) {
+				out = append(out, completionJoin(partial, prefix, name))
+				break
 			}
-			err = errors.Errorf("panic occurred: %v.", recoveredErr)
 		}
-	}()
-	plc4xpcapanalyzerLog.Debug().
-		Stringer("c", c).Str("commandText", commandText).
-		Msg("c executes commandText")
-	if !c.acceptsCurrentText(commandText) {
-		return errors.Errorf("%s doesn't understand %s", c.Name, commandText)
-	}
-	if c.doesCommandTextTargetSubCommand(commandText) {
-		prepareForSubCommandForSubCommand := c.prepareForSubCommand(commandText)
-		for _, command := range c.subCommands {
-			if command.acceptsCurrentText(prepareForSubCommandForSubCommand) {
-				plc4xpcapanalyzerLog.Debug().
-					Stringer("c", c).
-					Str("commandText", commandText).
-					Msg("c delegates to sub command")
-				return command.Execute(ctx, prepareForSubCommandForSubCommand)
+	}
+	return out
+}
+
+// suggestProtocolAndFile completes the "<protocol> <pcapfile>" argument analyze and extract
+// take, offering the open captures once a protocol has been chosen.
+func suggestProtocolAndFile(state *State, partial string) []string {
+	name, file, hasFile := strings.Cut(partial, " ")
+	if !hasFile {
+		var out []string
+		for _, proto := range protocol.All() {
+			if strings.HasPrefix(proto.Name, name) {
+				out = append(out, proto.Name)
 			}
 		}
-		return errors.Errorf("%s not accepted by any subcommands of %s", commandText, c.Name)
-	} else {
-		if c.action == nil {
-			return NotDirectlyExecutable
+		return out
+	}
+	proto, err := protocol.Resolve(name)
+	if err != nil {
+		return nil
+	}
+	var out []string
+	for _, capture := range state.Captures {
+		if strings.HasPrefix(capture.Path, file) || strings.HasPrefix(capture.Name, file) {
+			out = append(out, proto.Name+" "+capture.Path)
 		}
-		plc4xpcapanalyzerLog.Debug().
-			Stringer("c", c).
-			Str("commandText", commandText).
-			Msg("c executes commandText directly")
-		preparedForParameters := c.prepareForParameters(commandText)
-		return c.action(ctx, c, preparedForParameters)
 	}
+	for _, candidate := range suggestCaptureFiles(state, file) {
+		out = append(out, proto.Name+" "+candidate)
+	}
+	return out
 }
 
-func (c Command) visit(i int, f func(currentIndent int, command Command)) {
-	f(i, c)
-	for _, subCommand := range c.subCommands {
-		subCommand.visit(i+1, f)
+// completionBase splits a partial path into the directory to read and the name prefix to
+// match inside it.
+func completionBase(state *State, partial string) (base, prefix string) {
+	if partial == "" {
+		return state.CurrentDir, ""
+	}
+	dir, name := filepath.Split(partial)
+	switch {
+	case dir == "":
+		return state.CurrentDir, name
+	case filepath.IsAbs(dir):
+		return dir, name
+	default:
+		return filepath.Join(state.CurrentDir, dir), name
 	}
 }
 
-func (c Command) String() string {
-	return c.Name
+// completionJoin rebuilds a completion so that it replaces exactly what the user typed,
+// keeping any directory part they had already entered.
+func completionJoin(partial, prefix, name string) string {
+	return strings.TrimSuffix(partial, prefix) + name
 }
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/commands_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/commands_test.go
new file mode 100644
index 00000000..b47eafc1
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/ui/commands_test.go
@@ -0,0 +1,432 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package ui
+
+import (
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"testing"
+
+	"github.com/rs/zerolog"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
+)
+
+// run executes one command line against a session and returns what it produced.
+func run(t *testing.T, state *State, line string) Outcome {
+	t.Helper()
+	return Root().Execute(t.Context(), state, line)
+}
+
+// TestEveryCommandTheOldUiOfferedIsStillHere is the inventory check.
+//
+// The list is transcribed from the tview version's command tree. It is spelled out rather than
+// derived so that removing a command from the tree fails here instead of quietly shipping.
+func TestEveryCommandTheOldUiOfferedIsStillHere(t *testing.T) {
+	inventory := []string{
+		"ls", "cd", "pwd", "open", "analyze", "extract",
+		"host set", "host get",
+		"register", "quit",
+		"log get", "log set",
+		"conf list",
+		"conf plc4xpcapanalyzer-debug on", "conf plc4xpcapanalyzer-debug off",
+		"conf auto-register list", "conf auto-register enable", "conf auto-register disable",
+		"plc4x-conf TraceTransactionManagerWorkers on", "plc4x-conf TraceTransactionManagerWorkers off",
+		"plc4x-conf TraceTransactionManagerTransactions on", "plc4x-conf TraceTransactionManagerTransactions off",
+		"plc4x-conf TraceDefaultMessageCodecWorker on", "plc4x-conf TraceDefaultMessageCodecWorker off",
+		"history",
+		"clear", "clear message", "clear console", "clear command",
+		"abort", "help",
+	}
+	root := Root()
+	for _, path := range inventory {
+		t.Run(path, func(t *testing.T) {
+			node, _, ok := root.resolve(path)
+			require.True(t, ok, "%q no longer resolves to a command", path)
+			last := path[strings.LastIndex(path, " ")+1:]
+			assert.Equal(t, last, node.Name, "%q resolved to %q instead", path, node.Name)
+			assert.NotNil(t, node.Run, "%q resolves but cannot be run", path)
+		})
+	}
+}
+
+func TestConfSetCoversEveryConfigurationStruct(t *testing.T) {
+	root := Root()
+	set, _, ok := root.resolve("conf set")
+	require.True(t, ok, "conf set is missing")
+	assert.NotEmpty(t, set.Sub, "conf set is a dispatcher; with no groups it can set nothing")
+	for _, group := range []string{"RootConfig", "AnalyzeConfig", "ExtractConfig", "BacnetConfig", "CBusConfig", "PcapConfig"} {
+		node, _, ok := root.resolve("conf set " + group)
+		require.True(t, ok, "conf set %s is missing", group)
+		assert.NotEmpty(t, node.Sub, "conf set %s offers no fields", group)
+	}
+}
+
+func TestUnknownCommandNamesTheWayOut(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+	outcome := run(t, state, "frobnicate the widget")
+	require.Error(t, outcome.Err)
+	assert.Contains(t, outcome.Err.Error(), "frobnicate")
+	assert.Contains(t, outcome.Err.Error(), "help")
+}
+
+func TestACommandThatNeedsASubcommandSaysWhichOnes(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+	outcome := run(t, state, "host")
+	require.Error(t, outcome.Err)
+	assert.Contains(t, outcome.Err.Error(), "set")
+	assert.Contains(t, outcome.Err.Error(), "get")
+}
+
+func TestPwdAndCd(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.Mkdir(filepath.Join(dir, "captures"), 0o755))
+	state := NewState(dir, NewConfig())
+
+	assert.Contains(t, run(t, state, "pwd").Lines[0], dir)
+
+	outcome := run(t, state, "cd captures")
+	require.NoError(t, outcome.Err)
+	assert.Equal(t, filepath.Join(dir, "captures"), state.CurrentDir)
+
+	failed := run(t, state, "cd nowhere")
+	assert.Error(t, failed.Err)
+}
+
+func TestCdRefusesAFile(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "a.pcap"), []byte("x"), 0o600))
+	state := NewState(dir, NewConfig())
+
+	outcome := run(t, state, "cd a.pcap")
+	require.Error(t, outcome.Err)
+	assert.Contains(t, outcome.Err.Error(), "not a directory")
+}
+
+func TestLsListsTheDirectory(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "a.pcap"), []byte("x"), 0o600))
+	require.NoError(t, os.Mkdir(filepath.Join(dir, "sub"), 0o755))
+	state := NewState(dir, NewConfig())
+
+	outcome := run(t, state, "ls")
+	require.NoError(t, outcome.Err)
+	joined := strings.Join(outcome.Lines, "\n")
+	assert.Contains(t, joined, "a.pcap")
+	assert.Contains(t, joined, "sub"+string(os.PathSeparator))
+}
+
+func TestOpenSelectsTheCapture(t *testing.T) {
+	state, demo := demoState(t)
+	state.Captures, state.Current = nil, -1
+
+	outcome := run(t, state, "open "+DemoCaptureName)
+	require.NoError(t, outcome.Err)
+	capture, ok := state.CurrentCapture()
+	require.True(t, ok)
+	assert.Equal(t, demo.Path, capture.Path)
+	assert.Contains(t, state.Config.History.Last10Files, demo.Path,
+		"opening a capture must put it in the recent list, which is what open completes from")
+}
+
+func TestOpeningTheSameCaptureTwiceSelectsItRatherThanFailing(t *testing.T) {
+	state, _ := demoState(t)
+	before := len(state.Captures)
+
+	outcome := run(t, state, "open "+DemoCaptureName)
+	require.NoError(t, outcome.Err)
+	assert.Len(t, state.Captures, before, "an already-open capture must be selected, not duplicated")
+}
+
+func TestOpenReportsAMissingFile(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+	outcome := run(t, state, "open nope.pcap")
+	require.Error(t, outcome.Err)
+	assert.Contains(t, outcome.Err.Error(), "nope.pcap")
+}
+
+func TestAnalyzeReturnsARequestRatherThanDoingTheWork(t *testing.T) {
+	state, demo := demoState(t)
+
+	outcome := run(t, state, "analyze c-bus "+demo.Path)
+	require.NoError(t, outcome.Err)
+	require.NotNil(t, outcome.Analysis, "the command layer must hand the model a request, not run it")
+	assert.Equal(t, protocol.CBus.Name, outcome.Analysis.Protocol.Name)
+	assert.Equal(t, demo.Path, outcome.Analysis.PcapFile)
+	assert.Equal(t, demo.Client, outcome.Analysis.Client)
+}
+
+// TestAnalyzeAcceptsTheBacnetAlias is the regression guard for the defect the protocol registry
+// was introduced to fix: the CLI accepted "bacnet" and the analyzer only ever matched
+// "bacnetip".
+func TestAnalyzeAcceptsTheBacnetAlias(t *testing.T) {
+	state, demo := demoState(t)
+
+	outcome := run(t, state, "analyze bacnet "+demo.Path)
+	require.NoError(t, outcome.Err)
+	require.NotNil(t, outcome.Analysis)
+	assert.Equal(t, protocol.BacnetIP.Name, outcome.Analysis.Protocol.Name)
+}
+
+func TestAnalyzeWithNoArgumentsUsesTheSelectedCapture(t *testing.T) {
+	state, demo := demoState(t)
+
+	outcome := run(t, state, "analyze")
+	require.NoError(t, outcome.Err)
+	require.NotNil(t, outcome.Analysis)
+	assert.Equal(t, demo.Path, outcome.Analysis.PcapFile)
+}
+
+func TestAnalyzeWithNothingOpenSaysWhatToDo(t *testing.T) {
+	pinGlobals(t)
+	state := NewState(t.TempDir(), NewConfig())
+
+	outcome := run(t, state, "analyze")
+	require.Error(t, outcome.Err)
+	assert.Contains(t, outcome.Err.Error(), "open <pcapfile>")
+}
+
+func TestAnalyzeRejectsAnUnknownProtocol(t *testing.T) {
+	state, demo := demoState(t)
+	outcome := run(t, state, "analyze modbus "+demo.Path)
+	require.Error(t, outcome.Err)
+	assert.Contains(t, outcome.Err.Error(), "modbus")
+}
+
+func TestExtractReturnsAnExtraction(t *testing.T) {
+	state, demo := demoState(t)
+	outcome := run(t, state, "extract c-bus "+demo.Path)
+	require.NoError(t, outcome.Err)
+	require.NotNil(t, outcome.Extraction)
+	assert.Nil(t, outcome.Analysis)
+	assert.Equal(t, demo.Path, outcome.Extraction.PcapFile)
+}
+
+func TestHostSetAndGet(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+
+	assert.Contains(t, run(t, state, "host get").Lines[0], "host set <ip>")
+
+	require.NoError(t, run(t, state, "host set 10.0.0.1").Err)
+	assert.Equal(t, "10.0.0.1", state.HostIP)
+	assert.Equal(t, "10.0.0.1", state.Config.HostIp, "the address has to survive the session")
+	assert.Contains(t, run(t, state, "host get").Lines[0], "10.0.0.1")
+}
+
+func TestRegisterAndUnregisterADriver(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+
+	require.NoError(t, run(t, state, "register c-bus").Err)
+	assert.True(t, state.IsRegistered("c-bus"))
+	assert.NotNil(t, state.DriverManager)
+
+	assert.Error(t, run(t, state, "register c-bus").Err, "registering twice must say so rather than silently succeed")
+	assert.Error(t, run(t, state, "register nonsense").Err)
+
+	require.NoError(t, run(t, state, "unregister c-bus").Err)
+	assert.False(t, state.IsRegistered("c-bus"))
+	assert.Error(t, run(t, state, "unregister c-bus").Err)
+}
+
+func TestLogSetChangesTheLevel(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+
+	require.NoError(t, run(t, state, "log set debug").Err)
+	assert.Equal(t, zerolog.DebugLevel, state.LogLevel)
+	assert.Equal(t, "debug", state.Config.LogLevel)
+	assert.Contains(t, run(t, state, "log get").Lines[0], "debug")
+
+	assert.Error(t, run(t, state, "log set shouting").Err)
+}
+
+// TestConfSetConvertsToTheFieldsType is the fix for a real panic: the old implementation called
+// reflect.Value.SetString on every field, so setting any bool or uint blew up and the
+// dispatcher's recover reported it as "panic occurred".
+func TestConfSetConvertsToTheFieldsType(t *testing.T) {
+	pinGlobals(t)
+	state := NewState(t.TempDir(), NewConfig())
+
+	// Filter lives on PcapConfig, which AnalyzeConfig embeds. The reflection walks declared
+	// fields only, so the embedded struct is addressed by its own name — the same shape the
+	// previous version had.
+	require.NoError(t, run(t, state, "conf set PcapConfig Filter tcp port 10001").Err)
+	assert.Equal(t, "tcp port 10001", cliConfig.AnalyzeConfigInstance.Filter)
+
+	require.NoError(t, run(t, state, "conf set AnalyzeConfig OnlyParse true").Err)
+	assert.True(t, cliConfig.AnalyzeConfigInstance.OnlyParse)
+
+	require.NoError(t, run(t, state, "conf set PcapConfig PackageNumberLimit 42").Err)
+	assert.Equal(t, uint(42), cliConfig.PcapConfigInstance.PackageNumberLimit)
+
+	require.NoError(t, run(t, state, "conf set RootConfig Verbosity 3").Err)
+	assert.Equal(t, 3, cliConfig.RootConfigInstance.Verbosity)
+}
+
+func TestConfSetRejectsAValueOfTheWrongType(t *testing.T) {
+	pinGlobals(t)
+	state := NewState(t.TempDir(), NewConfig())
+
+	outcome := run(t, state, "conf set AnalyzeConfig OnlyParse maybe")
+	require.Error(t, outcome.Err)
+	assert.Contains(t, outcome.Err.Error(), "true or false")
+	assert.NotContains(t, outcome.Err.Error(), "panic")
+}
+
+func TestConfListShowsTheCurrentValues(t *testing.T) {
+	pinGlobals(t)
+	cliConfig.AnalyzeConfigInstance.Filter = "udp port 47808"
+	state := NewState(t.TempDir(), NewConfig())
+
+	joined := strings.Join(run(t, state, "conf list").Lines, "\n")
+	assert.Contains(t, joined, "AnalyzeConfig")
+	assert.Contains(t, joined, "udp port 47808")
+}
+
+func TestAutoRegister(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+
+	assert.Contains(t, run(t, state, "conf auto-register list").Lines[0], "conf auto-register enable")
+
+	require.NoError(t, run(t, state, "conf auto-register enable c-bus").Err)
+	assert.Equal(t, []string{"c-bus"}, state.Config.AutoRegisterDrivers)
+	assert.Error(t, run(t, state, "conf auto-register enable c-bus").Err)
+	assert.Error(t, run(t, state, "conf auto-register enable nonsense").Err)
+
+	require.NoError(t, run(t, state, "conf auto-register disable c-bus").Err)
+	assert.Empty(t, state.Config.AutoRegisterDrivers)
+	assert.Error(t, run(t, state, "conf auto-register disable c-bus").Err)
+}
+
+func TestDebugToggle(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+	require.NoError(t, run(t, state, "conf plc4xpcapanalyzer-debug on").Err)
+	assert.True(t, state.Debug)
+	require.NoError(t, run(t, state, "conf plc4xpcapanalyzer-debug off").Err)
+	assert.False(t, state.Debug)
+}
+
+func TestHistoryListsTheRememberedCommands(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+	assert.Contains(t, run(t, state, "history").Lines[0], "no commands")
+
+	state.Config.RememberCommand("open a.pcap")
+	state.Config.RememberCommand("analyze c-bus a.pcap")
+	joined := strings.Join(run(t, state, "history").Lines, "\n")
+	assert.Contains(t, joined, "open a.pcap")
+	assert.Contains(t, joined, "analyze c-bus a.pcap")
+}
+
+func TestClearTargetsTheRightPane(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+
+	all := run(t, state, "clear")
+	assert.True(t, all.ClearPackets && all.ClearLog && all.ClearTranscript)
+
+	assert.True(t, run(t, state, "clear message").ClearPackets)
+	assert.False(t, run(t, state, "clear message").ClearLog)
+	assert.True(t, run(t, state, "clear console").ClearLog)
+	assert.True(t, run(t, state, "clear command").ClearTranscript)
+}
+
+func TestQuitAndAbortAreEffectsRatherThanActions(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+	assert.True(t, run(t, state, "quit").Quit)
+	assert.True(t, run(t, state, "abort").Abort)
+}
+
+func TestHelpListsEveryCommand(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+	joined := strings.Join(run(t, state, "help").Lines, "\n")
+	for _, name := range []string{"analyze", "extract", "conf", "auto-register", "plc4x-conf", "abort"} {
+		assert.Contains(t, joined, name)
+	}
+}
+
+func TestCompletionsAreWholeLines(t *testing.T) {
+	state, demo := demoState(t)
+
+	for _, testCase := range []struct {
+		line string
+		want string
+	}{
+		{"an", "analyze"},
+		{"conf ", "conf list"},
+		{"conf a", "conf auto-register"},
+		{"log s", "log set"},
+		{"log set de", "log set debug"},
+		{"register c", "register c-bus"},
+		{"analyze c", "analyze c-bus"},
+		{"analyze c-bus ", "analyze c-bus " + demo.Path},
+	} {
+		t.Run(testCase.line, func(t *testing.T) {
+			candidates := Root().Completions(state, testCase.line)
+			assert.Contains(t, candidates, testCase.want)
+			for _, candidate := range candidates {
+				assert.True(t, strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(strings.TrimRight(testCase.line, " "))),
+					"%q is not a replacement for %q; textinput matches a candidate against the whole line", candidate, testCase.line)
+			}
+		})
+	}
+}
+
+// TestCompletionsAreSilentOnAnEmptyLine keeps a menu of every command from appearing under the
+// prompt the moment it takes focus.
+func TestCompletionsAreSilentOnAnEmptyLine(t *testing.T) {
+	state := NewState(t.TempDir(), NewConfig())
+	assert.Empty(t, Root().Completions(state, ""))
+	assert.Empty(t, Root().Completions(state, "   "))
+}
+
+func TestOpenCompletesCaptureFilesAndDirectories(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "trace.pcap"), []byte("x"), 0o600))
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o600))
+	require.NoError(t, os.Mkdir(filepath.Join(dir, "more"), 0o755))
+	state := NewState(dir, NewConfig())
+
+	candidates := Root().Completions(state, "open ")
+	assert.Contains(t, candidates, "open trace.pcap")
+	assert.Contains(t, candidates, "open more")
+	assert.NotContains(t, candidates, "open notes.txt", "a text file is not a capture")
+}
+
+func TestCdCompletesOnlyDirectories(t *testing.T) {
+	dir := t.TempDir()
+	require.NoError(t, os.WriteFile(filepath.Join(dir, "trace.pcap"), []byte("x"), 0o600))
+	require.NoError(t, os.Mkdir(filepath.Join(dir, "more"), 0o755))
+	state := NewState(dir, NewConfig())
+
+	candidates := Root().Completions(state, "cd ")
+	assert.Equal(t, []string{"cd more"}, candidates)
+}
+
+func TestValidateDriverNamesTheKnownOnes(t *testing.T) {
+	err := ValidateDriver("modbus")
+	require.Error(t, err)
+	for _, driver := range DriverNames {
+		assert.Contains(t, err.Error(), driver)
+	}
+	assert.True(t, slices.Contains(DriverNames, "c-bus"))
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/common.go b/plc4go/tools/plc4xpcapanalyzer/ui/common.go
deleted file mode 100644
index ff8cdd46..00000000
--- a/plc4go/tools/plc4xpcapanalyzer/ui/common.go
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-package ui
-
-import (
-	"context"
-	"io"
-	"os"
-	"strings"
-	"sync"
-	"time"
-
-	plc4go "github.com/apache/plc4x/plc4go/pkg/api"
-	apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
-	"github.com/apache/plc4x/plc4go/spi"
-	"github.com/rs/zerolog"
-)
-
-const protocols = "ads,bacnetip,c-bus,s7"
-
-var protocolList = strings.Split(protocols, ",")
-
-var plc4xpcapanalyzerLog = zerolog.Nop()
-
-var driverManager plc4go.PlcDriverManager
-var driverAdded func(string)
-
-type loadedPcapFile struct {
-	name string
-	path string
-}
-
-var loadedPcapFiles []loadedPcapFile
-var loadedPcapFilesChanged func()
-
-var messageReceived func(messageNumber int, receiveTime time.Time, message apiModel.PlcMessage)
-var numberOfMessagesReceived int
-var spiMessageReceived func(messageNumber int, receiveTime time.Time, message spi.Message)
-var spiNumberOfMessagesReceived int
-var messageOutput io.Writer
-var messageOutputClear func()
-
-var consoleOutput io.Writer
-var consoleOutputClear func()
-
-var commandsExecuted int
-var commandOutput io.Writer
-var commandOutputClear func()
-
-var currentDirChanged func()
-var currentDir = func() string {
-	dir, _ := os.Getwd()
-	return dir
-}()
-
-var rootContext = context.Background()
-var cancelFunctions = make(map[uint32]context.CancelFunc)
-
-var shutdownMutex sync.Mutex
-var hasShutdown bool
-
-func Shutdown() {
-	shutdownMutex.Lock()
-	defer shutdownMutex.Unlock()
-	if hasShutdown {
-		return
-	}
-	hasShutdown = true
-	saveConfig()
-}
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/config.go b/plc4go/tools/plc4xpcapanalyzer/ui/config.go
index 2b08cf8f..29b738c3 100644
--- a/plc4go/tools/plc4xpcapanalyzer/ui/config.go
+++ b/plc4go/tools/plc4xpcapanalyzer/ui/config.go
@@ -21,41 +21,49 @@ package ui
 
 import (
 	"os"
-	"path"
+	"path/filepath"
 	"slices"
 	"time"
 
 	"github.com/apache/plc4x/plc4go/spi/errors"
-	"github.com/rs/zerolog"
-	"github.com/rs/zerolog/log"
 	"gopkg.in/yaml.v3"
 
 	cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
 )
 
-var plc4xpcapanalyzerConfigDir string
-var configFile string
-var config = Config{
-	MaxConsoleLines: 500,
-	MaxOutputLines:  500,
-	CliConfigs:      allCliConfigsInstances,
-}
+// The session configuration this tool remembers between runs.
+//
+// It is loaded and saved explicitly rather than in an init function. The version this replaces
+// created the configuration directory from a package-level init and panicked when it could
+// not, so merely importing the package for a test wrote to the user's home directory and, on a
+// machine where os.UserConfigDir fails, took the whole process down before main ran.
+
+// ConfigFileName is the file the session configuration is kept in, inside the tool's directory
+// under the user's configuration directory.
+const ConfigFileName = "config.yml"
+
+// HistoryLimit is how many recent files and recent commands are remembered. The field names
+// below still say "last 10" because that is what the on-disk format calls them.
+const HistoryLimit = 10
 
+// Config is what survives between sessions.
 type Config struct {
 	HostIp  string `yaml:"host_ip"`
 	History struct {
 		Last10Files    []string `yaml:"last_hosts"`
 		Last10Commands []string `yaml:"last_commands"`
-	}
+	} `yaml:"history"`
 	AutoRegisterDrivers []string      `yaml:"auto_register_driver"`
 	LastUpdated         time.Time     `yaml:"last_updated"`
 	LogLevel            string        `yaml:"log_level"`
 	MaxConsoleLines     int           `yaml:"max_console_lines"`
 	MaxOutputLines      int           `yaml:"max_output_lines"`
-	CliConfigs          allCliConfigs `yaml:"cli_configs"`
+	CliConfigs          AllCliConfigs `yaml:"cli_configs"`
 }
 
-type allCliConfigs struct {
+// AllCliConfigs is the set of CLI configuration singletons the conf command reflects over.
+// The pointers are shared with the cobra layer, so a value set here is the value a run uses.
+type AllCliConfigs struct {
 	RootConfig    *cliConfig.RootConfig
 	AnalyzeConfig *cliConfig.AnalyzeConfig
 	ExtractConfig *cliConfig.ExtractConfig
@@ -64,141 +72,141 @@ type allCliConfigs struct {
 	PcapConfig    *cliConfig.PcapConfig
 }
 
-var allCliConfigsInstances = allCliConfigs{
-	&cliConfig.RootConfigInstance,
-	&cliConfig.AnalyzeConfigInstance,
-	&cliConfig.ExtractConfigInstance,
-	&cliConfig.BacnetConfigInstance,
-	&cliConfig.CBusConfigInstance,
-	&cliConfig.PcapConfigInstance,
+// CliConfigInstances points at the live singletons.
+func CliConfigInstances() AllCliConfigs {
+	return AllCliConfigs{
+		RootConfig:    &cliConfig.RootConfigInstance,
+		AnalyzeConfig: &cliConfig.AnalyzeConfigInstance,
+		ExtractConfig: &cliConfig.ExtractConfigInstance,
+		BacnetConfig:  &cliConfig.BacnetConfigInstance,
+		CBusConfig:    &cliConfig.CBusConfigInstance,
+		PcapConfig:    &cliConfig.PcapConfigInstance,
+	}
 }
 
-func init() {
+// NewConfig returns the defaults a fresh installation starts from.
+func NewConfig() Config {
+	config := Config{
+		MaxConsoleLines: 500,
+		MaxOutputLines:  500,
+		CliConfigs:      CliConfigInstances(),
+	}
+	return config
+}
+
+// ConfigDir is the directory the session configuration lives in. It returns an error rather
+// than panicking, because a tool that cannot find a home directory should still start.
+func ConfigDir() (string, error) {
 	userConfigDir, err := os.UserConfigDir()
 	if err != nil {
-		panic(err)
+		return "", errors.Wrap(err, "error resolving the user configuration directory")
 	}
-	plc4xpcapanalyzerConfigDir = path.Join(userConfigDir, "plc4xpcapanalyzer")
-	if _, err := os.Stat(plc4xpcapanalyzerConfigDir); os.IsNotExist(err) {
-		err := os.Mkdir(plc4xpcapanalyzerConfigDir, os.ModeDir|os.ModePerm)
-		if err != nil {
-			panic(err)
-		}
-	}
-	configFile = path.Join(plc4xpcapanalyzerConfigDir, "config.yml")
+	return filepath.Join(userConfigDir, "plc4xpcapanalyzer"), nil
 }
 
-func LoadConfig() {
-	f, err := os.Open(configFile)
+// ConfigPath is the full path of the session configuration file.
+func ConfigPath() (string, error) {
+	dir, err := ConfigDir()
 	if err != nil {
-		log.Info().Err(err).Msg("No config file found")
-		return
+		return "", err
 	}
-	defer func(f *os.File) {
-		err := f.Close()
-		if err != nil {
-			log.Error().Err(err).Msg("Error closing config file")
+	return filepath.Join(dir, ConfigFileName), nil
+}
+
+// LoadConfigFrom reads the configuration at path. A missing file is not an error: it is what a
+// first run looks like, and the defaults are correct for it.
+func LoadConfigFrom(path string) (Config, error) {
+	config := NewConfig()
+	file, err := os.Open(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return config, nil
 		}
-	}(f)
+		return config, errors.Wrapf(err, "error opening %s", path)
+	}
+	defer func() { _ = file.Close() }()
 
-	decoder := yaml.NewDecoder(f)
-	if err = decoder.Decode(&config); err != nil {
-		log.Warn().Err(err).Msg("Can't decode config file")
-		return
+	if err := yaml.NewDecoder(file).Decode(&config); err != nil {
+		// A corrupt file must not stop the tool starting, so the defaults are returned
+		// alongside the error and the caller decides how loudly to complain.
+		return NewConfig(), errors.Wrapf(err, "error decoding %s", path)
 	}
+	// Decoding replaces the pointer set, and a file written by an older version may not carry
+	// one at all. Point them back at the live singletons so conf set reaches the real config.
+	config.CliConfigs = CliConfigInstances()
+	return config, nil
 }
 
-func saveConfig() {
-	config.LastUpdated = time.Now()
-	f, err := os.OpenFile(configFile, os.O_RDWR|os.O_CREATE, 0755)
+// SaveConfigTo writes the configuration to path, creating the directory if it is missing.
+func SaveConfigTo(path string, config Config, now time.Time) error {
+	config.LastUpdated = now
+	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+		return errors.Wrapf(err, "error creating %s", filepath.Dir(path))
+	}
+	file, err := os.Create(path)
 	if err != nil {
-		log.Warn().Err(err).Msg("Can't save config file")
-		return
+		return errors.Wrapf(err, "error creating %s", path)
 	}
-	encoder := yaml.NewEncoder(f)
-	defer func(encoder *yaml.Encoder) {
-		err := encoder.Close()
-		if err != nil {
-			log.Error().Err(err).Msg("Error closing config file")
-		}
-	}(encoder)
+	defer func() { _ = file.Close() }()
+
+	encoder := yaml.NewEncoder(file)
 	if err := encoder.Encode(config); err != nil {
-		log.Warn().Err(err).Msg("Can't encode config file")
-		panic(err)
+		_ = encoder.Close()
+		return errors.Wrapf(err, "error encoding %s", path)
 	}
+	return errors.Wrapf(encoder.Close(), "error closing %s", path)
 }
 
-func addRecentFilesEntry(pcapFile string) {
-	existingIndex := -1
-	for i, lastPcapFile := range config.History.Last10Files {
-		if lastPcapFile == pcapFile {
-			existingIndex = i
-			break
-		}
-	}
-	if existingIndex >= 0 {
-		config.History.Last10Files = append(config.History.Last10Files[:existingIndex], config.History.Last10Files[existingIndex+1:]...)
-	}
-	if len(config.History.Last10Files) >= 10 {
-		config.History.Last10Files = config.History.Last10Files[1:]
-	}
-	config.History.Last10Files = append(config.History.Last10Files, pcapFile)
+// RememberFile records a capture in the recent-files list, most recent last, without
+// duplicates.
+func (c *Config) RememberFile(pcapFile string) {
+	c.History.Last10Files = remember(c.History.Last10Files, pcapFile)
 }
 
-func addCommandHistoryEntry(command string) {
+// RememberCommand records a command in the recent-commands list.
+//
+// clear and history are skipped, as they were before: they are the two commands whose whole
+// purpose is to inspect or reset the session, and remembering them crowds out the commands a
+// user actually wants to recall.
+func (c *Config) RememberCommand(command string) {
 	switch command {
-	case "clear":
-		return
-	case "history":
+	case "clear", "history":
 		return
 	}
-	existingIndex := -1
-	for i, lastCommand := range config.History.Last10Commands {
-		if lastCommand == command {
-			existingIndex = i
-			break
-		}
+	c.History.Last10Commands = remember(c.History.Last10Commands, command)
+}
+
+// remember appends entry, moving an existing occurrence to the end and dropping the oldest
+// once the limit is reached.
+func remember(entries []string, entry string) []string {
+	if entry == "" {
+		return entries
 	}
-	if existingIndex >= 0 {
-		config.History.Last10Commands = append(config.History.Last10Commands[:existingIndex], config.History.Last10Commands[existingIndex+1:]...)
+	if index := slices.Index(entries, entry); index >= 0 {
+		entries = slices.Delete(entries, index, index+1)
 	}
-	if len(config.History.Last10Commands) >= 10 {
-		config.History.Last10Commands = config.History.Last10Commands[1:]
+	entries = append(entries, entry)
+	if len(entries) > HistoryLimit {
+		entries = slices.Delete(entries, 0, len(entries)-HistoryLimit)
 	}
-	config.History.Last10Commands = append(config.History.Last10Commands, command)
-}
-
-func setLevel(level zerolog.Level) {
-	config.LogLevel = level.String()
+	return entries
 }
 
-func enableAutoRegister(driver string) error {
-	if err := validateDriverParam(driver); err != nil {
-		return err
-	}
-	if slices.Contains(config.AutoRegisterDrivers, driver) {
+// EnableAutoRegister adds a driver to the set registered at startup.
+func (c *Config) EnableAutoRegister(driver string) error {
+	if slices.Contains(c.AutoRegisterDrivers, driver) {
 		return errors.Errorf("%s already registered for auto register", driver)
 	}
-	config.AutoRegisterDrivers = append(config.AutoRegisterDrivers, driver)
-	log.Info().Str("driver", driver).Msg("Auto register enabled")
+	c.AutoRegisterDrivers = append(c.AutoRegisterDrivers, driver)
 	return nil
 }
 
-func disableAutoRegister(driver string) error {
-	if err := validateDriverParam(driver); err != nil {
-		return err
-	}
-	index := -1
-	for i, autoRegisterDriver := range config.AutoRegisterDrivers {
-		if autoRegisterDriver == driver {
-			index = i
-			break
-		}
-	}
+// DisableAutoRegister removes a driver from the set registered at startup.
+func (c *Config) DisableAutoRegister(driver string) error {
+	index := slices.Index(c.AutoRegisterDrivers, driver)
 	if index < 0 {
 		return errors.Errorf("%s not registered for auto register", driver)
 	}
-	config.AutoRegisterDrivers = append(config.AutoRegisterDrivers[:index], config.AutoRegisterDrivers[index+1:]...)
-	log.Info().Str("driver", driver).Msg("Auto register disabled")
+	c.AutoRegisterDrivers = slices.Delete(c.AutoRegisterDrivers, index, index+1)
 	return nil
 }
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/demo.go b/plc4go/tools/plc4xpcapanalyzer/ui/demo.go
new file mode 100644
index 00000000..1f64a78f
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/ui/demo.go
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package ui
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/apache/plc4x/plc4go/spi/errors"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture"
+	"github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol"
+)
+
+// Demo mode.
+//
+// The analyzers parse raw captured bytes: there is no connection, no handshake and no device
+// anywhere in the path. So demo mode does not need a fake driver, and it does not need a
+// capture committed to the repository either — it writes one, from the same fixture builder
+// the tests use, containing genuine C-Bus traffic taken from plc4x's own protocol tests.
+//
+// The session it generates deliberately includes payloads the codec CANNOT parse. A demo where
+// everything succeeds shows none of what this tool is for; this one produces findings, so the
+// Findings tab, the failure counters and the detail pane all have something real in them.
+
+// DemoCaptureName is the file the generated capture is written as. It is the name the sidebar
+// shows, so it says what it is.
+const DemoCaptureName = "cbus-demo.pcap"
+
+// Demo is a generated capture, and everything needed to analyse it.
+type Demo struct {
+	// Dir is the temporary directory holding the capture. Cleanup removes it.
+	Dir string
+	// Path is the capture.
+	Path string
+	// Protocol is what the capture contains.
+	Protocol protocol.Protocol
+	// Client is the address the capture's requests come from, which C-Bus needs in order to
+	// tell a request from a response.
+	Client string
+}
+
+// NewDemo writes a demo capture into a fresh temporary directory.
+func NewDemo() (Demo, error) {
+	dir, err := os.MkdirTemp("", "plc4xpcapanalyzer-demo-")
+	if err != nil {
+		return Demo{}, errors.Wrap(err, "error creating the demo directory")
+	}
+	demo, err := NewDemoIn(dir)
+	if err != nil {
+		_ = os.RemoveAll(dir)
+		return Demo{}, err
+	}
+	return demo, nil
+}
+
+// NewDemoIn writes a demo capture into an existing directory. Tests use it with t.TempDir so
+// that nothing has to be cleaned up by hand.
+func NewDemoIn(dir string) (Demo, error) {
+	path := filepath.Join(dir, DemoCaptureName)
+	if err := pcapfixture.WriteCBus(path, pcapfixture.CBusSessionWithFailures()); err != nil {
+		return Demo{}, errors.Wrap(err, "error writing the demo capture")
+	}
+	return Demo{
+		Dir:      dir,
+		Path:     path,
+		Protocol: protocol.CBus,
+		Client:   pcapfixture.ClientIP,
+	}, nil
+}
+
+// Cleanup removes the generated capture. It is safe to call on a zero Demo, and safe to call
+// twice, so a deferred call needs no guard.
+func (d Demo) Cleanup() error {
+	if d.Dir == "" {
+		return nil
+	}
+	return errors.Wrapf(os.RemoveAll(d.Dir), "error removing the demo directory %s", d.Dir)
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/helpers_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/helpers_test.go
new file mode 100644
index 00000000..f4bd89c1
--- /dev/null
+++ b/plc4go/tools/plc4xpcapanalyzer/ui/helpers_test.go
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package ui
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/rs/zerolog"
+	"github.com/stretchr/testify/require"
+
+	"github.com/apache/plc4x-extras/plc4go/tools/internal/tui"
+	cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config"
+)
+
+// Test helpers.
+//
+// Two pieces of process-global state have to be pinned in every test that touches an analysis:
+// the CLI configuration singletons, which are where the analysis options live, and the global
+// zerolog logger, which the C-Bus analyzer writes several lines per packet to.
+
+// TestMain silences the global logger for the whole package.
+//
+// The plc4x driver registration and the C-Bus analyzer both log several lines per call through
+// it, and a test run that prints a thousand JSON records buries its own failures.
+func TestMain(m *testing.M) {
+	zerolog.SetGlobalLevel(zerolog.Disabled)
+	os.Exit(m.Run())
+}
+
+// testTheme is the theme every view test renders with: no colour and the ASCII glyph set, so
+// that an assertion on the content is an assertion on the content and a width is a count of
+// characters.
+func testTheme() tui.Options {
+	return tui.Options{Dark: true, NoColor: true, ASCII: true}
+}
+
+// fixedClock is a clock that does not move, so an elapsed time is whatever the test says it is.
+func fixedClock() func() time.Time {
+	base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
+	return func() time.Time { return base }
+}
+
+// pinGlobals saves and restores the configuration singletons and the logger.
+func pinGlobals(t *testing.T) {
+	t.Helper()
+	savedRoot := cliConfig.RootConfigInstance
+	savedPcap := cliConfig.PcapConfigInstance
+	savedAnalyze := cliConfig.AnalyzeConfigInstance
+	savedExtract := cliConfig.ExtractConfigInstance
+	savedBacnet := cliConfig.BacnetConfigInstance
+	savedCBus := cliConfig.CBusConfigInstance
+	savedLevel := zerolog.GlobalLevel()
+	t.Cleanup(func() {
+		cliConfig.RootConfigInstance = savedRoot
+		cliConfig.PcapConfigInstance = savedPcap
+		cliConfig.AnalyzeConfigInstance = savedAnalyze
+		cliConfig.ExtractConfigInstance = savedExtract
+		cliConfig.BacnetConfigInstance = savedBacnet
+		cliConfig.CBusConfigInstance = savedCBus
+		zerolog.SetGlobalLevel(savedLevel)
+	})
+	// The C-Bus analyzer logs at debug for every packet, which would bury a test's own output.
+	zerolog.SetGlobalLevel(zerolog.Disabled)
+	cliConfig.RootConfigInstance.HideProgressBar = true
+}
+
+// demoState builds a session with a generated demo capture open, in a directory that the test
+// framework removes.
+func demoState(t *testing.T) (*State, Demo) {
+	t.Helper()
+	pinGlobals(t)
+	demo, err := NewDemoIn(t.TempDir())
+	require.NoError(t, err)
+
+	state := NewState(demo.Dir, NewConfig())
+	state.Demo = true
+	state.Protocol = demo.Protocol
+	state.HostIP = demo.Client
+	_, err = state.Open(demo.Path)
+	require.NoError(t, err)
+	return state, demo
+}
+
+// newTestModel builds a model at a size, with a fixed clock and a stable theme.
+func newTestModel(t *testing.T, state *State, size tui.Size) Model {
+	t.Helper()
+	model := NewModel(Options{Theme: testTheme(), State: state, Clock: fixedClock()})
+	return send(model, tea.WindowSizeMsg{Width: size.Width, Height: size.Height})
+}
+
+// send delivers one message and returns the model that came back.
+func send(model Model, msg tea.Msg) Model {
+	next, _ := model.Update(msg)
+	return next.(Model)
+}
+
+// sendWithCmd delivers one message and returns the model and the command it produced.
+func sendWithCmd(model Model, msg tea.Msg) (Model, tea.Cmd) {
+	next, cmd := model.Update(msg)
+	return next.(Model), cmd
+}
+
+// charKey builds the key press a terminal sends for a printable character. Text has to be set
+// as well as Code, because the key name is taken from Text and that is what key.Matches
+// compares against.
+func charKey(r rune) tea.KeyPressMsg {
+	return tea.KeyPressMsg{Code: r, Text: string(r)}
+}
+
+// namedKey builds the key press for a named key, which carries no text.
+func namedKey(code rune) tea.KeyPressMsg {
+	return tea.KeyPressMsg{Code: code}
+}
+
+// modKey builds a modified key press, such as alt+3 or shift+tab.
+func modKey(code rune, mod tea.KeyMod) tea.KeyPressMsg {
+	return tea.KeyPressMsg{Code: code, Mod: mod}
+}
diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/keys.go b/plc4go/tools/plc4xpcapanalyzer/ui/keys.go
new file mode 100644
index 00000000..3d77a767
--- /dev/null
... 5203 lines suppressed ...