11package tui
22
33import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
47 "fmt"
58 "math"
9+ "net"
610 "sort"
711 "strings"
812 "time"
@@ -16,6 +20,7 @@ import (
1620 tea "github.com/charmbracelet/bubbletea"
1721 "github.com/charmbracelet/lipgloss"
1822 "github.com/monster0506/meshexec/internal"
23+ "github.com/monster0506/meshexec/internal/discovery"
1924 "github.com/monster0506/meshexec/internal/logging"
2025)
2126
@@ -82,12 +87,155 @@ type model struct {
8287 input textinput.Model
8388 suggList list.Model
8489 cmdHistory []string
90+ targetExpr string
8591
8692 // Toasts
8793 lastToast string
8894 lastToastAt time.Time
8995}
9096
97+ // command execution
98+ type execDoneMsg struct { Results * internal.ExecutionResults }
99+
100+ func (m model ) executeCommand (command string ) tea.Cmd {
101+ m .toast ("Executing: " + command )
102+ // Discover peers then send, off the UI thread
103+ return func () tea.Msg {
104+ ctx , cancel := context .WithTimeout (context .Background (), 5 * time .Second )
105+ defer cancel ()
106+ discovery .SetLogger (m .logger )
107+ peers , _ := discovery .Discover (ctx , 4500 * time .Millisecond )
108+ // Apply simple target filter from m.targetExpr (supports name/role/os/arch=val words)
109+ if q := strings .TrimSpace (strings .ToLower (m .targetExpr )); q != "" && q != "all" {
110+ want := map [string ]string {}
111+ parts := strings .FieldsFunc (q , func (r rune ) bool { return r == '&' || r == '|' || r == ' ' })
112+ for _ , pr := range parts {
113+ if eq := strings .IndexByte (pr , '=' ); eq > 0 {
114+ k := strings .ToLower (strings .TrimSpace (pr [:eq ]))
115+ v := strings .Trim (strings .TrimSpace (pr [eq + 1 :]), "\" " )
116+ want [k ] = v
117+ }
118+ }
119+ filtered := make ([]internal.PeerInfo , 0 , len (peers ))
120+ for _ , peer := range peers {
121+ ok := true
122+ for k , v := range want {
123+ switch k {
124+ case "name" :
125+ ok = ok && strings .EqualFold (peer .Name , v )
126+ case "role" :
127+ ok = ok && strings .EqualFold (peer .Role , v )
128+ case "os" :
129+ ok = ok && strings .EqualFold (peer .OS , v )
130+ case "arch" :
131+ ok = ok && strings .EqualFold (peer .Arch , v )
132+ default :
133+ if tv , exists := peer .Tags [k ]; exists {
134+ ok = ok && strings .EqualFold (tv , v )
135+ } else {
136+ ok = false
137+ }
138+ }
139+ if ! ok {
140+ break
141+ }
142+ }
143+ if ok {
144+ filtered = append (filtered , peer )
145+ }
146+ }
147+ peers = filtered
148+ }
149+ if len (peers ) == 0 {
150+ res := & internal.ExecutionResults {
151+ CommandID : "local-" + time .Now ().Format ("150405" ),
152+ Command : command ,
153+ Target : "none" ,
154+ Results : []internal.ExecutionResult {},
155+ Summary : internal.ResultSummary {TotalDevices : 0 },
156+ Timestamp : time .Now (),
157+ }
158+ return execDoneMsg {Results : res }
159+ }
160+ rows := make ([]internal.ExecutionResult , 0 , len (peers ))
161+ successes , failures , timeouts := 0 , 0 , 0
162+ var totalDur int64
163+ for _ , p := range peers {
164+ addr := p .Address
165+ if ! strings .Contains (addr , ":" ) {
166+ addr = addr + ":9876"
167+ }
168+ start := time .Now ()
169+ r , err := tuiSendCommandTCP (addr , command , 30 * time .Second )
170+ durMs := int64 (time .Since (start ) / time .Millisecond )
171+ if err != nil {
172+ rows = append (rows , internal.ExecutionResult {Device : p .Name , ExitCode : 1 , Stderr : err .Error (), Status : "failed" , Duration : durMs })
173+ failures ++
174+ totalDur += durMs
175+ continue
176+ }
177+ if r .Status == "timeout" {
178+ timeouts ++
179+ } else if r .ExitCode == 0 {
180+ successes ++
181+ } else {
182+ failures ++
183+ }
184+ r .Device = p .Name
185+ if r .Duration <= 0 {
186+ r .Duration = durMs
187+ }
188+ rows = append (rows , * r )
189+ totalDur += r .Duration
190+ }
191+ avg := int64 (0 )
192+ if len (rows ) > 0 {
193+ avg = totalDur / int64 (len (rows ))
194+ }
195+ res := & internal.ExecutionResults {
196+ CommandID : "local-" + time .Now ().Format ("150405" ),
197+ Command : command ,
198+ Target : "tui" ,
199+ Results : rows ,
200+ Summary : internal.ResultSummary {TotalDevices : len (rows ), Successful : successes , Failed : failures , Timeout : timeouts , AverageDuration : avg },
201+ Timestamp : time .Now (),
202+ }
203+ return execDoneMsg {Results : res }
204+ }
205+ }
206+
207+ func tuiSendCommandTCP (addr , command string , timeout time.Duration ) (* internal.ExecutionResult , error ) {
208+ if timeout <= 0 {
209+ timeout = 5 * time .Second
210+ }
211+ conn , err := net .DialTimeout ("tcp" , addr , 3 * time .Second )
212+ if err != nil {
213+ return nil , err
214+ }
215+ defer func () { _ = conn .Close () }()
216+ _ = conn .SetDeadline (time .Now ().Add (timeout ))
217+ enc := json .NewEncoder (conn )
218+ if err := enc .Encode (map [string ]string {"cmd" : command }); err != nil {
219+ return nil , err
220+ }
221+ var resp struct {
222+ Ok bool `json:"ok"`
223+ Result * internal.ExecutionResult `json:"result"`
224+ }
225+ dec := json .NewDecoder (bufio .NewReader (conn ))
226+ if err := dec .Decode (& resp ); err != nil {
227+ return nil , err
228+ }
229+ if ! resp .Ok {
230+ return nil , fmt .Errorf ("remote error" )
231+ }
232+ if resp .Result == nil {
233+ r := & internal.ExecutionResult {Status : "unknown" }
234+ return r , nil
235+ }
236+ return resp .Result , nil
237+ }
238+
91239type uiStyles struct {
92240 bg lipgloss.Style
93241 container lipgloss.Style
@@ -134,6 +282,16 @@ func buildStyles(th theme) uiStyles {
134282 }
135283}
136284
285+ func defaultSuggestionItems () []list.Item {
286+ return []list.Item {
287+ list .Item (peerItem {Address : "" , Name : "uptime" , Role : "cmd" }),
288+ list .Item (peerItem {Address : "" , Name : "whoami" , Role : "cmd" }),
289+ list .Item (peerItem {Address : "" , Name : "hostname" , Role : "cmd" }),
290+ list .Item (peerItem {Address : "" , Name : "date" , Role : "cmd" }),
291+ list .Item (peerItem {Address : "" , Name : "echo hello" , Role : "cmd" }),
292+ }
293+ }
294+
137295func newModel (logger * logging.Logger , th theme , useEmoji bool ) model {
138296 items := []list.Item {}
139297 // Custom delegate with professional colors
@@ -167,22 +325,15 @@ func newModel(logger *logging.Logger, th theme, useEmoji bool) model {
167325 rf .CharLimit = 80
168326 rf .Prompt = "Filter: "
169327
170- // Suggestions list for commands
171- suggItems := []list.Item {
172- list .Item (peerItem {Address : "" , Name : "uptime" , Role : "cmd" }),
173- list .Item (peerItem {Address : "" , Name : "df -h" , Role : "cmd" }),
174- list .Item (peerItem {Address : "" , Name : "whoami" , Role : "cmd" }),
175- list .Item (peerItem {Address : "" , Name : "hostname" , Role : "cmd" }),
176- list .Item (peerItem {Address : "" , Name : "date" , Role : "cmd" }),
177- list .Item (peerItem {Address : "" , Name : "echo hello" , Role : "cmd" }),
178- }
328+ // Suggestions list for commands (defaults; history will be prepended dynamically)
329+ suggItems := defaultSuggestionItems ()
179330 suggDel := list .NewDefaultDelegate ()
180331 suggDel .Styles .SelectedTitle = lipgloss .NewStyle ().Foreground (th .SelectionText ).Background (th .SelectionBg ).Bold (true )
181332 suggDel .Styles .SelectedDesc = lipgloss .NewStyle ().Foreground (th .SelectionText ).Background (th .SelectionBg )
182333 suggDel .Styles .NormalTitle = lipgloss .NewStyle ().Foreground (th .Text )
183334 suggDel .Styles .NormalDesc = lipgloss .NewStyle ().Foreground (th .Muted )
184335 sl := list .New (suggItems , suggDel , 0 , 0 )
185- sl .Title = "Suggestions"
336+ sl .Title = "Suggestions (history + common) "
186337 sl .SetShowHelp (false )
187338 sl .SetShowFilter (false )
188339 sl .SetStatusBarItemName ("suggestion" , "suggestions" )
@@ -289,6 +440,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
289440 case tabCommands :
290441 m .input .Focus ()
291442 m .resultFilter .Blur ()
443+ // capture targetExpr from peers list filter input, if any
444+ m .targetExpr = strings .TrimSpace (m .peerList .FilterValue ())
445+ // refresh suggestions with history
446+ m .refreshSuggestions ()
292447 case tabResults :
293448 m .resultFilter .Focus ()
294449 m .input .Blur ()
@@ -308,6 +463,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
308463 case tabCommands :
309464 m .input .Focus ()
310465 m .resultFilter .Blur ()
466+ m .targetExpr = strings .TrimSpace (m .peerList .FilterValue ())
467+ m .refreshSuggestions ()
311468 case tabResults :
312469 m .resultFilter .Focus ()
313470 m .input .Blur ()
@@ -333,6 +490,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
333490 m .tab = tabCommands
334491 m .input .Focus ()
335492 m .resultFilter .Blur ()
493+ m .targetExpr = strings .TrimSpace (m .peerList .FilterValue ())
494+ m .refreshSuggestions ()
336495 return m , nil
337496 }
338497 }
@@ -341,21 +500,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
341500 // Simulate execution producing results
342501 cmd := strings .TrimSpace (m .input .Value ())
343502 if cmd != "" {
344- res := & internal.ExecutionResults {
345- CommandID : "local-" + time .Now ().Format ("150405" ),
346- Command : cmd ,
347- Target : "demo" ,
348- Results : []internal.ExecutionResult {
349- {ID : "x1" , Device : "local" , ExitCode : 0 , Stdout : cmd + " - ok" , Duration : 500 , Status : "ok" },
350- },
351- Summary : internal.ResultSummary {TotalDevices : 1 , Successful : 1 , Failed : 0 , Timeout : 0 , AverageDuration : 500 },
352- Timestamp : time .Now (),
503+ // record history (dedupe recent)
504+ if len (m .cmdHistory ) == 0 || m .cmdHistory [0 ] != cmd {
505+ m .cmdHistory = append ([]string {cmd }, m .cmdHistory ... )
506+ if len (m .cmdHistory ) > 20 {
507+ m .cmdHistory = m .cmdHistory [:20 ]
508+ }
353509 }
354- // Update results and switch to results tab
355- m .results = res
356- m .cmdHistory = append ([]string {cmd }, m .cmdHistory ... )
357- m .toast ("Executed: " + cmd )
358- m .tab = tabResults
510+ m .refreshSuggestions ()
511+ return m , m .executeCommand (cmd )
359512 }
360513 return m , nil
361514 }
@@ -386,6 +539,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
386539 case resultsUpdateMsg :
387540 m .results = msg .Results
388541 return m , nil
542+ case execDoneMsg :
543+ m .results = msg .Results
544+ m .tab = tabResults
545+ return m , nil
389546 case time.Time :
390547 // periodic tick to refresh animations/toasts
391548 return m , tea .Tick (time .Second , func (t time.Time ) tea.Msg { return t })
@@ -400,6 +557,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
400557 if m .tab == tabCommands {
401558 var cmd tea.Cmd
402559 m .input , cmd = m .input .Update (msg )
560+ // allow selecting from suggestions
561+ if km , ok := msg .(tea.KeyMsg ); ok {
562+ if km .String () == "tab" { // autocomplete from selected suggestion
563+ if it := m .suggList .SelectedItem (); it != nil {
564+ if pi , ok := it .(peerItem ); ok {
565+ m .input .SetValue (pi .Name )
566+ }
567+ }
568+ }
569+ }
403570 return m , cmd
404571 }
405572 if m .tab == tabResults {
@@ -513,7 +680,7 @@ func (m model) renderResults() string {
513680}
514681
515682func (m model ) renderCommands () string {
516- hint := lipgloss .NewStyle ().Foreground (m .theme .Muted ).Render ("Press Enter to simulate execution (demo)" )
683+ hint := lipgloss .NewStyle ().Foreground (m .theme .Muted ).Render ("Enter to run. Tab to take suggestion. Peers filtered by target: " + choose ( m . targetExpr != "" , m . targetExpr , "all" ) )
517684 return lipgloss .JoinVertical (lipgloss .Top , m .input .View (), m .suggList .View (), hint )
518685}
519686
@@ -678,3 +845,24 @@ func (m model) currentViewName() string {
678845 return ""
679846 }
680847}
848+
849+ func (m * model ) refreshSuggestions () {
850+ // Build items: history first, then defaults (no duplicates)
851+ seen := map [string ]bool {}
852+ items := make ([]list.Item , 0 , len (m .cmdHistory )+ 5 )
853+ for _ , h := range m .cmdHistory {
854+ if h = strings .TrimSpace (h ); h != "" && ! seen [h ] {
855+ items = append (items , list .Item (peerItem {Address : "" , Name : h , Role : "cmd" }))
856+ seen [h ] = true
857+ }
858+ }
859+ for _ , it := range defaultSuggestionItems () {
860+ if pi , ok := it .(peerItem ); ok {
861+ if ! seen [pi .Name ] {
862+ items = append (items , it )
863+ seen [pi .Name ] = true
864+ }
865+ }
866+ }
867+ m .suggList .SetItems (items )
868+ }
0 commit comments