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

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

commit ee188f5e72bcede567cd29e5e8b819e3a21cc249
Author: Sebastian Rühl <[email protected]>
AuthorDate: Tue Nov 25 12:51:04 2025 +0100

    feat(plc4go): charmbracelet support for plc4xbrowser
---
 plc4go/go.mod                              |  16 +
 plc4go/go.sum                              |  32 ++
 plc4go/tools/plc4xbrowser/main.go          |  20 +-
 plc4go/tools/plc4xbrowser/ui/actions.go    |  23 +-
 plc4go/tools/plc4xbrowser/ui/commands.go   |  18 +-
 plc4go/tools/plc4xbrowser/ui/common.go     |  12 +
 plc4go/tools/plc4xbrowser/ui/dispatcher.go |  59 ++
 plc4go/tools/plc4xbrowser/ui/log_buffer.go |  65 +++
 plc4go/tools/plc4xbrowser/ui/output.go     |  39 ++
 plc4go/tools/plc4xbrowser/ui/program.go    | 862 +++++++++++++++++++++++++++++
 10 files changed, 1134 insertions(+), 12 deletions(-)

diff --git a/plc4go/go.mod b/plc4go/go.mod
index 7346a47..69853e4 100644
--- a/plc4go/go.mod
+++ b/plc4go/go.mod
@@ -23,6 +23,9 @@ go 1.25
 
 require (
        github.com/apache/plc4x/plc4go v0.0.0-20251124092144-6738c00ca1a7
+       github.com/charmbracelet/bubbles v0.21.0
+       github.com/charmbracelet/bubbletea v1.3.10
+       github.com/charmbracelet/lipgloss v1.1.0
        github.com/fatih/color v1.18.0
        github.com/gdamore/tcell/v2 v2.12.0
        github.com/gopacket/gopacket v1.5.0
@@ -39,11 +42,18 @@ require (
 
 require (
        github.com/IBM/netaddr v1.5.0 // indirect
+       github.com/atotto/clipboard v0.1.4 // indirect
+       github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
        github.com/bitfield/gotestdox v0.2.2 // indirect
+       github.com/charmbracelet/colorprofile 
v0.2.3-0.20250311203215-f60798e515dc // indirect
+       github.com/charmbracelet/x/ansi v0.10.1 // indirect
+       github.com/charmbracelet/x/cellbuf 
v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+       github.com/charmbracelet/x/term v0.2.1 // indirect
        github.com/chigopher/pathlib v0.19.1 // indirect
        github.com/cstockton/go-conv v1.0.0 // indirect
        github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 
indirect
        github.com/dnephin/pflag v1.0.7 // indirect
+       github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 
indirect
        github.com/fsnotify/fsnotify v1.9.0 // indirect
        github.com/gdamore/encoding v1.0.1 // indirect
        github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
@@ -60,9 +70,14 @@ require (
        github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
        github.com/mattn/go-colorable v0.1.14 // indirect
        github.com/mattn/go-isatty v0.0.20 // indirect
+       github.com/mattn/go-localereader v0.0.1 // indirect
+       github.com/mattn/go-runewidth v0.0.16 // indirect
        github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // 
indirect
        github.com/mitchellh/go-homedir v1.1.0 // indirect
        github.com/mitchellh/mapstructure v1.5.0 // indirect
+       github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+       github.com/muesli/cancelreader v0.2.2 // indirect
+       github.com/muesli/termenv v0.16.0 // indirect
        github.com/pelletier/go-toml/v2 v2.2.4 // indirect
        github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 
indirect
        github.com/rivo/uniseg v0.4.7 // indirect
@@ -74,6 +89,7 @@ require (
        github.com/stretchr/objx v0.5.2 // indirect
        github.com/subosito/gotenv v1.6.0 // indirect
        github.com/vektra/mockery/v2 v2.53.2 // indirect
+       github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
        go.yaml.in/yaml/v3 v3.0.4 // indirect
        golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
        golang.org/x/mod v0.30.0 // indirect
diff --git a/plc4go/go.sum b/plc4go/go.sum
index 65bf061..bab20c5 100644
--- a/plc4go/go.sum
+++ b/plc4go/go.sum
@@ -6,8 +6,26 @@ github.com/antchfx/xpath v0.0.0-20170515025933-1f3266e77307 
h1:C735MoY/X+UOx6SEC
 github.com/antchfx/xpath v0.0.0-20170515025933-1f3266e77307/go.mod 
h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
 github.com/apache/plc4x/plc4go v0.0.0-20251124092144-6738c00ca1a7 
h1:kDoe4hT0iZcB/2+mbHPnLdPCEkmeqPUCGxaxMFw+5CA=
 github.com/apache/plc4x/plc4go v0.0.0-20251124092144-6738c00ca1a7/go.mod 
h1:yDBaYPcJ6jyhjEAMVN0TryBpyKAmpxYF/3MQ9IXhWmY=
+github.com/atotto/clipboard v0.1.4 
h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod 
h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 
h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod 
h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 github.com/bitfield/gotestdox v0.2.2 
h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
 github.com/bitfield/gotestdox v0.2.2/go.mod 
h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
+github.com/charmbracelet/bubbles v0.21.0 
h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+github.com/charmbracelet/bubbles v0.21.0/go.mod 
h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+github.com/charmbracelet/bubbletea v1.3.10 
h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod 
h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc 
h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile 
v0.2.3-0.20250311203215-f60798e515dc/go.mod 
h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 
h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod 
h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.10.1 
h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
+github.com/charmbracelet/x/ansi v0.10.1/go.mod 
h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd 
h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf 
v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod 
h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 
h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod 
h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/chengxilo/virtualterm v1.0.4 
h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
 github.com/chengxilo/virtualterm v1.0.4/go.mod 
h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
 github.com/chigopher/pathlib v0.19.1 
h1:RoLlUJc0CqBGwq239cilyhxPNLXTK+HXoASGyGznx5A=
@@ -21,6 +39,8 @@ github.com/davecgh/go-spew 
v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod 
h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
 github.com/dnephin/pflag v1.0.7/go.mod 
h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f 
h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod 
h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
 github.com/fatih/color v1.18.0/go.mod 
h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
 github.com/frankban/quicktest v1.14.6 
h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -75,6 +95,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod 
h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
 github.com/mattn/go-isatty v0.0.19/go.mod 
h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-isatty v0.0.20 
h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod 
h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 
h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod 
h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.16 
h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 github.com/mattn/go-runewidth v0.0.16/go.mod 
h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db 
h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
@@ -83,6 +105,12 @@ github.com/mitchellh/go-homedir v1.1.0 
h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
 github.com/mitchellh/go-homedir v1.1.0/go.mod 
h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/mapstructure v1.5.0 
h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod 
h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 
h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod 
h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 
h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod 
h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 
h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod 
h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e 
h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod 
h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/pelletier/go-toml/v2 v2.2.4 
h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
@@ -94,6 +122,7 @@ github.com/pmezard/go-difflib 
v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod 
h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 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.2.0/go.mod 
h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 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=
@@ -130,6 +159,8 @@ github.com/subosito/gotenv v1.6.0 
h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
 github.com/subosito/gotenv v1.6.0/go.mod 
h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
 github.com/vektra/mockery/v2 v2.53.2 
h1:4G/4fl9x722Yb8hLqH1YU3XZNRJFwl5KUMvpkmAyuC8=
 github.com/vektra/mockery/v2 v2.53.2/go.mod 
h1:UJT+mgXhCcOCHXTnM5cJHCZL+d76BYB+EbY1sFztEB8=
+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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod 
h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
@@ -155,6 +186,7 @@ golang.org/x/sync v0.18.0/go.mod 
h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 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-20210809222454-d867a43fc93e/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.0.0-20220811171246-fbc7d0a398ab/go.mod 
h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/plc4go/tools/plc4xbrowser/main.go 
b/plc4go/tools/plc4xbrowser/main.go
index 7a1cd1b..d79c011 100644
--- a/plc4go/tools/plc4xbrowser/main.go
+++ b/plc4go/tools/plc4xbrowser/main.go
@@ -20,16 +20,28 @@
 package main
 
 import (
+       "flag"
        "github.com/apache/plc4x-extras/plc4go/tools/plc4xbrowser/ui"
 )
 
 func main() {
+       flag.Parse()
        ui.LoadConfig()
-       application := ui.SetupApplication()
-       ui.InitSubsystem()
 
-       if err := application.Run(); err != nil {
+       if ui.IsLegacyUI() {
+               application := ui.SetupApplication()
+               ui.InitSubsystem()
+               defer ui.Shutdown()
+               if err := application.Run(); err != nil {
+                       panic(err)
+               }
+               return
+       }
+
+       program := ui.SetupProgram()
+       ui.InitSubsystem()
+       defer ui.Shutdown()
+       if _, err := program.Run(); err != nil {
                panic(err)
        }
-       ui.Shutdown()
 }
diff --git a/plc4go/tools/plc4xbrowser/ui/actions.go 
b/plc4go/tools/plc4xbrowser/ui/actions.go
index 1355b46..1bb3443 100644
--- a/plc4go/tools/plc4xbrowser/ui/actions.go
+++ b/plc4go/tools/plc4xbrowser/ui/actions.go
@@ -48,7 +48,11 @@ func InitSubsystem() {
                //With().Caller().Logger().
                Output(zerolog.NewConsoleWriter(
                        func(w *zerolog.ConsoleWriter) {
-                               w.Out = tview.ANSIWriter(consoleOutput)
+                               out := consoleOutput
+                               if IsLegacyUI() {
+                                       out = tview.ANSIWriter(out)
+                               }
+                               w.Out = out
                        },
                        func(w *zerolog.ConsoleWriter) {
                                w.FormatFieldValue = func(i interface{}) string 
{
@@ -76,7 +80,9 @@ func InitSubsystem() {
 
        // We offset the commands executed with the last commands
        commandsExecuted = len(config.History.Last10Commands)
-       outputCommandHistory()
+       if IsLegacyUI() {
+               outputCommandHistory()
+       }
 
        for _, driver := range config.AutoRegisterDrivers {
                log.Info().Str("driver", driver).Msg("Auto register driver")
@@ -89,9 +95,16 @@ func InitSubsystem() {
 }
 
 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))
+       if IsLegacyUI() {
+               _, _ = 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))
+               }
+       } else {
+               _, _ = fmt.Fprintln(commandOutput, "Last 10 commands")
+               for i, command := range config.History.Last10Commands {
+                       _, _ = fmt.Fprintf(commandOutput, "  %d: %s\n", i, 
command)
+               }
        }
 }
 
diff --git a/plc4go/tools/plc4xbrowser/ui/commands.go 
b/plc4go/tools/plc4xbrowser/ui/commands.go
index 5637dc8..db02688 100644
--- a/plc4go/tools/plc4xbrowser/ui/commands.go
+++ b/plc4go/tools/plc4xbrowser/ui/commands.go
@@ -530,7 +530,11 @@ var rootCommand = Command{
                                                        Name:        "on",
                                                        Description: "debug on",
                                                        action: func(ctx 
context.Context, _ Command, _ string) error {
-                                                               plc4xBrowserLog 
= zerolog.New(zerolog.ConsoleWriter{Out: tview.ANSIWriter(consoleOutput)})
+                                                               out := 
consoleOutput
+                                                               if IsLegacyUI() 
{
+                                                                       out = 
tview.ANSIWriter(out)
+                                                               }
+                                                               plc4xBrowserLog 
= zerolog.New(zerolog.ConsoleWriter{Out: out})
                                                                return nil
                                                        },
                                                },
@@ -640,14 +644,22 @@ func init() {
                Name:        "help",
                Description: "prints out this help",
                action: func(_ context.Context, _ Command, _ string) error {
-                       _, _ = fmt.Fprintf(commandOutput, "[#0000ff]Available 
commands[white]\n")
+                       if IsLegacyUI() {
+                               _, _ = fmt.Fprintf(commandOutput, 
"[#0000ff]Available commands[white]\n")
+                       } else {
+                               _, _ = fmt.Fprintln(commandOutput, "Available 
commands")
+                       }
                        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)
+                               if IsLegacyUI() {
+                                       _, _ = fmt.Fprintf(commandOutput, "%s 
[#00ff00]%s[white]: %s\n", indentString, command.Name, description)
+                               } else {
+                                       _, _ = fmt.Fprintf(commandOutput, 
"%s%s: %s\n", indentString, command.Name, description)
+                               }
                        })
                        return nil
                },
diff --git a/plc4go/tools/plc4xbrowser/ui/common.go 
b/plc4go/tools/plc4xbrowser/ui/common.go
index 935219c..6d97e68 100644
--- a/plc4go/tools/plc4xbrowser/ui/common.go
+++ b/plc4go/tools/plc4xbrowser/ui/common.go
@@ -20,6 +20,7 @@
 package ui
 
 import (
+       "flag"
        "io"
        "strings"
        "sync"
@@ -30,10 +31,21 @@ import (
        "github.com/rs/zerolog"
 )
 
+var legacyUI = flag.Bool("legacy-ui", true, "Use the legacy tview-based UI")
+
+func IsLegacyUI() bool {
+       if legacyUI == nil {
+               return false
+       }
+       return *legacyUI
+}
+
 const protocols = "ads,bacnetip,c-bus,opcua,s7"
 
 var protocolList = strings.Split(protocols, ",")
 
+var dispatcher = newDispatcher()
+
 var plc4xBrowserLog = zerolog.Nop()
 
 var driverManager plc4go.PlcDriverManager
diff --git a/plc4go/tools/plc4xbrowser/ui/dispatcher.go 
b/plc4go/tools/plc4xbrowser/ui/dispatcher.go
new file mode 100644
index 0000000..adf4f06
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/ui/dispatcher.go
@@ -0,0 +1,59 @@
+package ui
+
+import (
+       "sync"
+
+       tea "github.com/charmbracelet/bubbletea"
+)
+
+// messageDispatcher buffers UI messages until the Bubble Tea program is ready
+// and forwards them once it is. This keeps the rest of the code decoupled from
+// the program lifecycle and preserves behaviour when subsystems write to the 
UI
+// before the program starts running.
+type messageDispatcher struct {
+       mu      sync.Mutex
+       program *tea.Program
+       buffer  []tea.Msg
+       queue   chan tea.Msg
+}
+
+func newDispatcher() *messageDispatcher {
+       d := &messageDispatcher{
+               queue: make(chan tea.Msg, 128),
+       }
+       go d.run()
+       return d
+}
+
+func (d *messageDispatcher) setProgram(program *tea.Program) {
+       d.mu.Lock()
+       d.program = program
+       buffered := append([]tea.Msg(nil), d.buffer...)
+       d.buffer = nil
+       d.mu.Unlock()
+
+       go func() {
+               for _, msg := range buffered {
+                       d.queue <- msg
+               }
+       }()
+}
+
+func (d *messageDispatcher) send(msg tea.Msg) {
+       d.queue <- msg
+}
+
+func (d *messageDispatcher) run() {
+       for msg := range d.queue {
+               d.mu.Lock()
+               program := d.program
+               if program == nil {
+                       d.buffer = append(d.buffer, msg)
+                       d.mu.Unlock()
+                       continue
+               }
+               d.mu.Unlock()
+
+               program.Send(msg)
+       }
+}
diff --git a/plc4go/tools/plc4xbrowser/ui/log_buffer.go 
b/plc4go/tools/plc4xbrowser/ui/log_buffer.go
new file mode 100644
index 0000000..72c0f48
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/ui/log_buffer.go
@@ -0,0 +1,65 @@
+package ui
+
+// logBuffer keeps a rolling window of lines while handling fragmented writes.
+// It is used for the command, console and message outputs.
+type logBuffer struct {
+       lines []string
+       max   int
+}
+
+func newLogBuffer(max int) logBuffer {
+       return logBuffer{lines: []string{""}, max: max}
+}
+
+func (b *logBuffer) append(text string) {
+       if len(b.lines) == 0 {
+               b.lines = []string{""}
+       }
+       remaining := text
+       for len(remaining) > 0 {
+               newline := -1
+               for i := 0; i < len(remaining); i++ {
+                       if remaining[i] == '\n' {
+                               newline = i
+                               break
+                       }
+               }
+               if newline == -1 {
+                       b.lines[len(b.lines)-1] += remaining
+                       break
+               }
+               b.lines[len(b.lines)-1] += remaining[:newline]
+               b.lines = append(b.lines, "")
+               remaining = remaining[newline+1:]
+       }
+       b.trim()
+}
+
+func (b *logBuffer) clear() {
+       b.lines = []string{""}
+}
+
+func (b *logBuffer) trim() {
+       if b.max <= 0 {
+               return
+       }
+       if len(b.lines) <= b.max {
+               return
+       }
+       offset := len(b.lines) - b.max
+       b.lines = append([]string(nil), b.lines[offset:]...)
+       if len(b.lines) == 0 {
+               b.lines = []string{""}
+       }
+}
+
+func (b logBuffer) String() string {
+       result := ""
+       for i, line := range b.lines {
+               if i > 0 {
+                       result += "\n"
+               }
+               result += line
+       }
+       return result
+}
diff --git a/plc4go/tools/plc4xbrowser/ui/output.go 
b/plc4go/tools/plc4xbrowser/ui/output.go
new file mode 100644
index 0000000..9663827
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/ui/output.go
@@ -0,0 +1,39 @@
+package ui
+
+import "io"
+
+type outputTarget int
+
+const (
+       targetCommand outputTarget = iota
+       targetConsole
+       targetMessage
+)
+
+type appendOutputMsg struct {
+       target outputTarget
+       text   string
+}
+
+type clearOutputMsg struct {
+       target outputTarget
+}
+
+type programWriter struct {
+       target outputTarget
+}
+
+func (w *programWriter) Write(p []byte) (int, error) {
+       dispatcher.send(appendOutputMsg{target: w.target, text: string(p)})
+       return len(p), nil
+}
+
+func newWriter(target outputTarget) io.Writer {
+       return &programWriter{target: target}
+}
+
+func makeClearFunc(target outputTarget) func() {
+       return func() {
+               dispatcher.send(clearOutputMsg{target: target})
+       }
+}
diff --git a/plc4go/tools/plc4xbrowser/ui/program.go 
b/plc4go/tools/plc4xbrowser/ui/program.go
new file mode 100644
index 0000000..8e456fa
--- /dev/null
+++ b/plc4go/tools/plc4xbrowser/ui/program.go
@@ -0,0 +1,862 @@
+/*
+ * 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"
+       "sort"
+       "strconv"
+       "strings"
+       "time"
+
+       plc4go "github.com/apache/plc4x/plc4go/pkg/api"
+       apiModel "github.com/apache/plc4x/plc4go/pkg/api/model"
+       "github.com/charmbracelet/bubbles/textinput"
+       "github.com/charmbracelet/bubbles/viewport"
+       tea "github.com/charmbracelet/bubbletea"
+       "github.com/charmbracelet/lipgloss"
+)
+
+type driverEntry struct {
+       Code        string
+       Description string
+}
+
+type messageEntry struct {
+       Number    int
+       Timestamp time.Time
+}
+
+type connectionsUpdatedMsg struct {
+       Connections []string
+}
+
+type driversUpdatedMsg struct {
+       Drivers []driverEntry
+}
+
+type messageArrivedMsg struct {
+       Entry messageEntry
+}
+
+type commandExecResultMsg struct {
+       Command string
+       Err     error
+}
+
+var commandHistoryShortcut = regexp.MustCompile("^[0-9]$")
+
+var (
+       textColor        = lipgloss.Color("15")
+       accentColor      = lipgloss.Color("12")
+       highlightColor   = lipgloss.Color("10")
+       placeholderColor = lipgloss.Color("242")
+
+       titleStyle        = lipgloss.NewStyle().Bold(true).Foreground(textColor)
+       subtitleStyle     = 
lipgloss.NewStyle().Foreground(textColor).Align(lipgloss.Center)
+       sectionTitleStyle = 
lipgloss.NewStyle().Bold(true).Foreground(accentColor)
+       sectionBodyStyle  = lipgloss.NewStyle().MarginLeft(2)
+       suggestionStyle   = 
lipgloss.NewStyle().MarginLeft(2).Foreground(highlightColor)
+       columnTitleStyle  = 
lipgloss.NewStyle().Bold(true).Align(lipgloss.Center).Foreground(textColor)
+       panelStyle        = 
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(textColor).Padding(0,
 1)
+       placeholderStyle  = 
lipgloss.NewStyle().MarginLeft(2).Foreground(placeholderColor).Italic(true)
+       historyIndexStyle = 
lipgloss.NewStyle().Foreground(highlightColor).Bold(true).MarginLeft(2).Width(2).Align(lipgloss.Right)
+       historyGapStyle   = lipgloss.NewStyle().Foreground(textColor)
+       historyTextStyle  = lipgloss.NewStyle().Foreground(textColor)
+)
+
+type model struct {
+       width  int
+       height int
+
+       commandLog logBuffer
+       consoleLog logBuffer
+       messageLog logBuffer
+
+       connections []string
+       drivers     []driverEntry
+       messages    []messageEntry
+
+       commandInput textinput.Model
+       history      []string
+       historyIndex int
+
+       suggestions     []string
+       suggestionIndex int
+
+       commandViewport viewport.Model
+       payloadViewport viewport.Model
+       consoleViewport viewport.Model
+}
+
+func newModel() *model {
+       input := textinput.New()
+       input.Prompt = "$ "
+       input.Placeholder = ""
+       input.CharLimit = 0
+       input.Focus()
+
+       history := append([]string(nil), config.History.Last10Commands...)
+
+       vp := viewport.New(0, 0)
+       vp.Style = lipgloss.NewStyle()
+
+       payload := viewport.New(0, 0)
+       payload.Style = lipgloss.NewStyle()
+
+       console := viewport.New(0, 0)
+       console.Style = lipgloss.NewStyle()
+
+       mdl := &model{
+               commandLog:      newLogBuffer(config.MaxOutputLines),
+               consoleLog:      newLogBuffer(config.MaxConsoleLines),
+               messageLog:      newLogBuffer(config.MaxOutputLines),
+               connections:     snapshotConnections(),
+               drivers:         snapshotDrivers(),
+               commandInput:    input,
+               history:         history,
+               historyIndex:    len(history),
+               suggestions:     nil,
+               suggestionIndex: -1,
+               commandViewport: vp,
+               payloadViewport: payload,
+               consoleViewport: console,
+       }
+
+       mdl.updateCommandViewportContent()
+       mdl.updatePayloadViewportContent()
+       mdl.updateConsoleViewportContent()
+
+       return mdl
+}
+
+func SetupProgram() *tea.Program {
+       mdl := newModel()
+       program := tea.NewProgram(mdl, tea.WithAltScreen(), 
tea.WithMouseCellMotion())
+       dispatcher.setProgram(program)
+
+       commandOutput = newWriter(targetCommand)
+       commandOutputClear = makeClearFunc(targetCommand)
+       consoleOutput = newWriter(targetConsole)
+       consoleOutputClear = makeClearFunc(targetConsole)
+       messageOutput = newWriter(targetMessage)
+       messageOutputClear = makeClearFunc(targetMessage)
+
+       driverAdded = func(driver plc4go.PlcDriver) {
+               dispatcher.send(driversUpdatedMsg{Drivers: snapshotDrivers()})
+       }
+       connectionsChanged = func() {
+               dispatcher.send(connectionsUpdatedMsg{Connections: 
snapshotConnections()})
+       }
+       messageReceived = func(messageNumber int, receiveTime time.Time, 
message apiModel.PlcMessage) {
+               dispatcher.send(messageArrivedMsg{Entry: messageEntry{Number: 
messageNumber, Timestamp: receiveTime}})
+               fmt.Fprintf(messageOutput, "Message #%d (%s)\n%v\n", 
messageNumber, receiveTime.Format("15:04:05.999999"), message)
+       }
+
+       // Ensure the current state is reflected before the program starts.
+       dispatcher.send(connectionsUpdatedMsg{Connections: 
snapshotConnections()})
+       dispatcher.send(driversUpdatedMsg{Drivers: snapshotDrivers()})
+
+       return program
+}
+
+func (m *model) Init() tea.Cmd {
+       return nil
+}
+
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+       switch msg := msg.(type) {
+       case tea.WindowSizeMsg:
+               m.width = msg.Width
+               m.height = msg.Height
+               m.updateLayout()
+       case tea.KeyMsg:
+               return m.handleKey(msg)
+       case tea.MouseMsg:
+               var cmds []tea.Cmd
+               var cmd tea.Cmd
+               m.commandViewport, cmd = m.commandViewport.Update(msg)
+               if cmd != nil {
+                       cmds = append(cmds, cmd)
+               }
+               m.payloadViewport, cmd = m.payloadViewport.Update(msg)
+               if cmd != nil {
+                       cmds = append(cmds, cmd)
+               }
+               m.consoleViewport, cmd = m.consoleViewport.Update(msg)
+               if cmd != nil {
+                       cmds = append(cmds, cmd)
+               }
+               return m, tea.Batch(cmds...)
+       case appendOutputMsg:
+               m.handleAppendOutput(msg)
+       case clearOutputMsg:
+               m.handleClearOutput(msg)
+       case connectionsUpdatedMsg:
+               m.connections = append([]string(nil), msg.Connections...)
+       case driversUpdatedMsg:
+               m.drivers = append([]driverEntry(nil), msg.Drivers...)
+       case messageArrivedMsg:
+               m.messages = append(m.messages, msg.Entry)
+               if len(m.messages) > 200 {
+                       m.messages = append([]messageEntry(nil), 
m.messages[len(m.messages)-200:]...)
+               }
+               m.updatePayloadViewportContent()
+       case commandExecResultMsg:
+               if msg.Err != nil {
+                       fmt.Fprintf(commandOutput, "%s %v\n", 
time.Now().Format("15:04"), msg.Err)
+                       return m, nil
+               }
+               m.commandInput.SetValue("")
+               m.commandInput.CursorEnd()
+               m.history = append(m.history, msg.Command)
+               if len(m.history) > 100 {
+                       m.history = append([]string(nil), 
m.history[len(m.history)-100:]...)
+               }
+               m.historyIndex = len(m.history)
+               m.updateLayout()
+       }
+       return m, nil
+}
+
+func (m *model) View() string {
+       width := m.width
+       if width <= 0 {
+               width = 120
+       }
+       header := 
titleStyle.Align(lipgloss.Center).Width(width).PaddingBottom(1).Render("PLC4X 
Browser")
+       footer := 
subtitleStyle.Width(width).PaddingTop(1).Render("https://github.com/apache/plc4x";)
+       availableHeight := m.height - lipgloss.Height(header) - 
lipgloss.Height(footer)
+       if availableHeight < 8 {
+               availableHeight = m.height / 2
+               if availableHeight < 8 {
+                       availableHeight = 8
+               }
+       }
+
+       const gapWidth = 0
+       leftWidth, centerWidth, rightWidth := computeColumnWidths(width, 
gapWidth)
+
+       leftPanel := renderPanel("", []panelSection{
+               {title: "Connections", body: renderList(m.connections)},
+               {title: "Registered drivers", body: renderDrivers(m.drivers)},
+       }, max(leftWidth-2, 0), availableHeight)
+
+       centerPanel := m.renderOutputPanel(max(centerWidth-2, 0), 
availableHeight)
+
+       rightPanel := m.renderCommandPanel(max(rightWidth-2, 0), 
availableHeight)
+
+       gap := lipgloss.NewStyle().Width(gapWidth).Render("")
+       columns := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, gap, 
centerPanel, gap, rightPanel)
+       columns = lipgloss.Place(width, availableHeight, lipgloss.Center, 
lipgloss.Top, columns)
+       content := lipgloss.JoinVertical(lipgloss.Left, header, columns, footer)
+       if m.height > 0 {
+               content = lipgloss.Place(width, m.height, lipgloss.Center, 
lipgloss.Top, content)
+       }
+       return content
+}
+
+func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+       switch msg.Type {
+       case tea.KeyCtrlC, tea.KeyCtrlD:
+               return m, tea.Quit
+       case tea.KeyEnter:
+               return m.submitCommand()
+       case tea.KeyTab:
+               return m.completeCommand(false)
+       case tea.KeyShiftTab:
+               return m.completeCommand(true)
+       case tea.KeyPgUp:
+               m.commandViewport.PageUp()
+               return m, nil
+       case tea.KeyPgDown:
+               m.commandViewport.PageDown()
+               return m, nil
+       case tea.KeyHome:
+               m.commandViewport.GotoTop()
+               return m, nil
+       case tea.KeyEnd:
+               m.commandViewport.GotoBottom()
+               return m, nil
+       case tea.KeyUp:
+               if msg.Alt {
+                       m.commandViewport.ScrollUp(1)
+                       return m, nil
+               }
+               m.usePreviousHistory()
+               return m, nil
+       case tea.KeyDown:
+               if msg.Alt {
+                       m.commandViewport.ScrollDown(1)
+                       return m, nil
+               }
+               m.useNextHistory()
+               return m, nil
+       default:
+               oldValue := m.commandInput.Value()
+               var cmd tea.Cmd
+               m.commandInput, cmd = m.commandInput.Update(msg)
+               if m.commandInput.Value() != oldValue {
+                       m.suggestions = nil
+                       m.suggestionIndex = -1
+                       m.updateLayout()
+               }
+               return m, cmd
+       }
+}
+
+func (m *model) submitCommand() (tea.Model, tea.Cmd) {
+       commandText := strings.TrimSpace(m.commandInput.Value())
+       if commandText == "" {
+               return m, nil
+       }
+       if commandText == "quit" {
+               return m, tea.Quit
+       }
+       commandsExecuted++
+       resolved, err := resolveHistoryShortcut(commandText)
+       if err != nil {
+               fmt.Fprintf(commandOutput, "%s %v\n", 
time.Now().Format("15:04"), err)
+               return m, nil
+       }
+       fmt.Fprintf(commandOutput, "%s %s\n", time.Now().Format("15:04"), 
resolved)
+       m.suggestions = nil
+       m.suggestionIndex = -1
+       m.updateLayout()
+       return m, executeCommand(resolved)
+}
+
+func (m *model) completeCommand(reverse bool) (tea.Model, tea.Cmd) {
+       current := m.commandInput.Value()
+       if len(m.suggestions) == 0 {
+               m.suggestions = rootCommand.Completions(current)
+               m.suggestionIndex = -1
+               if len(m.suggestions) == 0 {
+                       return m, nil
+               }
+       }
+       if reverse {
+               m.suggestionIndex--
+               if m.suggestionIndex < 0 {
+                       m.suggestionIndex = len(m.suggestions) - 1
+               }
+       } else {
+               m.suggestionIndex = (m.suggestionIndex + 1) % len(m.suggestions)
+       }
+       completion := m.suggestions[m.suggestionIndex]
+       m.commandInput.SetValue(completion)
+       m.commandInput.CursorEnd()
+       m.updateLayout()
+       return m, nil
+}
+
+func (m *model) usePreviousHistory() {
+       if len(m.history) == 0 {
+               return
+       }
+       if m.historyIndex == 0 {
+               m.commandInput.SetValue(m.history[0])
+               m.commandInput.CursorEnd()
+               return
+       }
+       m.historyIndex--
+       m.commandInput.SetValue(m.history[m.historyIndex])
+       m.commandInput.CursorEnd()
+}
+
+func (m *model) useNextHistory() {
+       if len(m.history) == 0 {
+               return
+       }
+       if m.historyIndex >= len(m.history)-1 {
+               m.historyIndex = len(m.history)
+               m.commandInput.SetValue("")
+               m.commandInput.CursorEnd()
+               return
+       }
+       m.historyIndex++
+       m.commandInput.SetValue(m.history[m.historyIndex])
+       m.commandInput.CursorEnd()
+}
+
+func (m *model) handleAppendOutput(msg appendOutputMsg) {
+       switch msg.target {
+       case targetCommand:
+               m.commandLog.append(msg.text)
+               m.updateCommandViewportContent()
+       case targetConsole:
+               m.consoleLog.append(msg.text)
+               m.updateConsoleViewportContent()
+       case targetMessage:
+               m.messageLog.append(msg.text)
+               m.updatePayloadViewportContent()
+       }
+}
+
+func (m *model) handleClearOutput(msg clearOutputMsg) {
+       switch msg.target {
+       case targetCommand:
+               m.commandLog.clear()
+               m.updateCommandViewportContent()
+       case targetConsole:
+               m.consoleLog.clear()
+               m.updateConsoleViewportContent()
+       case targetMessage:
+               m.messageLog.clear()
+               m.updatePayloadViewportContent()
+       }
+}
+
+func (m *model) renderCommandPanel(width, height int) string {
+       panel := panelStyle
+       if width > 0 {
+               panel = panel.Width(width)
+       }
+       if height > 0 {
+               panel = panel.Height(height)
+       }
+
+       innerWidth := width
+       if width > 0 {
+               frameWidth, _ := panelStyle.GetFrameSize()
+               innerWidth = max(width-frameWidth, 0)
+       }
+
+       parts := []string{
+               columnTitleStyle.Width(innerWidth).Render("Commands"),
+               m.commandViewport.View(),
+               "",
+               sectionTitleStyle.Render("Command Input"),
+               sectionBodyStyle.Render(m.commandInput.View()),
+       }
+       if len(m.suggestions) > 0 {
+               suggestions := 
suggestionStyle.Render(strings.Join(m.suggestions, "    "))
+               parts = append(parts, "", 
sectionTitleStyle.Render("Suggestions"), sectionBodyStyle.Render(suggestions))
+       }
+
+       body := lipgloss.JoinVertical(lipgloss.Left, parts...)
+       return panel.Render(body)
+}
+
+func (m *model) renderOutputPanel(width, height int) string {
+       panel := panelStyle
+       if width > 0 {
+               panel = panel.Width(width)
+       }
+       if height > 0 {
+               panel = panel.Height(height)
+       }
+
+       innerWidth := width
+       if width > 0 {
+               frameWidth, _ := panelStyle.GetFrameSize()
+               innerWidth = max(width-frameWidth, 0)
+       }
+
+       parts := []string{
+               columnTitleStyle.Width(innerWidth).Render("Output"),
+               m.payloadViewport.View(),
+               "",
+               sectionTitleStyle.Render("Console"),
+               m.consoleViewport.View(),
+       }
+
+       body := lipgloss.JoinVertical(lipgloss.Left, parts...)
+       return panel.Render(body)
+}
+
+func (m *model) updateLayout() {
+       if m.width == 0 && m.height == 0 {
+               return
+       }
+
+       width := m.width
+       if width <= 0 {
+               width = 120
+       }
+       header := titleStyle.Align(lipgloss.Center).Width(width).Render("PLC4X 
Browser")
+       footer := 
subtitleStyle.Width(width).Render("https://github.com/apache/plc4x";)
+       availableHeight := m.height - lipgloss.Height(header) - 
lipgloss.Height(footer)
+       if availableHeight < 8 {
+               availableHeight = m.height / 2
+               if availableHeight < 8 {
+                       availableHeight = 8
+               }
+       }
+
+       const gapWidth = 0
+       _, centerWidth, rightWidth := computeColumnWidths(width, gapWidth)
+       panelFrameWidth, panelFrameHeight := panelStyle.GetFrameSize()
+       bodyFrameWidth, _ := sectionBodyStyle.GetFrameSize()
+
+       commandPanelWidth := max(rightWidth-2, 0)
+       commandInnerWidth := commandPanelWidth
+       if commandPanelWidth > 0 {
+               commandInnerWidth = max(commandPanelWidth-panelFrameWidth, 0)
+       }
+       commandInnerHeight := availableHeight
+       if availableHeight > 0 {
+               commandInnerHeight = max(availableHeight-panelFrameHeight, 0)
+       }
+
+       suggestionsBlock := ""
+       if len(m.suggestions) > 0 {
+               suggestionsBlock = 
suggestionStyle.Render(strings.Join(m.suggestions, "    "))
+       }
+
+       cmdHeader := 
columnTitleStyle.Width(commandInnerWidth).Render("Commands")
+       inputTitle := sectionTitleStyle.Render("Command Input")
+       inputBody := sectionBodyStyle.Render(m.commandInput.View())
+
+       commandStaticHeight := lipgloss.Height(cmdHeader) + 
lipgloss.Height(inputTitle) + lipgloss.Height(inputBody) + 1
+       if suggestionsBlock != "" {
+               suggestionsTitle := sectionTitleStyle.Render("Suggestions")
+               suggestionsBody := sectionBodyStyle.Render(suggestionsBlock)
+               commandStaticHeight += 1 + lipgloss.Height(suggestionsTitle) + 
lipgloss.Height(suggestionsBody)
+       }
+
+       commandViewportHeight := commandInnerHeight - commandStaticHeight
+       if commandViewportHeight < 0 {
+               commandViewportHeight = 0
+       }
+
+       commandViewportWidth := commandInnerWidth - bodyFrameWidth
+       if commandViewportWidth < 0 {
+               commandViewportWidth = 0
+       }
+
+       m.commandViewport.Width = commandViewportWidth
+       m.commandViewport.Height = commandViewportHeight
+       m.updateCommandViewportContent()
+
+       outputPanelWidth := max(centerWidth-2, 0)
+       outputInnerWidth := outputPanelWidth
+       if outputPanelWidth > 0 {
+               outputInnerWidth = max(outputPanelWidth-panelFrameWidth, 0)
+       }
+       outputInnerHeight := availableHeight
+       if availableHeight > 0 {
+               outputInnerHeight = max(availableHeight-panelFrameHeight, 0)
+       }
+
+       outputHeader := 
columnTitleStyle.Width(outputInnerWidth).Render("Output")
+       consoleTitle := sectionTitleStyle.Render("Console")
+       fixedHeight := lipgloss.Height(outputHeader) + 1 + 
lipgloss.Height(consoleTitle)
+
+       const (
+               minPayloadHeight       = 6
+               minConsoleHeight       = 4
+               preferredConsoleHeight = 10
+       )
+
+       availableForViewports := outputInnerHeight - fixedHeight
+       if availableForViewports < 0 {
+               availableForViewports = 0
+       }
+
+       var payloadViewportHeight, consoleViewportHeight int
+       if availableForViewports == 0 {
+               payloadViewportHeight, consoleViewportHeight = 0, 0
+       } else {
+               payloadViewportHeight = minPayloadHeight
+               consoleViewportHeight = minConsoleHeight
+               if payloadViewportHeight+consoleViewportHeight > 
availableForViewports {
+                       payloadViewportHeight = min(availableForViewports, 
minPayloadHeight)
+                       if payloadViewportHeight < 0 {
+                               payloadViewportHeight = 0
+                       }
+                       consoleViewportHeight = availableForViewports - 
payloadViewportHeight
+                       if consoleViewportHeight < 0 {
+                               consoleViewportHeight = 0
+                       }
+               } else {
+                       remaining := availableForViewports - 
(payloadViewportHeight + consoleViewportHeight)
+                       extraConsoleCapacity := preferredConsoleHeight - 
minConsoleHeight
+                       if extraConsoleCapacity > 0 {
+                               extra := min(extraConsoleCapacity, remaining)
+                               consoleViewportHeight += extra
+                               remaining -= extra
+                       }
+                       payloadViewportHeight += remaining
+               }
+       }
+
+       payloadViewportWidth := outputInnerWidth - bodyFrameWidth
+       if payloadViewportWidth < 0 {
+               payloadViewportWidth = 0
+       }
+       consoleViewportWidth := outputInnerWidth - bodyFrameWidth
+       if consoleViewportWidth < 0 {
+               consoleViewportWidth = 0
+       }
+
+       m.payloadViewport.Width = payloadViewportWidth
+       m.payloadViewport.Height = payloadViewportHeight
+       m.consoleViewport.Width = consoleViewportWidth
+       m.consoleViewport.Height = consoleViewportHeight
+
+       m.updatePayloadViewportContent()
+       m.updateConsoleViewportContent()
+}
+
+func (m *model) updateCommandViewportContent() {
+       logBody := strings.TrimSuffix(m.commandLog.String(), "\n")
+       logSection := lipgloss.JoinVertical(lipgloss.Left,
+               sectionTitleStyle.Render("Command Log"),
+               renderBodyBlock(logBody),
+       )
+
+       historySection := lipgloss.JoinVertical(lipgloss.Left,
+               sectionTitleStyle.Render("Last 10 commands"),
+               renderCommandHistoryView(m.history),
+       )
+
+       content := lipgloss.JoinVertical(lipgloss.Left, logSection, "", 
historySection)
+       atBottom := m.commandViewport.AtBottom()
+       m.commandViewport.SetContent(content)
+       if atBottom {
+               m.commandViewport.GotoBottom()
+       }
+}
+
+func (m *model) updatePayloadViewportContent() {
+       segments := make([]string, 0, 2)
+       messageBody := strings.TrimSuffix(m.messageLog.String(), "\n")
+       if strings.TrimSpace(messageBody) != "" {
+               segment := lipgloss.JoinVertical(lipgloss.Left,
+                       sectionTitleStyle.Render("Message Log"),
+                       renderBodyBlock(messageBody),
+               )
+               segments = append(segments, segment)
+       }
+       if len(m.messages) > 0 {
+               recent := lipgloss.JoinVertical(lipgloss.Left,
+                       sectionTitleStyle.Render("Recent Messages"),
+                       renderBodyBlock(renderMessages(m.messages)),
+               )
+               segments = append(segments, recent)
+       }
+       if len(segments) == 0 {
+               segments = append(segments, placeholderStyle.Render("(none)"))
+       }
+       content := strings.Join(segments, "\n\n")
+       atBottom := m.payloadViewport.AtBottom()
+       m.payloadViewport.SetContent(content)
+       if atBottom {
+               m.payloadViewport.GotoBottom()
+       }
+}
+
+func (m *model) updateConsoleViewportContent() {
+       consoleBody := strings.TrimSuffix(m.consoleLog.String(), "\n")
+       content := renderBodyBlock(consoleBody)
+       atBottom := m.consoleViewport.AtBottom()
+       m.consoleViewport.SetContent(content)
+       if atBottom {
+               m.consoleViewport.GotoBottom()
+       }
+}
+
+func resolveHistoryShortcut(commandText string) (string, error) {
+       if !commandHistoryShortcut.MatchString(commandText) {
+               return commandText, nil
+       }
+       index, _ := strconv.Atoi(commandHistoryShortcut.FindString(commandText))
+       if index >= len(config.History.Last10Commands) {
+               return "", fmt.Errorf("no such element %d in command history", 
index)
+       }
+       return config.History.Last10Commands[index], nil
+}
+
+func executeCommand(command string) tea.Cmd {
+       return func() tea.Msg {
+               err := Execute(command)
+               return commandExecResultMsg{Command: command, Err: err}
+       }
+}
+
+func renderList(entries []string) string {
+       if len(entries) == 0 {
+               return "(none)"
+       }
+       return strings.Join(entries, "\n")
+}
+
+func renderDrivers(entries []driverEntry) string {
+       if len(entries) == 0 {
+               return "(none)"
+       }
+       rows := make([]string, 0, len(entries))
+       for _, entry := range entries {
+               rows = append(rows, fmt.Sprintf("%s — %s", entry.Code, 
entry.Description))
+       }
+       return strings.Join(rows, "\n")
+}
+
+func renderMessages(entries []messageEntry) string {
+       if len(entries) == 0 {
+               return "(none)"
+       }
+       const maxLines = 10
+       start := 0
+       if len(entries) > maxLines {
+               start = len(entries) - maxLines
+       }
+       rows := make([]string, 0, len(entries)-start)
+       for _, entry := range entries[start:] {
+               rows = append(rows, fmt.Sprintf("#%d @ %s", entry.Number, 
entry.Timestamp.Format("15:04:05")))
+       }
+       return strings.Join(rows, "\n")
+}
+
+func renderCommandHistoryLines(history []string) []string {
+       start := len(history) - 10
+       if start < 0 {
+               start = 0
+       }
+       rows := make([]string, 0, len(history)-start)
+       for idx := start; idx < len(history); idx++ {
+               index := historyIndexStyle.Render(fmt.Sprintf("%d", idx))
+               divider := historyGapStyle.Render(": ")
+               entry := historyTextStyle.Render(history[idx])
+               rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, 
index, divider, entry))
+       }
+       return rows
+}
+
+func renderCommandHistoryView(history []string) string {
+       if len(history) == 0 {
+               return placeholderStyle.Render("(none)")
+       }
+       rows := renderCommandHistoryLines(history)
+       return lipgloss.JoinVertical(lipgloss.Left, rows...)
+}
+
+func renderBodyBlock(text string) string {
+       if strings.TrimSpace(text) == "" {
+               return placeholderStyle.Render("(none)")
+       }
+       lines := strings.Split(text, "\n")
+       styled := make([]string, len(lines))
+       for i, line := range lines {
+               styled[i] = sectionBodyStyle.Render(line)
+       }
+       return lipgloss.JoinVertical(lipgloss.Left, styled...)
+}
+
+type panelSection struct {
+       title string
+       body  string
+}
+
+func renderPanel(title string, sections []panelSection, width, height int) 
string {
+       var parts []string
+       if strings.TrimSpace(title) != "" {
+               parts = append(parts, 
columnTitleStyle.Width(width).Render(title))
+       }
+       for i, section := range sections {
+               if strings.TrimSpace(section.title) != "" {
+                       parts = append(parts, 
sectionTitleStyle.Render(section.title))
+               }
+               body := section.body
+               if strings.TrimSpace(body) == "" {
+                       body = placeholderStyle.Render("(none)")
+               } else if !containsANSI(body) {
+                       body = sectionBodyStyle.Render(body)
+               }
+               parts = append(parts, body)
+               if i != len(sections)-1 {
+                       parts = append(parts, "")
+               }
+       }
+       if len(sections) == 0 {
+               parts = append(parts, placeholderStyle.Render("(none)"))
+       }
+       inner := strings.Join(parts, "\n")
+       style := panelStyle
+       if width > 0 {
+               style = style.Width(width)
+       }
+       if height > 0 {
+               style = style.Height(height)
+       }
+       return style.Render(inner)
+}
+
+func containsANSI(body string) bool {
+       return strings.Contains(body, "\x1b[")
+}
+
+func computeColumnWidths(total, gap int) (int, int, int) {
+       if total <= 0 {
+               total = 120
+       }
+       gapsWidth := gap * 2
+       usable := total - gapsWidth
+       if usable < 60 {
+               usable = 60
+       }
+       leftMin, centerMin, rightMin := 24, 32, 24
+       left := max(leftMin, usable/4)
+       right := max(rightMin, usable/4)
+       center := usable - left - right
+       if center < centerMin {
+               deficit := centerMin - center
+               if spare := left - leftMin; spare > 0 {
+                       shift := min(deficit, spare)
+                       left -= shift
+                       deficit -= shift
+               }
+               if deficit > 0 {
+                       if spare := right - rightMin; spare > 0 {
+                               shift := min(deficit, spare)
+                               right -= shift
+                               deficit -= shift
+                       }
+               }
+               center = usable - left - right
+       }
+       return left, center, right
+}
+
+func snapshotConnections() []string {
+       result := make([]string, 0, len(connections))
+       for connection := range connections {
+               result = append(result, connection)
+       }
+       sort.Strings(result)
+       return result
+}
+
+func snapshotDrivers() []driverEntry {
+       result := make([]driverEntry, 0, len(registeredDrivers))
+       for _, driver := range registeredDrivers {
+               result = append(result, driverEntry{
+                       Code:        driver.GetProtocolCode(),
+                       Description: driver.String(),
+               })
+       }
+       sort.Slice(result, func(i, j int) bool {
+               return result[i].Code < result[j].Code
+       })
+       return result
+}


Reply via email to