From 059180117e1f1bdf3ce6d2e525cda7c3caac275d Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 10:13:51 -0500 Subject: [PATCH 01/11] DevMode: improvements around feedback --- internal/dev/server.go | 110 +++++++++++++++++++++++++++++++++++------ internal/dev/tui.go | 54 ++++++++++++++------ 2 files changed, 136 insertions(+), 28 deletions(-) diff --git a/internal/dev/server.go b/internal/dev/server.go index ff8c8273..e53f21f4 100644 --- a/internal/dev/server.go +++ b/internal/dev/server.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "net/http" "net/http/httputil" @@ -192,10 +193,33 @@ func (s *Server) connect(initial bool) { } +type AgentWelcome struct { + project.AgentConfig + Welcome +} + type AgentsControlResponse struct { - ProjectID string `json:"projectId"` - ProjectName string `json:"projectName"` - Agents []project.AgentConfig `json:"agents"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + Agents []AgentWelcome `json:"agents"` +} + +func (s *Server) getAgents(ctx context.Context, project *project.Project) (*AgentsControlResponse, error) { + var resp = &AgentsControlResponse{ + ProjectID: project.ProjectId, + ProjectName: project.Name, + } + welcome, err := s.getWelcome(ctx, s.port) + if err != nil { + return nil, err + } + for _, agent := range project.Agents { + resp.Agents = append(resp.Agents, AgentWelcome{ + AgentConfig: agent, + Welcome: welcome[agent.ID], + }) + } + return resp, nil } func sendCORSHeaders(headers http.Header) { @@ -226,18 +250,14 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { return case "/_agents": sendCORSHeaders(w.Header()) - buf, err := json.Marshal(AgentsControlResponse{ - ProjectID: s.Project.Project.ProjectId, - ProjectName: s.Project.Project.Name, - Agents: s.Project.Project.Agents, - }) + agents, err := s.getAgents(r.Context(), s.Project.Project) if err != nil { s.logger.Error("failed to marshal agents control response: %s", err) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - w.Write(buf) + io.WriteString(w, cstr.JSONStringify(agents)) return case "/_control": sendCORSHeaders(w.Header()) @@ -247,7 +267,15 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) rc := http.NewResponseController(w) rc.Flush() + s.HealthCheck(fmt.Sprintf("http://127.0.0.1:%d", s.port)) // make sure the server is running w.Write([]byte("event: start\ndata: connected\n\n")) + agents, err := s.getAgents(r.Context(), s.Project.Project) + if err != nil { + s.logger.Error("failed to marshal agents control response: %s", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Write([]byte(fmt.Sprintf("event: agents\ndata: %s\n\n", cstr.JSONStringify(agents)))) rc.Flush() select { case <-s.ctx.Done(): @@ -258,11 +286,11 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { return } - if r.Method != "POST" { - sendCORSHeaders(w.Header()) - w.WriteHeader(http.StatusMethodNotAllowed) - return - } + // if r.Method != "POST" { + // sendCORSHeaders(w.Header()) + // w.WriteHeader(http.StatusMethodNotAllowed) + // return + // } agentId := r.URL.Path[1:] var found bool @@ -281,6 +309,11 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { return } + if r.Method == "GET" { + message.CustomErrorResponse(w, "Agents, Not Humans, Live Here", "Hi! I'm an Agentuity Agent running in development mode.", "", http.StatusOK) + return + } + sctx, logger, span := telemetry.StartSpan(r.Context(), s.logger, s.tracer, "TriggerRun", trace.WithAttributes( attribute.Bool("@agentuity/devmode", true), @@ -373,6 +406,55 @@ func (s *Server) AgentURL(agentId string) string { return fmt.Sprintf("http://127.0.0.1:%d/%s", s.port, agentId) } +func isConnectionErrorRetryable(err error) bool { + if strings.Contains(err.Error(), "connection refused") { + return true + } + if strings.Contains(err.Error(), "connection reset by peer") { + return true + } + if strings.Contains(err.Error(), "No connection could be made because the target machine actively refused it") { // windows + return true + } + return false +} + +type Welcome struct { + Message string `json:"welcome"` + Prompts []struct { + Data string `json:"data"` + ContentType string `json:"contentType"` + } `json:"prompts,omitempty"` +} + +func (s *Server) getWelcome(ctx context.Context, port int) (map[string]Welcome, error) { + url := fmt.Sprintf("http://127.0.0.1:%d/welcome", port) + for i := 0; i < 5; i++ { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + if isConnectionErrorRetryable(err) { + time.Sleep(time.Millisecond * time.Duration(100*i+1)) + continue + } + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == 404 { + return nil, nil // this is ok, just means no agents have inspect + } + res := make(map[string]Welcome) + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + return res, nil + } + return nil, fmt.Errorf("failed to inspect agents after 5 attempts") +} + func (s *Server) HealthCheck(devModeUrl string) error { started := time.Now() var i int diff --git a/internal/dev/tui.go b/internal/dev/tui.go index 0a757c8e..1e5a0d03 100644 --- a/internal/dev/tui.go +++ b/internal/dev/tui.go @@ -25,6 +25,7 @@ var ( agentsKey = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "show agents")) logoColor = lipgloss.AdaptiveColor{Light: "#11c7b9", Dark: "#00FFFF"} labelColor = lipgloss.AdaptiveColor{Light: "#999999", Dark: "#FFFFFF"} + selectedColor = lipgloss.AdaptiveColor{Light: "#36EEE0", Dark: "#00FFFF"} runningColor = lipgloss.AdaptiveColor{Light: "#00FF00", Dark: "#009900"} pausedColor = lipgloss.AdaptiveColor{Light: "#FFA500", Dark: "#FFA500"} statusColor = lipgloss.AdaptiveColor{Light: "#750075", Dark: "#FF5CFF"} @@ -58,14 +59,17 @@ type model struct { type spinnerStartMsg struct{} type spinnerStopMsg struct{} -type logItem string +type logItem struct { + timestamp time.Time + message string +} -func (i logItem) Title() string { return strings.ReplaceAll(string(i), "\n", " ") } +func (i logItem) Title() string { return strings.ReplaceAll(string(i.message), "\n", " ") } func (i logItem) Description() string { return "" } -func (i logItem) FilterValue() string { return string(i) } +func (i logItem) FilterValue() string { return string(i.message) } type tickMsg time.Time -type addLogMsg string +type addLogMsg logItem type statusMessageMsg string func tick() tea.Cmd { @@ -89,7 +93,7 @@ func initialModel(config DevModeConfig) *model { listDelegate.ShowDescription = false listDelegate.SetSpacing(0) listDelegate.Styles.NormalTitle = listDelegate.Styles.NormalTitle.Padding(0, 1) - listDelegate.Styles.SelectedTitle = listDelegate.Styles.SelectedTitle.BorderLeft(false).Foreground(labelColor).Bold(true) + listDelegate.Styles.SelectedTitle = listDelegate.Styles.SelectedTitle.BorderLeft(false).Foreground(selectedColor).Bold(true) l := list.New(items, listDelegate, width-2, 10) l.SetShowTitle(false) @@ -174,7 +178,7 @@ func (m *model) generateInfoBox() string { %s %s %s %s`, tui.Bold("⨺ Agentuity DevMode")+" "+statusStyle.Render(tui.PadLeft("⏺", m.windowSize.Width-25, " ")), - label("Dashboard"), tui.Link("%s", m.appUrl), + label("DevMode"), tui.Link("%s", m.appUrl), label("Local"), tui.Link("%s", m.devModeUrl), label("Public"), url, ) @@ -196,11 +200,30 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner = sm cmd = append(cmd, c) break + case tea.MouseMsg: + if !m.showhelp && !m.showagents && !m.logList.SettingFilter() && m.selectedLog == nil { + if msg.Button == tea.MouseButtonWheelUp { + m.logList.CursorUp() + } else if msg.Button == tea.MouseButtonWheelDown { + m.logList.CursorDown() + } else if msg.Button == tea.MouseButtonLeft && m.selectedLog == nil { + if sel := m.logList.SelectedItem(); sel != nil { + if log, ok := sel.(logItem); ok { + m.selectedLog = &log + break + } + } + } + } + break case tea.KeyMsg: if msg.Type == tea.KeyCtrlC { cmd = append(cmd, tea.Quit) break } + if m.logList.SettingFilter() { + break // if in the filter mode, don't do anything as it will be handled by the list + } if msg.Type == tea.KeyEscape { if m.showhelp { m.showhelp = false @@ -215,6 +238,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedLog = nil return m, nil } + return m, nil // otherwise escape just is ignored } if msg.Type == tea.KeyEnter && m.selectedLog == nil { if sel := m.logList.SelectedItem(); sel != nil { @@ -262,11 +286,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd = append(cmd, tick()) break case addLogMsg: - m.logItems = append([]list.Item{logItem(msg)}, m.logItems...) - if !m.paused { - if m.logList.FilterState() == list.Unfiltered { - m.logList.SetItems(m.logItems) - } + m.logItems = append(m.logItems, logItem(msg)) + cmd = append(cmd, m.logList.SetItems(m.logItems)) + if m.logList.FilterState() == list.Unfiltered && !m.paused { + m.logList.Select(len(m.logItems) - 1) } break case statusMessageMsg: @@ -324,7 +347,7 @@ func (m *model) View() string { ) } else if m.selectedLog != nil { showModal = true - modalContent = string(*m.selectedLog) + modalContent = fmt.Sprintf("%s\n\n%s", tui.Muted(m.selectedLog.timestamp.Format(time.DateTime)), tui.Highlight(m.selectedLog.message)) } else if m.showagents { showModal = true modalContent = "Agents" @@ -427,8 +450,8 @@ func (d *DevModeUI) Close(abort bool) { func (d *DevModeUI) Start() { d.program = tea.NewProgram( d.model, - tea.WithAltScreen(), tea.WithoutSignalHandler(), + tea.WithMouseAllMotion(), ) d.wg.Add(1) go func() { @@ -450,7 +473,10 @@ func (d *DevModeUI) Start() { // Add a log message to the log list func (d *DevModeUI) AddLog(log string, args ...any) { - d.program.Send(addLogMsg(fmt.Sprintf(log, args...))) + d.program.Send(addLogMsg{ + timestamp: time.Now(), + message: ansiColorStripper.ReplaceAllString(fmt.Sprintf(log, args...), ""), + }) } func (d *DevModeUI) SetStatusMessage(msg string, args ...any) { From 40f9ea2523f3d8213ea1287631403af037f84e84 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 10:16:33 -0500 Subject: [PATCH 02/11] remove dead code --- internal/dev/server.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/dev/server.go b/internal/dev/server.go index e53f21f4..669a47aa 100644 --- a/internal/dev/server.go +++ b/internal/dev/server.go @@ -286,12 +286,6 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { return } - // if r.Method != "POST" { - // sendCORSHeaders(w.Header()) - // w.WriteHeader(http.StatusMethodNotAllowed) - // return - // } - agentId := r.URL.Path[1:] var found bool for _, agent := range s.Project.Project.Agents { From ef08c516446395b1e086f64188060253989cee58 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 10:21:47 -0500 Subject: [PATCH 03/11] ignore single doc changes --- .github/workflows/go.yml | 3 +++ .github/workflows/upgrade-test.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index fb0e2a4a..7d1855a5 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,6 +8,9 @@ on: - 'CHANGELOG.md' pull_request: branches: [ "main" ] + paths-ignore: + - 'README.md' + - 'CHANGELOG.md' permissions: contents: read diff --git a/.github/workflows/upgrade-test.yml b/.github/workflows/upgrade-test.yml index a9de465a..d0506fb9 100644 --- a/.github/workflows/upgrade-test.yml +++ b/.github/workflows/upgrade-test.yml @@ -8,6 +8,9 @@ on: - 'CHANGELOG.md' pull_request: branches: [ main ] + paths-ignore: + - 'README.md' + - 'CHANGELOG.md' workflow_dispatch: permissions: From ca37247605c631b5c695a025f917470da87ef7a8 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 10:25:34 -0500 Subject: [PATCH 04/11] Update internal/dev/server.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/dev/server.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/dev/server.go b/internal/dev/server.go index 669a47aa..7222da50 100644 --- a/internal/dev/server.go +++ b/internal/dev/server.go @@ -272,7 +272,8 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { agents, err := s.getAgents(r.Context(), s.Project.Project) if err != nil { s.logger.Error("failed to marshal agents control response: %s", err) - w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("event: error\ndata: %q\n\n", err.Error()))) + rc.Flush() return } w.Write([]byte(fmt.Sprintf("event: agents\ndata: %s\n\n", cstr.JSONStringify(agents)))) From 6aa31505703aa32d6b47d9e179a5636e6be6c8df Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 10:25:53 -0500 Subject: [PATCH 05/11] Update internal/dev/tui.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/dev/tui.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/dev/tui.go b/internal/dev/tui.go index 1e5a0d03..899afdfe 100644 --- a/internal/dev/tui.go +++ b/internal/dev/tui.go @@ -206,11 +206,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logList.CursorUp() } else if msg.Button == tea.MouseButtonWheelDown { m.logList.CursorDown() - } else if msg.Button == tea.MouseButtonLeft && m.selectedLog == nil { + } else if msg.Button == tea.MouseButtonLeft { + // let the list update first so the clicked row becomes selected + lm, _ := m.logList.Update(msg) + m.logList = lm if sel := m.logList.SelectedItem(); sel != nil { if log, ok := sel.(logItem); ok { m.selectedLog = &log - break } } } From f09ae29aa297714232337f6735632e1b4bd9f453 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 10:48:22 -0500 Subject: [PATCH 06/11] Update internal/dev/server.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/dev/server.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/dev/server.go b/internal/dev/server.go index 7222da50..6d1c0d27 100644 --- a/internal/dev/server.go +++ b/internal/dev/server.go @@ -214,9 +214,13 @@ func (s *Server) getAgents(ctx context.Context, project *project.Project) (*Agen return nil, err } for _, agent := range project.Agents { + var w Welcome + if welcome != nil { + w = welcome[agent.ID] + } resp.Agents = append(resp.Agents, AgentWelcome{ AgentConfig: agent, - Welcome: welcome[agent.ID], + Welcome: w, }) } return resp, nil From 2cbafce1b754bf7ce2fc898170a85997f95cc06e Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 11:20:35 -0500 Subject: [PATCH 07/11] on exit, keep raw logs in output buffer --- internal/dev/tui.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/dev/tui.go b/internal/dev/tui.go index 899afdfe..2857c395 100644 --- a/internal/dev/tui.go +++ b/internal/dev/tui.go @@ -62,6 +62,7 @@ type spinnerStopMsg struct{} type logItem struct { timestamp time.Time message string + raw string } func (i logItem) Title() string { return strings.ReplaceAll(string(i.message), "\n", " ") } @@ -445,6 +446,10 @@ func (d *DevModeUI) Close(abort bool) { d.program.Quit() <-d.Done() fmt.Fprint(os.Stdout, "\033c") + tui.ClearScreen() + for _, item := range d.model.logItems { + fmt.Println(item.(logItem).raw) + } }) } @@ -460,11 +465,6 @@ func (d *DevModeUI) Start() { defer func() { d.cancel() d.wg.Done() - if d.aborting { - for i := len(d.model.logItems) - 1; i >= 0; i-- { - fmt.Println(d.model.logItems[i]) - } - } }() _, err := d.program.Run() if err != nil { @@ -475,9 +475,11 @@ func (d *DevModeUI) Start() { // Add a log message to the log list func (d *DevModeUI) AddLog(log string, args ...any) { + raw := fmt.Sprintf(log, args...) d.program.Send(addLogMsg{ timestamp: time.Now(), - message: ansiColorStripper.ReplaceAllString(fmt.Sprintf(log, args...), ""), + raw: raw, + message: ansiColorStripper.ReplaceAllString(raw, ""), }) } From 4dc5808c3e2775e8b7acda17cdbe416f56241b2e Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 12:42:53 -0500 Subject: [PATCH 08/11] add better mouse click tracking --- go.mod | 9 +++++---- go.sum | 18 ++++++++++-------- internal/dev/tui.go | 27 ++++++++++++++++----------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index 479e2d83..28f5398d 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/evanw/esbuild v0.25.0 github.com/fsnotify/fsnotify v1.7.0 github.com/google/uuid v1.6.0 + github.com/lrstanley/bubblezone v1.0.0 github.com/marcozac/go-jsonc v0.1.1 github.com/mattn/go-isatty v0.0.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c @@ -35,8 +36,8 @@ require ( github.com/ProtonMail/go-crypto v1.1.5 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/cloudflare/circl v1.6.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect @@ -132,9 +133,9 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/net v0.38.0 - golang.org/x/sync v0.12.0 // indirect + golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 - golang.org/x/text v0.23.0 // indirect + golang.org/x/text v0.24.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/grpc v1.71.0 // indirect diff --git a/go.sum b/go.sum index af7e7f40..efc4f535 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQW github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= -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/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/huh/spinner v0.0.0-20250313000648-36d9de46d64e h1:J8uxtAwJwvw0r5Wf+dfglLl/s+LcuUwj6VvoMyFw89U= @@ -53,8 +53,8 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -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/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= @@ -152,6 +152,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= @@ -320,8 +322,8 @@ golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -343,8 +345,8 @@ golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 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.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/internal/dev/tui.go b/internal/dev/tui.go index 2857c395..86d2be2c 100644 --- a/internal/dev/tui.go +++ b/internal/dev/tui.go @@ -15,6 +15,8 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + zone "github.com/lrstanley/bubblezone" "golang.org/x/term" ) @@ -60,14 +62,15 @@ type spinnerStartMsg struct{} type spinnerStopMsg struct{} type logItem struct { + id string timestamp time.Time message string raw string } -func (i logItem) Title() string { return strings.ReplaceAll(string(i.message), "\n", " ") } +func (i logItem) Title() string { return zone.Mark(i.id, i.message) } func (i logItem) Description() string { return "" } -func (i logItem) FilterValue() string { return string(i.message) } +func (i logItem) FilterValue() string { return zone.Mark(i.id, i.message) } type tickMsg time.Time type addLogMsg logItem @@ -207,13 +210,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logList.CursorUp() } else if msg.Button == tea.MouseButtonWheelDown { m.logList.CursorDown() - } else if msg.Button == tea.MouseButtonLeft { - // let the list update first so the clicked row becomes selected - lm, _ := m.logList.Update(msg) - m.logList = lm - if sel := m.logList.SelectedItem(); sel != nil { - if log, ok := sel.(logItem); ok { - m.selectedLog = &log + } else if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionRelease { + // try and find the item that was clicked on + for i, listItem := range m.logList.VisibleItems() { + v, _ := listItem.(logItem) + if zone.Get(v.id).InBounds(msg) { + m.logList.Select(i - 1) + break } } } @@ -390,7 +393,7 @@ func (m *model) View() string { view = " " } - return fmt.Sprintf("%s\n%s\n%s", m.infoBox, view+statusMsgStyle.Render(m.statusMessage), m.logList.View()) + return zone.Scan(fmt.Sprintf("%s\n%s\n%s", m.infoBox, view+statusMsgStyle.Render(m.statusMessage), m.logList.View())) } type Agent struct { @@ -455,6 +458,7 @@ func (d *DevModeUI) Close(abort bool) { // Start the program func (d *DevModeUI) Start() { + zone.NewGlobal() d.program = tea.NewProgram( d.model, tea.WithoutSignalHandler(), @@ -477,9 +481,10 @@ func (d *DevModeUI) Start() { func (d *DevModeUI) AddLog(log string, args ...any) { raw := fmt.Sprintf(log, args...) d.program.Send(addLogMsg{ + id: uuid.New().String(), timestamp: time.Now(), raw: raw, - message: ansiColorStripper.ReplaceAllString(raw, ""), + message: strings.ReplaceAll(ansiColorStripper.ReplaceAllString(raw, ""), "\n", " "), }) } From 11d9de01db063585d05d8600f6981a7352aa3d23 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 13:08:33 -0500 Subject: [PATCH 09/11] Fix issue with port binding conflict with multiple agents, support non TUI mode --- cmd/agent.go | 2 +- cmd/dev.go | 8 ++-- internal/dev/dev.go | 16 +++++--- internal/dev/tui.go | 92 ++++++++++++++++++++++++++++++++++++--------- 4 files changed, 90 insertions(+), 28 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index 5edd9dcf..e753adee 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -767,7 +767,7 @@ var agentTestCmd = &cobra.Command{ } endpoint := fmt.Sprintf("%s/%s/%s", theproject.TransportURL, route, agentID) if local { - port, _ := dev.FindAvailablePort(theproject) + port, _ := dev.FindAvailablePort(theproject, 0) endpoint = fmt.Sprintf("http://127.0.0.1:%d/%s", port, agentID) } diff --git a/cmd/dev.go b/cmd/dev.go index 29347f15..c0e527e3 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -75,11 +75,9 @@ Examples: orgId := project.OrgId port, _ := cmd.Flags().GetInt("port") - if port == 0 { - port, err = dev.FindAvailablePort(theproject) - if err != nil { - log.Fatal("failed to find available port: %s", err) - } + port, err = dev.FindAvailablePort(theproject, port) + if err != nil { + log.Fatal("failed to find available port: %s", err) } serverAddr, _ := cmd.Flags().GetString("server") diff --git a/internal/dev/dev.go b/internal/dev/dev.go index ffcb765a..c3a91f9a 100644 --- a/internal/dev/dev.go +++ b/internal/dev/dev.go @@ -56,12 +56,13 @@ func KillProjectServer(logger logger.Logger, projectServerCmd *exec.Cmd, pid int } func isPortAvailable(port int) bool { - listener, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", port)) + timeout := time.Second + conn, err := net.DialTimeout("tcp", fmt.Sprintf("0.0.0.0:%d", port), timeout) if err != nil { - return false + return true } - listener.Close() - return true + defer conn.Close() + return false } func findAvailablePort() (int, error) { @@ -73,7 +74,12 @@ func findAvailablePort() (int, error) { return listener.Addr().(*net.TCPAddr).Port, nil } -func FindAvailablePort(p project.ProjectContext) (int, error) { +func FindAvailablePort(p project.ProjectContext, tryPort int) (int, error) { + if tryPort > 0 { + if isPortAvailable(tryPort) { + return tryPort, nil + } + } if v, ok := os.LookupEnv("AGENTUITY_CLOUD_PORT"); ok && v != "" { p, err := strconv.Atoi(v) if err != nil { diff --git a/internal/dev/tui.go b/internal/dev/tui.go index 86d2be2c..c7e580d3 100644 --- a/internal/dev/tui.go +++ b/internal/dev/tui.go @@ -157,13 +157,13 @@ func label(s string) string { return labelStyle.Render(tui.PadRight(s, 10, " ")) } -func (m *model) generateInfoBox() string { +func generateInfoBox(width int, showPause bool, paused bool, publicUrl string, appUrl string, devModeUrl string) string { var statusStyle = runningStyle - if m.paused { + if paused { statusStyle = pausedStyle } var devmodeBox = lipgloss.NewStyle(). - Width(m.windowSize.Width-2). + Width(width-2). Border(lipgloss.NormalBorder()). BorderForeground(logoColor). Padding(1, 2). @@ -172,8 +172,13 @@ func (m *model) generateInfoBox() string { Foreground(labelColor) url := "loading..." - if m.publicUrl != "" { - url = tui.Link("%s", m.publicUrl) + " " + tui.Muted("(only accessible while running)") + if publicUrl != "" { + url = tui.Link("%s", publicUrl) + " " + tui.Muted("(only accessible while running)") + } + + var pauseLabel string + if showPause { + pauseLabel = statusStyle.Render(tui.PadLeft("⏺", width-25, " ")) } content := fmt.Sprintf(`%s @@ -181,14 +186,18 @@ func (m *model) generateInfoBox() string { %s %s %s %s %s %s`, - tui.Bold("⨺ Agentuity DevMode")+" "+statusStyle.Render(tui.PadLeft("⏺", m.windowSize.Width-25, " ")), - label("DevMode"), tui.Link("%s", m.appUrl), - label("Local"), tui.Link("%s", m.devModeUrl), + tui.Bold("⨺ Agentuity DevMode")+" "+pauseLabel, + label("DevMode"), tui.Link("%s", appUrl), + label("Local"), tui.Link("%s", devModeUrl), label("Public"), url, ) return devmodeBox.Render(content) } +func (m *model) generateInfoBox() string { + return generateInfoBox(m.windowSize.Width, true, m.paused, m.publicUrl, m.appUrl, m.devModeUrl) +} + func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd []tea.Cmd @@ -415,6 +424,10 @@ type DevModeUI struct { spinnerCtx context.Context spinnerCancel context.CancelFunc aborting bool + enabled bool + publicUrl string + devModeUrl string + appUrl string } type DevModeConfig struct { @@ -424,17 +437,39 @@ type DevModeConfig struct { Agents []*Agent } +func isVSCodeTerminal() bool { + return os.Getenv("TERM_PROGRAM") == "vscode" +} + func NewDevModeUI(ctx context.Context, config DevModeConfig) *DevModeUI { ctx, cancel := context.WithCancel(ctx) + enabled := true + var model *model + if !tui.HasTTY || isVSCodeTerminal() { + enabled = false + } else { + model = initialModel(config) + } return &DevModeUI{ - ctx: ctx, - cancel: cancel, - model: initialModel(config), + ctx: ctx, + cancel: cancel, + model: model, + enabled: enabled, + publicUrl: config.PublicUrl, + devModeUrl: config.DevModeUrl, + appUrl: config.AppUrl, } } func (d *DevModeUI) SetPublicURL(url string) { - d.model.publicUrl = url + d.publicUrl = url + if d.model != nil { + d.model.publicUrl = url + } + if !d.enabled { + width, _, _ := term.GetSize(int(os.Stdout.Fd())) + fmt.Println(generateInfoBox(width, false, false, d.publicUrl, d.appUrl, d.devModeUrl)) + } } // Done returns a channel that will be closed when the program is done @@ -446,18 +481,27 @@ func (d *DevModeUI) Done() <-chan struct{} { func (d *DevModeUI) Close(abort bool) { d.once.Do(func() { d.aborting = abort - d.program.Quit() + if d.enabled { + d.program.Quit() + } else { + d.cancel() + } <-d.Done() - fmt.Fprint(os.Stdout, "\033c") - tui.ClearScreen() - for _, item := range d.model.logItems { - fmt.Println(item.(logItem).raw) + if d.enabled { + fmt.Fprint(os.Stdout, "\033c") + tui.ClearScreen() + for _, item := range d.model.logItems { + fmt.Println(item.(logItem).raw) + } } }) } // Start the program func (d *DevModeUI) Start() { + if !d.enabled { + return + } zone.NewGlobal() d.program = tea.NewProgram( d.model, @@ -479,6 +523,10 @@ func (d *DevModeUI) Start() { // Add a log message to the log list func (d *DevModeUI) AddLog(log string, args ...any) { + if !d.enabled { + fmt.Println(fmt.Sprintf(log, args...)) + return + } raw := fmt.Sprintf(log, args...) d.program.Send(addLogMsg{ id: uuid.New().String(), @@ -490,6 +538,9 @@ func (d *DevModeUI) AddLog(log string, args ...any) { func (d *DevModeUI) SetStatusMessage(msg string, args ...any) { val := fmt.Sprintf(msg, args...) + if !d.enabled { + return + } d.program.Send(statusMessageMsg(val)) if val != "" { go func() { @@ -506,6 +557,10 @@ func (d *DevModeUI) SetStatusMessage(msg string, args ...any) { } func (d *DevModeUI) ShowSpinner(msg string, fn func()) { + if !d.enabled { + fn() + return + } d.SetSpinner(true) d.SetStatusMessage("%s", msg) fn() @@ -514,6 +569,9 @@ func (d *DevModeUI) ShowSpinner(msg string, fn func()) { } func (d *DevModeUI) SetSpinner(spinning bool) { + if !d.enabled { + return + } if spinning { d.program.Send(spinnerStartMsg{}) ctx, cancel := context.WithCancel(d.ctx) From 734f6039c4978ab35bf4187c8e70c7bd50b9a77b Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 13:10:59 -0500 Subject: [PATCH 10/11] fix potential index --- internal/dev/tui.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/dev/tui.go b/internal/dev/tui.go index c7e580d3..1d7ffbea 100644 --- a/internal/dev/tui.go +++ b/internal/dev/tui.go @@ -224,7 +224,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for i, listItem := range m.logList.VisibleItems() { v, _ := listItem.(logItem) if zone.Get(v.id).InBounds(msg) { - m.logList.Select(i - 1) + index := i - 1 + if index < 0 { + index = 0 + } + m.logList.Select(index) break } } From 126e4ae9da91684e515af91c87eb2e31f22c5396 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Sat, 17 May 2025 13:17:39 -0500 Subject: [PATCH 11/11] pr feedback --- internal/dev/tui.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/dev/tui.go b/internal/dev/tui.go index 1d7ffbea..c1ef2109 100644 --- a/internal/dev/tui.go +++ b/internal/dev/tui.go @@ -471,7 +471,10 @@ func (d *DevModeUI) SetPublicURL(url string) { d.model.publicUrl = url } if !d.enabled { - width, _, _ := term.GetSize(int(os.Stdout.Fd())) + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + width = 80 // Default width if terminal size can't be determined + } fmt.Println(generateInfoBox(width, false, false, d.publicUrl, d.appUrl, d.devModeUrl)) } }