From b00326a75a7449f43be6790dfcb08fc970c044cd Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:44:46 -0500 Subject: wip: refactoring tui --- packages/tui/cmd/opencode/main.go | 212 ++++++++++++++++++++++++++++++++ packages/tui/cmd/root.go | 247 -------------------------------------- 2 files changed, 212 insertions(+), 247 deletions(-) create mode 100644 packages/tui/cmd/opencode/main.go delete mode 100644 packages/tui/cmd/root.go (limited to 'packages/tui/cmd') diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go new file mode 100644 index 000000000..0128f41be --- /dev/null +++ b/packages/tui/cmd/opencode/main.go @@ -0,0 +1,212 @@ +package main + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + zone "github.com/lrstanley/bubblezone" + "github.com/sst/opencode/internal/pubsub" + "github.com/sst/opencode/internal/tui" + "github.com/sst/opencode/internal/tui/app" + "github.com/sst/opencode/pkg/client" +) + +func main() { + url := "http://localhost:16713" + httpClient, err := client.NewClientWithResponses(url) + if err != nil { + slog.Error("Failed to create client", "error", err) + os.Exit(1) + } + paths, _ := httpClient.PostPathGetWithResponse(context.Background()) + logfile := filepath.Join(paths.JSON200.Data, "log", "tui.log") + + if _, err := os.Stat(filepath.Dir(logfile)); os.IsNotExist(err) { + err := os.MkdirAll(filepath.Dir(logfile), 0755) + if err != nil { + slog.Error("Failed to create log directory", "error", err) + os.Exit(1) + } + } + file, err := os.Create(logfile) + if err != nil { + slog.Error("Failed to create log file", "error", err) + os.Exit(1) + } + defer file.Close() + logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug})) + slog.SetDefault(logger) + + // Create main context for the application + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + app, err := app.New(ctx, httpClient) + if err != nil { + slog.Error("Failed to create app", "error", err) + // return err + } + + // Set up the TUI + zone.NewGlobal() + program := tea.NewProgram( + tui.New(app), + tea.WithAltScreen(), + ) + + eventClient, err := client.NewClient(url) + if err != nil { + slog.Error("Failed to create event client", "error", err) + os.Exit(1) + } + + evts, err := eventClient.Event(ctx) + if err != nil { + slog.Error("Failed to subscribe to events", "error", err) + os.Exit(1) + } + + go func() { + for item := range evts { + program.Send(item) + } + }() + + // Setup the subscriptions, this will send services events to the TUI + ch, cancelSubs := setupSubscriptions(app, ctx) + + // Create a context for the TUI message handler + tuiCtx, tuiCancel := context.WithCancel(ctx) + var tuiWg sync.WaitGroup + tuiWg.Add(1) + + // Set up message handling for the TUI + go func() { + defer tuiWg.Done() + // defer logging.RecoverPanic("TUI-message-handler", func() { + // attemptTUIRecovery(program) + // }) + + for { + select { + case <-tuiCtx.Done(): + slog.Info("TUI message handler shutting down") + return + case msg, ok := <-ch: + if !ok { + slog.Info("TUI message channel closed") + return + } + program.Send(msg) + } + } + }() + + // Cleanup function for when the program exits + cleanup := func() { + // Cancel subscriptions first + cancelSubs() + + // Then shutdown the app + app.Shutdown() + + // Then cancel TUI message handler + tuiCancel() + + // Wait for TUI message handler to finish + tuiWg.Wait() + + slog.Info("All goroutines cleaned up") + } + + // Run the TUI + result, err := program.Run() + cleanup() + + if err != nil { + slog.Error("TUI error", "error", err) + // return fmt.Errorf("TUI error: %v", err) + } + + slog.Info("TUI exited", "result", result) +} + +func setupSubscriber[T any]( + ctx context.Context, + wg *sync.WaitGroup, + name string, + subscriber func(context.Context) <-chan pubsub.Event[T], + outputCh chan<- tea.Msg, +) { + wg.Add(1) + go func() { + defer wg.Done() + // defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil) + + subCh := subscriber(ctx) + if subCh == nil { + slog.Warn("subscription channel is nil", "name", name) + return + } + + for { + select { + case event, ok := <-subCh: + if !ok { + slog.Info("subscription channel closed", "name", name) + return + } + + var msg tea.Msg = event + + select { + case outputCh <- msg: + case <-time.After(2 * time.Second): + slog.Warn("message dropped due to slow consumer", "name", name) + case <-ctx.Done(): + slog.Info("subscription cancelled", "name", name) + return + } + case <-ctx.Done(): + slog.Info("subscription cancelled", "name", name) + return + } + } + }() +} + +func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) { + ch := make(chan tea.Msg, 100) + + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context + + setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch) + + cleanupFunc := func() { + slog.Info("Cancelling all subscriptions") + cancel() // Signal all goroutines to stop + + waitCh := make(chan struct{}) + go func() { + // defer logging.RecoverPanic("subscription-cleanup", nil) + wg.Wait() + close(waitCh) + }() + + select { + case <-waitCh: + slog.Info("All subscription goroutines completed successfully") + close(ch) // Only close after all writers are confirmed done + case <-time.After(5 * time.Second): + slog.Warn("Timed out waiting for some subscription goroutines to complete") + close(ch) + } + } + return ch, cleanupFunc +} diff --git a/packages/tui/cmd/root.go b/packages/tui/cmd/root.go deleted file mode 100644 index d952fc3a3..000000000 --- a/packages/tui/cmd/root.go +++ /dev/null @@ -1,247 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "sync" - "time" - - "log/slog" - - tea "github.com/charmbracelet/bubbletea" - zone "github.com/lrstanley/bubblezone" - "github.com/spf13/cobra" - "github.com/sst/opencode/internal/config" - "github.com/sst/opencode/internal/pubsub" - "github.com/sst/opencode/internal/tui" - "github.com/sst/opencode/internal/tui/app" -) - -var rootCmd = &cobra.Command{ - Use: "OpenCode", - Short: "A terminal AI assistant for software development", - Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks. -It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration -to assist developers in writing, debugging, and understanding code directly from the terminal.`, - RunE: func(cmd *cobra.Command, args []string) error { - // Setup logging - // file, err := os.OpenFile("debug.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) - // if err != nil { - // panic(err) - // } - // defer file.Close() - // logger := slog.New(slog.NewTextHandler(file, &slog.HandlerOptions{Level: slog.LevelDebug})) - // slog.SetDefault(logger) - - // Load the config - debug, _ := cmd.Flags().GetBool("debug") - cwd, _ := cmd.Flags().GetString("cwd") - if cwd != "" { - err := os.Chdir(cwd) - if err != nil { - return fmt.Errorf("failed to change directory: %v", err) - } - } - if cwd == "" { - c, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %v", err) - } - cwd = c - } - _, err := config.Load(cwd, debug) - if err != nil { - return err - } - - // Create main context for the application - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - app, err := app.New(ctx) - if err != nil { - slog.Error("Failed to create app", "error", err) - return err - } - - // Set up the TUI - zone.NewGlobal() - program := tea.NewProgram( - tui.New(app), - tea.WithAltScreen(), - ) - - evts, err := app.Events.Event(ctx) - if err != nil { - slog.Error("Failed to subscribe to events", "error", err) - return err - } - - go func() { - for item := range evts { - program.Send(item) - } - }() - - // Setup the subscriptions, this will send services events to the TUI - ch, cancelSubs := setupSubscriptions(app, ctx) - - // Create a context for the TUI message handler - tuiCtx, tuiCancel := context.WithCancel(ctx) - var tuiWg sync.WaitGroup - tuiWg.Add(1) - - // Set up message handling for the TUI - go func() { - defer tuiWg.Done() - // defer logging.RecoverPanic("TUI-message-handler", func() { - // attemptTUIRecovery(program) - // }) - - for { - select { - case <-tuiCtx.Done(): - slog.Info("TUI message handler shutting down") - return - case msg, ok := <-ch: - if !ok { - slog.Info("TUI message channel closed") - return - } - program.Send(msg) - } - } - }() - - // Cleanup function for when the program exits - cleanup := func() { - // Cancel subscriptions first - cancelSubs() - - // Then shutdown the app - app.Shutdown() - - // Then cancel TUI message handler - tuiCancel() - - // Wait for TUI message handler to finish - tuiWg.Wait() - - slog.Info("All goroutines cleaned up") - } - - // Run the TUI - result, err := program.Run() - cleanup() - - if err != nil { - slog.Error("TUI error", "error", err) - return fmt.Errorf("TUI error: %v", err) - } - - slog.Info("TUI exited", "result", result) - return nil - }, -} - -func setupSubscriber[T any]( - ctx context.Context, - wg *sync.WaitGroup, - name string, - subscriber func(context.Context) <-chan pubsub.Event[T], - outputCh chan<- tea.Msg, -) { - wg.Add(1) - go func() { - defer wg.Done() - // defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil) - - subCh := subscriber(ctx) - if subCh == nil { - slog.Warn("subscription channel is nil", "name", name) - return - } - - for { - select { - case event, ok := <-subCh: - if !ok { - slog.Info("subscription channel closed", "name", name) - return - } - - var msg tea.Msg = event - - select { - case outputCh <- msg: - case <-time.After(2 * time.Second): - slog.Warn("message dropped due to slow consumer", "name", name) - case <-ctx.Done(): - slog.Info("subscription cancelled", "name", name) - return - } - case <-ctx.Done(): - slog.Info("subscription cancelled", "name", name) - return - } - } - }() -} - -func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) { - ch := make(chan tea.Msg, 100) - - wg := sync.WaitGroup{} - ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context - - setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch) - - cleanupFunc := func() { - slog.Info("Cancelling all subscriptions") - cancel() // Signal all goroutines to stop - - waitCh := make(chan struct{}) - go func() { - // defer logging.RecoverPanic("subscription-cleanup", nil) - wg.Wait() - close(waitCh) - }() - - select { - case <-waitCh: - slog.Info("All subscription goroutines completed successfully") - close(ch) // Only close after all writers are confirmed done - case <-time.After(5 * time.Second): - slog.Warn("Timed out waiting for some subscription goroutines to complete") - close(ch) - } - } - return ch, cleanupFunc -} - -func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func init() { - rootCmd.Flags().BoolP("help", "h", false, "Help") - rootCmd.Flags().BoolP("version", "v", false, "Version") - rootCmd.Flags().BoolP("debug", "d", false, "Debug") - rootCmd.Flags().StringP("cwd", "c", "", "Current working directory") - rootCmd.Flags().StringP("prompt", "p", "", "Run a single prompt in non-interactive mode") - rootCmd.Flags().StringP("output-format", "f", "text", "Output format for non-interactive mode (text, json)") - rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode") - rootCmd.Flags().BoolP("verbose", "", false, "Display logs to stderr in non-interactive mode") - rootCmd.Flags().StringSlice("allowedTools", nil, "Restrict the agent to only use the specified tools in non-interactive mode (comma-separated list)") - rootCmd.Flags().StringSlice("excludedTools", nil, "Prevent the agent from using the specified tools in non-interactive mode (comma-separated list)") - - // Make allowedTools and excludedTools mutually exclusive - rootCmd.MarkFlagsMutuallyExclusive("allowedTools", "excludedTools") - - // Make quiet and verbose mutually exclusive - rootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose") -} -- cgit v1.2.3