From dee4a67ef8b8a7514ef4315feaf02d145bd498c1 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Fri, 23 May 2025 14:49:30 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=92=94=20Bye=20Bye=20DevMode=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/dev.go | 134 +++----- go.mod | 3 +- go.sum | 4 - internal/dev/pending_logger.go | 165 --------- internal/dev/server.go | 82 +++-- internal/dev/tui.go | 611 --------------------------------- internal/dev/tui_logger.go | 167 --------- 7 files changed, 109 insertions(+), 1057 deletions(-) delete mode 100644 internal/dev/pending_logger.go delete mode 100644 internal/dev/tui.go delete mode 100644 internal/dev/tui_logger.go diff --git a/cmd/dev.go b/cmd/dev.go index 82e47f95..ac10b8f2 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "os" "os/signal" "syscall" @@ -98,49 +97,25 @@ Examples: processCtx := context.Background() var pid int - consoleUrl := server.WebURL(appUrl) - devModeUrl := fmt.Sprintf("http://127.0.0.1:%d", port) - - agents := make([]*dev.Agent, 0) - for _, agent := range theproject.Project.Agents { - agents = append(agents, &dev.Agent{ - ID: agent.ID, - Name: agent.Name, - LocalURL: fmt.Sprintf("%s/%s", devModeUrl, agent.ID), - }) - } - - ui := dev.NewDevModeUI(ctx, dev.DevModeConfig{ - DevModeUrl: devModeUrl, - AppUrl: consoleUrl, - Agents: agents, - }) - - ui.Start() - - defer ui.Close(false) - - tuiLogger := dev.NewTUILogger(logLevel, ui, dev.Stdout) - tuiLoggerErr := dev.NewTUILogger(logLevel, ui, dev.StdErr) - - if err := server.Connect(ui, tuiLogger, tuiLoggerErr); err != nil { - if errors.Is(err, context.Canceled) { - ui.Close(true) + waitForConnection := func() { + if err := server.Connect(); err != nil { + if errors.Is(err, context.Canceled) { + return + } + log.Error("failed to start live dev connection: %s", err) return } - log.Error("failed to start live dev connection: %s", err) - ui.Close(true) - return } - publicUrl := server.PublicURL(appUrl) - ui.SetPublicURL(publicUrl) + tui.ShowSpinner("Connecting ...", waitForConnection) - for _, agent := range agents { - agent.PublicURL = fmt.Sprintf("%s/%s", publicUrl, agent.ID) - } + publicUrl := server.PublicURL(appUrl) + consoleUrl := server.WebURL(appUrl) + devModeUrl := fmt.Sprintf("http://127.0.0.1:%d", port) + infoBox := server.GenerateInfoBox(publicUrl, consoleUrl, devModeUrl) + fmt.Println(infoBox) - projectServerCmd, err := dev.CreateRunProjectCmd(processCtx, tuiLogger, theproject, server, dir, orgId, port, tuiLogger, tuiLoggerErr) + projectServerCmd, err := dev.CreateRunProjectCmd(processCtx, log, theproject, server, dir, orgId, port, os.Stdout, os.Stderr) if err != nil { errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage("Failed to run project")).ShowErrorAndExit() } @@ -148,92 +123,88 @@ Examples: build := func(initial bool) bool { started := time.Now() var ok bool - ui.ShowSpinner("Building project ...", func() { - var w io.Writer = tuiLogger + tui.ShowSpinner("Building project ...", func() { if err := bundler.Bundle(bundler.BundleContext{ Context: ctx, - Logger: tuiLogger, + Logger: log, ProjectDir: dir, Production: false, DevMode: true, - Writer: w, + Writer: os.Stdout, }); err != nil { if err == bundler.ErrBuildFailed { return } - ui.Close(true) errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage(fmt.Sprintf("Failed to bundle project: %s", err))).ShowErrorAndExit() } ok = true }) if ok && !initial { - ui.SetStatusMessage("✨ Built in %s", time.Since(started).Round(time.Millisecond)) + log.Info("✨ Built in %s", time.Since(started).Round(time.Millisecond)) } return ok } // Initial build must exit if it fails if !build(true) { - ui.Close(true) return } restart := func() { isDeliberateRestart = true build(false) - tuiLogger.Debug("killing project server") - dev.KillProjectServer(tuiLogger, projectServerCmd, pid) - tuiLogger.Debug("killing project server done") + log.Debug("killing project server") + dev.KillProjectServer(log, projectServerCmd, pid) + log.Debug("killing project server done") } - ui.SetStatusMessage("starting ...") - ui.SetSpinner(true) - // debounce a lot of changes at once to avoid multiple restarts in succession debounced := debounce.New(250 * time.Millisecond) // Watch for changes - watcher, err := dev.NewWatcher(tuiLogger, dir, theproject.Project.Development.Watch.Files, func(path string) { - tuiLogger.Trace("%s has changed", path) + watcher, err := dev.NewWatcher(log, dir, theproject.Project.Development.Watch.Files, func(path string) { + log.Trace("%s has changed", path) debounced(restart) }) if err != nil { errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage(fmt.Sprintf("Failed to start watcher: %s", err))).ShowErrorAndExit() } - defer watcher.Close(tuiLogger) + defer watcher.Close(log) - tuiLogger.Trace("starting project server") - if err := projectServerCmd.Start(); err != nil { - errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage(fmt.Sprintf("Failed to start project: %s", err))).ShowErrorAndExit() - } + initRun := func() { + log.Trace("starting project server") + if err := projectServerCmd.Start(); err != nil { + errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage(fmt.Sprintf("Failed to start project: %s", err))).ShowErrorAndExit() + } - pid = projectServerCmd.Process.Pid - tuiLogger.Trace("started project server with pid: %d", pid) + pid = projectServerCmd.Process.Pid + log.Trace("started project server with pid: %d", pid) - if err := server.HealthCheck(devModeUrl); err != nil { - tuiLogger.Error("failed to health check connection: %s", err) - dev.KillProjectServer(tuiLogger, projectServerCmd, pid) - ui.Close(true) - return + if err := server.HealthCheck(devModeUrl); err != nil { + log.Error("failed to health check connection: %s", err) + dev.KillProjectServer(log, projectServerCmd, pid) + return + } } - ui.SetStatusMessage("🚀 DevMode ready") - ui.SetSpinner(false) + tui.ShowSpinner("Starting Agents ...", initRun) + + log.Info("🚀 DevMode ready") go func() { for { - tuiLogger.Trace("waiting for project server to exit (pid: %d)", pid) + log.Trace("waiting for project server to exit (pid: %d)", pid) if err := projectServerCmd.Wait(); err != nil { if !isDeliberateRestart { - tuiLogger.Error("project server (pid: %d) exited with error: %s", pid, err) + log.Error("project server (pid: %d) exited with error: %s", pid, err) } } if projectServerCmd.ProcessState != nil { - tuiLogger.Debug("project server (pid: %d) exited with code %d", pid, projectServerCmd.ProcessState.ExitCode()) + log.Debug("project server (pid: %d) exited with code %d", pid, projectServerCmd.ProcessState.ExitCode()) } else { - tuiLogger.Debug("project server (pid: %d) exited", pid) + log.Debug("project server (pid: %d) exited", pid) } - tuiLogger.Debug("isDeliberateRestart: %t, pid: %d", isDeliberateRestart, pid) + log.Debug("isDeliberateRestart: %t, pid: %d", isDeliberateRestart, pid) if !isDeliberateRestart { return } @@ -241,8 +212,8 @@ Examples: // If it was a deliberate restart, start the new process here if isDeliberateRestart { isDeliberateRestart = false - tuiLogger.Trace("restarting project server") - projectServerCmd, err = dev.CreateRunProjectCmd(processCtx, tuiLogger, theproject, server, dir, orgId, port, tuiLogger, tuiLoggerErr) + log.Trace("restarting project server") + projectServerCmd, err = dev.CreateRunProjectCmd(processCtx, log, theproject, server, dir, orgId, port, os.Stdout, os.Stderr) if err != nil { errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage("Failed to run project")).ShowErrorAndExit() } @@ -250,23 +221,20 @@ Examples: errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage(fmt.Sprintf("Failed to start project: %s", err))).ShowErrorAndExit() } pid = projectServerCmd.Process.Pid - tuiLogger.Trace("restarted project server (pid: %d)", pid) + log.Trace("restarted project server (pid: %d)", pid) } } }() teardown := func() { - watcher.Close(tuiLogger) + watcher.Close(log) server.Close() - dev.KillProjectServer(tuiLogger, projectServerCmd, pid) + dev.KillProjectServer(log, projectServerCmd, pid) } - select { - case <-ui.Done(): - teardown() - case <-ctx.Done(): - teardown() - } + <-ctx.Done() + teardown() + }, } diff --git a/go.mod b/go.mod index 4ae3e5a1..94921f2b 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,6 @@ require ( github.com/stretchr/testify v1.10.0 github.com/zijiren233/yaml-comment v0.2.2 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 - golang.org/x/term v0.30.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.32.1 ) @@ -48,7 +47,6 @@ require ( github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -59,6 +57,7 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.36.0 // indirect + golang.org/x/term v0.30.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 9d98738e..48a3f2a7 100644 --- a/go.sum +++ b/go.sum @@ -150,8 +150,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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/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= @@ -210,8 +208,6 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= diff --git a/internal/dev/pending_logger.go b/internal/dev/pending_logger.go deleted file mode 100644 index 239e5dc2..00000000 --- a/internal/dev/pending_logger.go +++ /dev/null @@ -1,165 +0,0 @@ -package dev - -import ( - "context" - "fmt" - "os" - "sync" - - "github.com/agentuity/go-common/logger" - "github.com/agentuity/go-common/tui" -) - -type pendingLog struct { - level logger.LogLevel - msg string -} - -type PendingLogger struct { - pending []pendingLog - logLevel logger.LogLevel - logger logger.Logger - mutex sync.RWMutex -} - -var _ logger.Logger = (*PendingLogger)(nil) - -func NewPendingLogger(logLevel logger.LogLevel) *PendingLogger { - return &PendingLogger{ - pending: make([]pendingLog, 0), - logLevel: logLevel, - } -} - -func (l *PendingLogger) drain(ui *DevModeUI, logger logger.Logger) { - l.mutex.Lock() - defer l.mutex.Unlock() - for _, val := range l.pending { - ui.AddLog(val.level, "%s", val.msg) - } - l.logger = logger - l.pending = nil -} - -// With will return a new logger using metadata as the base context -func (l *PendingLogger) With(metadata map[string]interface{}) logger.Logger { - return l -} - -// WithPrefix will return a new logger with a prefix prepended to the message -func (l *PendingLogger) WithPrefix(prefix string) logger.Logger { - return l -} - -// WithContext will return a new logger with the given context -func (l *PendingLogger) WithContext(ctx context.Context) logger.Logger { - return l -} - -// Trace level logging -func (l *PendingLogger) Trace(msg string, args ...interface{}) { - if logger.LevelTrace < l.logLevel { - return - } - l.mutex.RLock() - defer l.mutex.RUnlock() - if l.logger != nil { - l.logger.Trace(msg, args...) - return - } - val := pendingLog{ - level: logger.LevelTrace, - msg: fmt.Sprintf(msg, args...), - } - l.pending = append(l.pending, val) -} - -// Debug level logging -func (l *PendingLogger) Debug(msg string, args ...interface{}) { - if logger.LevelDebug < l.logLevel { - return - } - l.mutex.RLock() - defer l.mutex.RUnlock() - if l.logger != nil { - l.logger.Debug(msg, args...) - return - } - val := pendingLog{ - level: logger.LevelDebug, - msg: fmt.Sprintf(msg, args...), - } - l.pending = append(l.pending, val) -} - -// Info level loggi ng -func (l *PendingLogger) Info(msg string, args ...interface{}) { - if logger.LevelInfo < l.logLevel { - return - } - l.mutex.RLock() - defer l.mutex.RUnlock() - if l.logger != nil { - l.logger.Info(msg, args...) - return - } - val := pendingLog{ - level: logger.LevelInfo, - msg: fmt.Sprintf(msg, args...), - } - l.pending = append(l.pending, val) -} - -// Warning level logging -func (l *PendingLogger) Warn(msg string, args ...interface{}) { - if logger.LevelWarn < l.logLevel { - return - } - l.mutex.RLock() - defer l.mutex.RUnlock() - if l.logger != nil { - l.logger.Warn(msg, args...) - return - } - val := pendingLog{ - level: logger.LevelWarn, - msg: fmt.Sprintf(msg, args...), - } - l.pending = append(l.pending, val) -} - -// Error level logging -func (l *PendingLogger) Error(msg string, args ...interface{}) { - if logger.LevelError < l.logLevel { - return - } - l.mutex.RLock() - defer l.mutex.RUnlock() - if l.logger != nil { - l.logger.Error(msg, args...) - return - } - val := pendingLog{ - level: logger.LevelError, - msg: fmt.Sprintf(msg, args...), - } - l.pending = append(l.pending, val) -} - -// Fatal level logging and exit with code 1 -func (l *PendingLogger) Fatal(msg string, args ...interface{}) { - l.mutex.RLock() - defer l.mutex.RUnlock() - if l.logger != nil { - l.logger.Fatal(msg, args...) - return - } - val := tui.Bold("[FATAL] " + fmt.Sprintf(msg, args...)) - fmt.Println(val) - os.Exit(1) -} - -// Stack will return a new logger that logs to the given logger as well as the current logger -func (l *PendingLogger) Stack(next logger.Logger) logger.Logger { - return l -} diff --git a/internal/dev/server.go b/internal/dev/server.go index 7a8a8efc..d368ad35 100644 --- a/internal/dev/server.go +++ b/internal/dev/server.go @@ -21,6 +21,8 @@ import ( "github.com/agentuity/go-common/message" cstr "github.com/agentuity/go-common/string" "github.com/agentuity/go-common/telemetry" + "github.com/agentuity/go-common/tui" + "github.com/charmbracelet/lipgloss" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -57,7 +59,6 @@ type Server struct { publicUrl string port int connected chan error - pendingLogger logger.Logger expiresAt *time.Time tlsCertificate *tls.Certificate conn *tls.Conn @@ -568,12 +569,7 @@ func (s *Server) HealthCheck(devModeUrl string) error { return fmt.Errorf("health check failed after %s", time.Since(started)) } -func (s *Server) Connect(ui *DevModeUI, tuiLogger logger.Logger, tuiLoggerErr logger.Logger) error { - if pl, ok := s.logger.(*PendingLogger); ok { - pl.drain(ui, tuiLogger) - } - s.logger = tuiLogger - s.pendingLogger = s.logger +func (s *Server) Connect() error { err := <-s.connected close(s.connected) if err != nil { @@ -598,6 +594,45 @@ func (s *Server) monitor() { } } +var ( + logoColor = lipgloss.AdaptiveColor{Light: "#11c7b9", Dark: "#00FFFF"} + labelColor = lipgloss.AdaptiveColor{Light: "#999999", Dark: "#FFFFFF"} + runningColor = lipgloss.AdaptiveColor{Light: "#00FF00", Dark: "#009900"} + labelStyle = lipgloss.NewStyle().Foreground(labelColor).Bold(true) +) + +func label(s string) string { + return labelStyle.Render(tui.PadRight(s, 10, " ")) +} + +func (s *Server) GenerateInfoBox(publicUrl string, appUrl string, devModeUrl string) string { + var devmodeBox = lipgloss.NewStyle(). + Width(100). + Border(lipgloss.NormalBorder()). + BorderForeground(logoColor). + Padding(1, 2). + AlignVertical(lipgloss.Top). + AlignHorizontal(lipgloss.Left). + Foreground(labelColor) + + url := "loading..." + if publicUrl != "" { + url = tui.Link("%s", publicUrl) + " " + tui.Muted("(only accessible while running)") + } + + content := fmt.Sprintf(`%s + +%s %s +%s %s +%s %s`, + tui.Bold("⨺ Agentuity DevMode"), + label("DevMode"), tui.Link("%s", appUrl), + label("Local"), tui.Link("%s", devModeUrl), + label("Public"), url, + ) + return devmodeBox.Render(content) +} + func New(args ServerArgs) (*Server, error) { id := cstr.NewHash(args.Project.Project.ProjectId, args.UserId) tracer := otel.Tracer("@agentuity/cli", trace.WithInstrumentationAttributes( @@ -610,27 +645,24 @@ func New(args ServerArgs) (*Server, error) { attribute.String("version", args.Version), ), trace.WithInstrumentationVersion(args.Version)) - pendingLogger := NewPendingLogger(args.LogLevel) - ctx, cancel := context.WithCancel(args.Ctx) server := &Server{ - ID: id, - logger: pendingLogger, - ctx: ctx, - cancel: cancel, - apiurl: args.APIURL, - transportUrl: args.TransportURL, - apiKey: args.APIKey, - Project: args.Project, - orgId: args.OrgId, - userId: args.UserId, - tracer: tracer, - version: args.Version, - port: args.Port, - apiclient: util.NewAPIClient(ctx, pendingLogger, args.APIURL, args.APIKey), - pendingLogger: pendingLogger, - connected: make(chan error, 1), + ID: id, + logger: args.Logger, + ctx: ctx, + cancel: cancel, + apiurl: args.APIURL, + transportUrl: args.TransportURL, + apiKey: args.APIKey, + Project: args.Project, + orgId: args.OrgId, + userId: args.UserId, + tracer: tracer, + version: args.Version, + port: args.Port, + apiclient: util.NewAPIClient(ctx, args.Logger, args.APIURL, args.APIKey), + connected: make(chan error, 1), } go server.connect(true) diff --git a/internal/dev/tui.go b/internal/dev/tui.go deleted file mode 100644 index a7671d1d..00000000 --- a/internal/dev/tui.go +++ /dev/null @@ -1,611 +0,0 @@ -package dev - -import ( - "context" - "fmt" - "os" - "strings" - "sync" - "time" - - "github.com/agentuity/go-common/logger" - "github.com/agentuity/go-common/tui" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "golang.org/x/term" -) - -var ( - resumeKey = key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "resume"), key.WithDisabled()) - pauseKey = key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "pause")) - helpKey = key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "show help")) - 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"} - errorColor = lipgloss.AdaptiveColor{Light: "#FF0000", Dark: "#EE0000"} - warningColor = lipgloss.AdaptiveColor{Light: "#FFFF00", Dark: "#FFFF00"} - errorStyle = lipgloss.NewStyle().Foreground(errorColor) - warningStyle = lipgloss.NewStyle().Foreground(warningColor) - runningStyle = lipgloss.NewStyle().Foreground(runningColor) - pausedStyle = lipgloss.NewStyle().Foreground(pausedColor).AlignHorizontal(lipgloss.Center) - labelStyle = lipgloss.NewStyle().Foreground(labelColor).Bold(true) - statusMsgStyle = lipgloss.NewStyle().Foreground(statusColor).Margin(0) - viewPortHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#999999"}).Background(lipgloss.AdaptiveColor{Light: "#999999", Dark: "#222222"}).AlignHorizontal(lipgloss.Left).MarginTop(1) - statusMsgHeight = 2 -) - -type model struct { - infoBox string - statusMessage string - logList list.Model - logItems []list.Item - windowSize tea.WindowSizeMsg - viewport *viewport.Model - paused bool - showhelp bool - showagents bool - agents []*Agent - selectedLog *logItem - spinner spinner.Model - spinning bool - devModeUrl string - publicUrl string - appUrl string -} - -type spinnerStartMsg struct{} -type spinnerStopMsg struct{} - -type logItem struct { - timestamp time.Time - severity logger.LogLevel - message string - raw string -} - -func (i logItem) Title() string { - switch i.severity { - case logger.LevelError: - return errorStyle.Render(i.message) - case logger.LevelDebug, logger.LevelTrace: - return tui.Muted(i.message) - case logger.LevelInfo: - return i.message - case logger.LevelWarn: - return warningStyle.Render(i.message) - default: - return i.message - } -} -func (i logItem) Description() string { return "" } -func (i logItem) FilterValue() string { return i.message } - -type tickMsg time.Time -type addLogMsg logItem -type statusMessageMsg string - -func tick() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} - -func initialModel(config DevModeConfig) *model { - - width, height, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - fmt.Println("Error getting terminal size:", err) - } - - spinner := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(statusMsgStyle.MarginLeft(1).MarginRight(0))) - - items := []list.Item{} - - listDelegate := list.NewDefaultDelegate() - listDelegate.ShowDescription = false - listDelegate.SetSpacing(0) - listDelegate.Styles.NormalTitle = listDelegate.Styles.NormalTitle.Padding(0, 1) - listDelegate.Styles.SelectedTitle = listDelegate.Styles.SelectedTitle.BorderLeft(false).Foreground(selectedColor).Bold(true) - - l := list.New(items, listDelegate, width-2, 10) - l.SetShowTitle(false) - l.SetShowStatusBar(false) - l.SetShowPagination(true) - l.SetFilteringEnabled(true) - l.SetShowHelp(true) - l.SetStatusBarItemName("log", "logs") - l.Styles.NoItems = l.Styles.NoItems.MarginLeft(1) - l.Styles.HelpStyle = l.Styles.HelpStyle.AlignHorizontal(lipgloss.Center).Width(width) - - l.AdditionalShortHelpKeys = func() []key.Binding { - return []key.Binding{ - resumeKey, - pauseKey, - helpKey, - agentsKey, - } - } - l.AdditionalFullHelpKeys = func() []key.Binding { - return []key.Binding{ - resumeKey, - pauseKey, - helpKey, - agentsKey, - } - } - - m := &model{ - logList: l, - logItems: items, - spinner: spinner, - windowSize: tea.WindowSizeMsg{Width: width, Height: height}, - devModeUrl: config.DevModeUrl, - publicUrl: config.PublicUrl, - appUrl: config.AppUrl, - agents: config.Agents, - } - - m.infoBox = m.generateInfoBox() - - infoBoxHeight := lipgloss.Height(m.infoBox) - available := height - infoBoxHeight - statusMsgHeight - if available < 1 { - available = 1 - } - m.logList.SetHeight(available) - - return m -} - -func (m *model) Init() tea.Cmd { - return tick() -} - -func label(s string) string { - return labelStyle.Render(tui.PadRight(s, 10, " ")) -} - -func generateInfoBox(width int, showPause bool, paused bool, publicUrl string, appUrl string, devModeUrl string) string { - var statusStyle = runningStyle - if paused { - statusStyle = pausedStyle - } - var devmodeBox = lipgloss.NewStyle(). - Width(width-2). - Border(lipgloss.NormalBorder()). - BorderForeground(logoColor). - Padding(1, 2). - AlignVertical(lipgloss.Top). - AlignHorizontal(lipgloss.Left). - Foreground(labelColor) - - url := "loading..." - 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 - -%s %s -%s %s -%s %s`, - 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 - - switch msg := msg.(type) { - case spinnerStartMsg: - m.spinning = true - break - case spinnerStopMsg: - m.spinning = false - break - case spinner.TickMsg: - sm, c := m.spinner.Update(msg) - m.spinner = sm - cmd = append(cmd, c) - 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 - return m, nil - } - if m.showagents { - m.showagents = false - m.viewport = nil - return m, nil - } - if m.selectedLog != nil { - 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 { - if log, ok := sel.(logItem); ok { - m.selectedLog = &log - break - } - } - } - if msg.Type == tea.KeyRunes { - switch msg.String() { - case "p": - m.paused = true - resumeKey.SetEnabled(true) - pauseKey.SetEnabled(false) - case "r": - m.paused = false - resumeKey.SetEnabled(false) - pauseKey.SetEnabled(true) - case "h": - m.showhelp = true - case "a": - m.showagents = true - } - m.infoBox = m.generateInfoBox() - } - if m.viewport != nil { - vp, vpCmd := m.viewport.Update(msg) - m.viewport = &vp - cmd = append(cmd, vpCmd) - } - case tea.WindowSizeMsg: - m.windowSize = msg - // Calculate the height for the info box - infoBoxHeight := lipgloss.Height(m.infoBox) - available := msg.Height - infoBoxHeight - statusMsgHeight - if available < 1 { - available = 1 - } - m.logList.SetHeight(available) - m.logList.SetWidth(m.windowSize.Width - 2) - break - case tickMsg: - m.infoBox = m.generateInfoBox() - cmd = append(cmd, tick()) - break - case addLogMsg: - 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: - m.statusMessage = string(msg) - break - } - - var lcmd tea.Cmd - m.logList, lcmd = m.logList.Update(msg) - cmd = append(cmd, lcmd) - return m, tea.Batch(cmd...) -} - -func (m *model) View() string { - - var showModal bool - var modalContent string - modalWidth := m.windowSize.Width / 2 - modalHeight := m.windowSize.Height / 2 - if modalWidth < 40 { - modalWidth = 40 - } - if modalHeight < 10 { - modalHeight = 10 - } - - if m.showhelp { - showModal = true - modalContent = lipgloss.JoinVertical( - lipgloss.Left, - tui.Bold("⨺ Agentuity DevMode"), - "", - tui.Secondary("When your project is running in DevMode, you can interact with it by sending messages to your local agents."), - "", "", - tui.Secondary("The best way to do this is to open the Agentuity console in your browser:"), - "", - tui.Link("%s", m.appUrl), - "", "", - tui.Secondary("You can also use curl or wget to send messages to the local agent."), - "", - tui.Secondary(fmt.Sprintf("To send a message to the local agent %s, use the following command:", m.agents[0].Name)), - "", "", - tui.Highlight(fmt.Sprintf("curl %s --json '{\"message\": \"Hello, world!\"}'", m.agents[0].LocalURL)), - "", - tui.Secondary(fmt.Sprintf("To send a message to the local agent %s from a remote machine, use the following command:", m.agents[0].Name)), - "", "", - tui.Highlight(fmt.Sprintf("curl %s --json '{\"message\": \"Hello, world!\"}'", m.agents[0].PublicURL)), - "", - tui.Muted("Note: The public URL is only accessible in devmode and has no authentication while in devmode. This this URL carefully."), - "", "", - tui.Warning("To get help or share your feedback, join our Discord community:"), - "", - tui.Link("https://discord.gg/agentuity"), - "", - ) - } else if m.selectedLog != nil { - showModal = true - 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" - var agentsContent string - modalWidth = int(float64(m.windowSize.Width) * 0.9) - for _, agent := range m.agents { - agentsContent += fmt.Sprintf("%s %s\n", tui.PadRight("ID", 10, " "), tui.Muted(agent.ID)) - agentsContent += fmt.Sprintf("%s %s\n", tui.PadRight("Agent", 10, " "), tui.Title(agent.Name)) - agentsContent += fmt.Sprintf("%s %s\n", tui.PadRight("Local", 10, " "), tui.Link("%s", agent.LocalURL)) - agentsContent += fmt.Sprintf("%s %s\n", tui.PadRight("Public", 10, " "), tui.Link("%s", agent.PublicURL)) - agentsContent += "\n" - } - modalContent = agentsContent - } - - if showModal { - modal := lipgloss.NewStyle().Padding(2) - if m.viewport == nil { - vp := viewport.New(m.windowSize.Width, m.windowSize.Height-1) - vp.SetYOffset(1) - m.viewport = &vp - } - m.viewport.SetContent(modal.Render(modalContent)) - m.viewport.Width = m.windowSize.Width - esc := "ESC to close" - pct := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) - spacer := m.windowSize.Width - lipgloss.Width(esc) - lipgloss.Width(pct) + 3 - right := lipgloss.NewStyle().AlignHorizontal(lipgloss.Right).Width(spacer).Render(pct) - return m.viewport.View() + "\n" + viewPortHelpStyle.Width(m.windowSize.Width).Render(lipgloss.JoinHorizontal(lipgloss.Left, esc, right)) - } - - var view string - - if m.spinning { - view = m.spinner.View() + " " - } else { - view = " " - } - - return fmt.Sprintf("%s\n%s\n%s", m.infoBox, view+statusMsgStyle.Render(m.statusMessage), m.logList.View()) -} - -type Agent struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - LocalURL string `json:"local_url,omitempty"` - PublicURL string `json:"public_url,omitempty"` -} - -type DevModeUI struct { - ctx context.Context - cancel context.CancelFunc - model *model - program *tea.Program - wg sync.WaitGroup - once sync.Once - - spinnerCtx context.Context - spinnerCancel context.CancelFunc - aborting bool - enabled bool - publicUrl string - devModeUrl string - appUrl string -} - -type DevModeConfig struct { - DevModeUrl string - PublicUrl string - AppUrl string - 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: model, - enabled: enabled, - publicUrl: config.PublicUrl, - devModeUrl: config.DevModeUrl, - appUrl: config.AppUrl, - } -} - -func (d *DevModeUI) SetPublicURL(url string) { - d.publicUrl = url - if d.model != nil { - d.model.publicUrl = url - } - if !d.enabled { - 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)) - } -} - -// Done returns a channel that will be closed when the program is done -func (d *DevModeUI) Done() <-chan struct{} { - return d.ctx.Done() -} - -// Close the program which will stop the program and wait for it to exit -func (d *DevModeUI) Close(abort bool) { - d.once.Do(func() { - d.aborting = abort - if d.enabled { - d.program.Quit() - } else { - d.cancel() - } - <-d.Done() - 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 - } - d.program = tea.NewProgram( - d.model, - tea.WithoutSignalHandler(), - ) - d.wg.Add(1) - go func() { - defer func() { - d.cancel() - d.wg.Done() - }() - _, err := d.program.Run() - if err != nil { - fmt.Printf("Error running program: %v\n", err) - } - }() -} - -// Add a log message to the log list -func (d *DevModeUI) AddLog(severity logger.LogLevel, log string, args ...any) { - if !d.enabled { - fmt.Println(fmt.Sprintf(log, args...)) - return - } - raw := fmt.Sprintf(log, args...) - d.program.Send(addLogMsg{ - timestamp: time.Now(), - severity: severity, - raw: raw, - message: strings.ReplaceAll(ansiColorStripper.ReplaceAllString(raw, ""), "\n", " "), - }) -} - -// Add an error log message to the log list -func (d *DevModeUI) AddErrorLog(log string, args ...any) { - if !d.enabled { - fmt.Println(errorStyle.Render(fmt.Sprintf(log, args...))) - return - } - raw := fmt.Sprintf(log, args...) - d.program.Send(addLogMsg{ - timestamp: time.Now(), - severity: logger.LevelError, - raw: raw, - message: strings.ReplaceAll(ansiColorStripper.ReplaceAllString(raw, ""), "\n", " "), - }) -} - -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() { - select { - case <-time.After(time.Second * 3): - if val == d.model.statusMessage { - d.program.Send(statusMessageMsg("")) - } - case <-d.ctx.Done(): - return - } - }() - } -} - -func (d *DevModeUI) ShowSpinner(msg string, fn func()) { - if !d.enabled { - fn() - return - } - d.SetSpinner(true) - d.SetStatusMessage("%s", msg) - fn() - d.SetStatusMessage("") - d.SetSpinner(false) -} - -func (d *DevModeUI) SetSpinner(spinning bool) { - if !d.enabled { - return - } - if spinning { - d.program.Send(spinnerStartMsg{}) - ctx, cancel := context.WithCancel(d.ctx) - d.spinnerCtx = ctx - d.spinnerCancel = cancel - go func() { - t := time.NewTicker(time.Millisecond * 200) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - d.program.Send(d.model.spinner.Tick()) - } - } - }() - } else if d.spinnerCtx != nil { - d.spinnerCancel() - d.spinnerCtx = nil - d.program.Send(spinnerStopMsg{}) - } -} diff --git a/internal/dev/tui_logger.go b/internal/dev/tui_logger.go deleted file mode 100644 index fa1444af..00000000 --- a/internal/dev/tui_logger.go +++ /dev/null @@ -1,167 +0,0 @@ -package dev - -import ( - "bytes" - "context" - "fmt" - "io" - "os" - "regexp" - "strings" - "sync" - - "github.com/agentuity/go-common/logger" - "github.com/agentuity/go-common/tui" -) - -type ioType int - -const ( - StdErr ioType = iota - Stdout -) - -type TuiLogger struct { - logLevel logger.LogLevel - ui *DevModeUI - ioType ioType - pending bytes.Buffer - mu sync.Mutex -} - -func NewTUILogger(logLevel logger.LogLevel, ui *DevModeUI, ioType ioType) *TuiLogger { - return &TuiLogger{logLevel: logLevel, ui: ui, ioType: ioType} -} - -var _ logger.Logger = (*TuiLogger)(nil) -var _ io.Writer = (*TuiLogger)(nil) - -// With will return a new logger using metadata as the base context -func (l *TuiLogger) With(metadata map[string]interface{}) logger.Logger { - return l -} - -// WithPrefix will return a new logger with a prefix prepended to the message -func (l *TuiLogger) WithPrefix(prefix string) logger.Logger { - return l -} - -// WithContext will return a new logger with the given context -func (l *TuiLogger) WithContext(ctx context.Context) logger.Logger { - return l -} - -// Trace level logging -func (l *TuiLogger) Trace(msg string, args ...interface{}) { - if logger.LevelTrace < l.logLevel { - return - } - l.ui.AddLog(logger.LevelTrace, "[TRACE] %s", fmt.Sprintf(msg, args...)) -} - -// Debug level logging -func (l *TuiLogger) Debug(msg string, args ...interface{}) { - if logger.LevelDebug < l.logLevel { - return - } - l.ui.AddLog(logger.LevelDebug, "[DEBUG] %s", fmt.Sprintf(msg, args...)) -} - -// Info level loggi ng -func (l *TuiLogger) Info(msg string, args ...interface{}) { - if logger.LevelInfo < l.logLevel { - return - } - l.ui.AddLog(logger.LevelInfo, "[INFO] %s", fmt.Sprintf(msg, args...)) -} - -// Warning level logging -func (l *TuiLogger) Warn(msg string, args ...interface{}) { - if logger.LevelWarn < l.logLevel { - return - } - l.ui.AddLog(logger.LevelWarn, "[WARN] %s", fmt.Sprintf(msg, args...)) -} - -// Error level logging -func (l *TuiLogger) Error(msg string, args ...interface{}) { - if logger.LevelError < l.logLevel { - return - } - l.ui.AddLog(logger.LevelError, "[ERROR] %s", fmt.Sprintf(msg, args...)) -} - -// Fatal level logging and exit with code 1 -func (l *TuiLogger) Fatal(msg string, args ...interface{}) { - val := tui.Bold("[FATAL] " + fmt.Sprintf(msg, args...)) - l.ui.AddLog(logger.LevelError, "%s", val) - os.Exit(1) -} - -// Stack will return a new logger that logs to the given logger as well as the current logger -func (l *TuiLogger) Stack(next logger.Logger) logger.Logger { - return l -} - -var eol = []byte("\n") -var ansiColorStripper = regexp.MustCompile("\x1b\\[[0-9;]*[mK]") - -// Map prefix to severity level -var prefixToLevel = map[string]logger.LogLevel{ - "[TRACE]": logger.LevelTrace, - "[DEBUG]": logger.LevelDebug, - "[INFO]": logger.LevelInfo, - "[INFO ]": logger.LevelInfo, // python - "[WARNI]": logger.LevelWarn, // python - "[WARN]": logger.LevelWarn, - "[ERROR]": logger.LevelError, - "[FATAL]": logger.LevelError, -} - -func (l *TuiLogger) Write(p []byte) (n int, err error) { - l.mu.Lock() - defer l.mu.Unlock() - l.pending.Write(p) - - if !bytes.HasSuffix(l.pending.Bytes(), eol) { - return len(p), nil - } - - trimmed := bytes.Split(l.pending.Bytes(), eol) - l.pending.Reset() - - for _, line := range trimmed { - if len(line) == 0 { - continue - } - log := ansiColorStripper.ReplaceAllString(string(line), "") - severity := logger.LevelTrace - var prefix string - if len(log) > 9 { - bracket := strings.Index(log, "] ") - if bracket == -1 { - // No prefix – treat the entire line as the message - prefix = "" - } else { - prefix = strings.TrimSpace(log[:bracket+2]) - log = strings.TrimPrefix(log[bracket+2:], " ") - } - // Find matching prefix - for p, level := range prefixToLevel { - if strings.HasPrefix(prefix, p) { - severity = level - if level < l.logLevel { - continue - } - break - } - } - } - if l.ioType == Stdout { - l.ui.AddLog(severity, "%s %s", prefix, log) - } else { - l.ui.AddErrorLog("%s", log) - } - } - return len(p), nil -} From 33020146f2df50f9dc5c4d075d2923450f8ba194 Mon Sep 17 00:00:00 2001 From: Jeff Haynie Date: Fri, 23 May 2025 14:53:50 -0500 Subject: [PATCH 2/2] Update internal/dev/server.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/dev/server.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/dev/server.go b/internal/dev/server.go index d368ad35..eb71a294 100644 --- a/internal/dev/server.go +++ b/internal/dev/server.go @@ -597,10 +597,8 @@ func (s *Server) monitor() { var ( logoColor = lipgloss.AdaptiveColor{Light: "#11c7b9", Dark: "#00FFFF"} labelColor = lipgloss.AdaptiveColor{Light: "#999999", Dark: "#FFFFFF"} - runningColor = lipgloss.AdaptiveColor{Light: "#00FF00", Dark: "#009900"} labelStyle = lipgloss.NewStyle().Foreground(labelColor).Bold(true) ) - func label(s string) string { return labelStyle.Render(tui.PadRight(s, 10, " ")) }