mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-02 07:25:00 +01:00
wip: refactoring tui
This commit is contained in:
@@ -1,292 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
charmlog "github.com/charmbracelet/log"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/db"
|
||||
"github.com/sst/opencode/internal/format"
|
||||
"github.com/sst/opencode/internal/llm/agent"
|
||||
"github.com/sst/opencode/internal/llm/tools"
|
||||
"github.com/sst/opencode/internal/message"
|
||||
"github.com/sst/opencode/internal/permission"
|
||||
"github.com/sst/opencode/internal/tui/components/spinner"
|
||||
"github.com/sst/opencode/internal/tui/theme"
|
||||
)
|
||||
|
||||
// syncWriter is a thread-safe writer that prevents interleaved output
|
||||
type syncWriter struct {
|
||||
w io.Writer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Write implements io.Writer
|
||||
func (sw *syncWriter) Write(p []byte) (n int, err error) {
|
||||
sw.mu.Lock()
|
||||
defer sw.mu.Unlock()
|
||||
return sw.w.Write(p)
|
||||
}
|
||||
|
||||
// newSyncWriter creates a new synchronized writer
|
||||
func newSyncWriter(w io.Writer) io.Writer {
|
||||
return &syncWriter{w: w}
|
||||
}
|
||||
|
||||
// filterTools filters the provided tools based on allowed or excluded tool names
|
||||
func filterTools(allTools []tools.BaseTool, allowedTools, excludedTools []string) []tools.BaseTool {
|
||||
// If neither allowed nor excluded tools are specified, return all tools
|
||||
if len(allowedTools) == 0 && len(excludedTools) == 0 {
|
||||
return allTools
|
||||
}
|
||||
|
||||
// Create a map for faster lookups
|
||||
allowedMap := make(map[string]bool)
|
||||
for _, name := range allowedTools {
|
||||
allowedMap[name] = true
|
||||
}
|
||||
|
||||
excludedMap := make(map[string]bool)
|
||||
for _, name := range excludedTools {
|
||||
excludedMap[name] = true
|
||||
}
|
||||
|
||||
var filteredTools []tools.BaseTool
|
||||
|
||||
for _, tool := range allTools {
|
||||
toolName := tool.Info().Name
|
||||
|
||||
// If we have an allowed list, only include tools in that list
|
||||
if len(allowedTools) > 0 {
|
||||
if allowedMap[toolName] {
|
||||
filteredTools = append(filteredTools, tool)
|
||||
}
|
||||
} else if len(excludedTools) > 0 {
|
||||
// If we have an excluded list, include all tools except those in the list
|
||||
if !excludedMap[toolName] {
|
||||
filteredTools = append(filteredTools, tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredTools
|
||||
}
|
||||
|
||||
// handleNonInteractiveMode processes a single prompt in non-interactive mode
|
||||
func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool, verbose bool, allowedTools, excludedTools []string) error {
|
||||
// Initial log message using standard slog
|
||||
slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet, "verbose", verbose,
|
||||
"allowedTools", allowedTools, "excludedTools", excludedTools)
|
||||
|
||||
// Sanity check for mutually exclusive flags
|
||||
if quiet && verbose {
|
||||
return fmt.Errorf("--quiet and --verbose flags cannot be used together")
|
||||
}
|
||||
|
||||
// Set up logging to stderr if verbose mode is enabled
|
||||
if verbose {
|
||||
// Create a synchronized writer to prevent interleaved output
|
||||
syncWriter := newSyncWriter(os.Stderr)
|
||||
|
||||
// Create a charmbracelet/log logger that writes to the synchronized writer
|
||||
charmLogger := charmlog.NewWithOptions(syncWriter, charmlog.Options{
|
||||
Level: charmlog.DebugLevel,
|
||||
ReportCaller: true,
|
||||
ReportTimestamp: true,
|
||||
TimeFormat: time.RFC3339,
|
||||
Prefix: "OpenCode",
|
||||
})
|
||||
|
||||
// Set the global logger for charmbracelet/log
|
||||
charmlog.SetDefault(charmLogger)
|
||||
|
||||
// Create a slog handler that uses charmbracelet/log
|
||||
// This will forward all slog logs to charmbracelet/log
|
||||
slog.SetDefault(slog.New(charmLogger))
|
||||
|
||||
// Log a message to confirm verbose logging is enabled
|
||||
charmLogger.Info("Verbose logging enabled")
|
||||
}
|
||||
|
||||
// Start spinner if not in quiet mode
|
||||
var s *spinner.Spinner
|
||||
if !quiet {
|
||||
// Get the current theme to style the spinner
|
||||
currentTheme := theme.CurrentTheme()
|
||||
|
||||
// Create a themed spinner
|
||||
if currentTheme != nil {
|
||||
// Use the primary color from the theme
|
||||
s = spinner.NewThemedSpinner("Thinking...", currentTheme.Primary())
|
||||
} else {
|
||||
// Fallback to default spinner if no theme is available
|
||||
s = spinner.NewSpinner("Thinking...")
|
||||
}
|
||||
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
}
|
||||
|
||||
// Connect DB, this will also run migrations
|
||||
conn, err := db.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a context with cancellation
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Create the app
|
||||
app, err := app.New(ctx, conn)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create app", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a new session for this prompt
|
||||
session, err := app.Sessions.Create(ctx, "Non-interactive prompt")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
// Set the session as current
|
||||
app.CurrentSession = &session
|
||||
|
||||
// Auto-approve all permissions for this session
|
||||
permission.AutoApproveSession(ctx, session.ID)
|
||||
|
||||
// Create the user message
|
||||
_, err = app.Messages.Create(ctx, session.ID, message.CreateMessageParams{
|
||||
Role: message.User,
|
||||
Parts: []message.ContentPart{message.TextContent{Text: prompt}},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create message: %w", err)
|
||||
}
|
||||
|
||||
// If tool restrictions are specified, create a new agent with filtered tools
|
||||
if len(allowedTools) > 0 || len(excludedTools) > 0 {
|
||||
// Initialize MCP tools synchronously to ensure they're included in filtering
|
||||
mcpCtx, mcpCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
agent.GetMcpTools(mcpCtx, app.Permissions)
|
||||
mcpCancel()
|
||||
|
||||
// Get all available tools including MCP tools
|
||||
allTools := agent.PrimaryAgentTools(
|
||||
app.Permissions,
|
||||
app.Sessions,
|
||||
app.Messages,
|
||||
app.History,
|
||||
app.LSPClients,
|
||||
)
|
||||
|
||||
// Filter tools based on allowed/excluded lists
|
||||
filteredTools := filterTools(allTools, allowedTools, excludedTools)
|
||||
|
||||
// Log the filtered tools for debugging
|
||||
var toolNames []string
|
||||
for _, tool := range filteredTools {
|
||||
toolNames = append(toolNames, tool.Info().Name)
|
||||
}
|
||||
slog.Debug("Using filtered tools", "count", len(filteredTools), "tools", toolNames)
|
||||
|
||||
// Create a new agent with the filtered tools
|
||||
restrictedAgent, err := agent.NewAgent(
|
||||
config.AgentPrimary,
|
||||
app.Sessions,
|
||||
app.Messages,
|
||||
filteredTools,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create restricted agent: %w", err)
|
||||
}
|
||||
|
||||
// Use the restricted agent for this request
|
||||
eventCh, err := restrictedAgent.Run(ctx, session.ID, prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run restricted agent: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the response
|
||||
var response message.Message
|
||||
for event := range eventCh {
|
||||
if event.Err() != nil {
|
||||
return fmt.Errorf("agent error: %w", event.Err())
|
||||
}
|
||||
response = event.Response()
|
||||
}
|
||||
|
||||
// Format and print the output
|
||||
content := ""
|
||||
if textContent := response.Content(); textContent != nil {
|
||||
content = textContent.Text
|
||||
}
|
||||
|
||||
formattedOutput, err := format.FormatOutput(content, outputFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format output: %w", err)
|
||||
}
|
||||
|
||||
// Stop spinner before printing output
|
||||
if !quiet && s != nil {
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
// Print the formatted output to stdout
|
||||
fmt.Println(formattedOutput)
|
||||
|
||||
// Shutdown the app
|
||||
app.Shutdown()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run the default agent if no tool restrictions
|
||||
eventCh, err := app.PrimaryAgent.Run(ctx, session.ID, prompt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run agent: %w", err)
|
||||
}
|
||||
|
||||
// Wait for the response
|
||||
var response message.Message
|
||||
for event := range eventCh {
|
||||
if event.Err() != nil {
|
||||
return fmt.Errorf("agent error: %w", event.Err())
|
||||
}
|
||||
response = event.Response()
|
||||
}
|
||||
|
||||
// Get the text content from the response
|
||||
content := ""
|
||||
if textContent := response.Content(); textContent != nil {
|
||||
content = textContent.Text
|
||||
}
|
||||
|
||||
// Format the output according to the specified format
|
||||
formattedOutput, err := format.FormatOutput(content, outputFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to format output: %w", err)
|
||||
}
|
||||
|
||||
// Stop spinner before printing output
|
||||
if !quiet && s != nil {
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
// Print the formatted output to stdout
|
||||
fmt.Println(formattedOutput)
|
||||
|
||||
// Shutdown the app
|
||||
app.Shutdown()
|
||||
|
||||
return nil
|
||||
}
|
||||
65
cmd/root.go
65
cmd/root.go
@@ -15,9 +15,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/config"
|
||||
"github.com/sst/opencode/internal/db"
|
||||
"github.com/sst/opencode/internal/format"
|
||||
"github.com/sst/opencode/internal/llm/agent"
|
||||
"github.com/sst/opencode/internal/logging"
|
||||
"github.com/sst/opencode/internal/lsp/discovery"
|
||||
"github.com/sst/opencode/internal/pubsub"
|
||||
@@ -90,52 +87,17 @@ to assist developers in writing, debugging, and understanding code directly from
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if we're in non-interactive mode
|
||||
prompt, _ := cmd.Flags().GetString("prompt")
|
||||
|
||||
// Check for piped input if no prompt was provided via flag
|
||||
if prompt == "" {
|
||||
pipedInput, hasPipedInput := checkStdinPipe()
|
||||
if hasPipedInput {
|
||||
prompt = pipedInput
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a prompt (either from flag or piped input), run in non-interactive mode
|
||||
if prompt != "" {
|
||||
outputFormatStr, _ := cmd.Flags().GetString("output-format")
|
||||
outputFormat := format.OutputFormat(outputFormatStr)
|
||||
if !outputFormat.IsValid() {
|
||||
return fmt.Errorf("invalid output format: %s", outputFormatStr)
|
||||
}
|
||||
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
verbose, _ := cmd.Flags().GetBool("verbose")
|
||||
|
||||
// Get tool restriction flags
|
||||
allowedTools, _ := cmd.Flags().GetStringSlice("allowedTools")
|
||||
excludedTools, _ := cmd.Flags().GetStringSlice("excludedTools")
|
||||
|
||||
return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet, verbose, allowedTools, excludedTools)
|
||||
}
|
||||
|
||||
// Run LSP auto-discovery
|
||||
if err := discovery.IntegrateLSPServers(cwd); err != nil {
|
||||
slog.Warn("Failed to auto-discover LSP servers", "error", err)
|
||||
// Continue anyway, this is not a fatal error
|
||||
}
|
||||
|
||||
// Connect DB, this will also run migrations
|
||||
conn, err := db.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create main context for the application
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
app, err := app.New(ctx, conn)
|
||||
app, err := app.New(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create app", "error", err)
|
||||
return err
|
||||
@@ -149,9 +111,6 @@ to assist developers in writing, debugging, and understanding code directly from
|
||||
tea.WithAltScreen(),
|
||||
)
|
||||
|
||||
// Initialize MCP tools in the background
|
||||
initMCPTools(ctx, app)
|
||||
|
||||
// Setup the subscriptions, this will send services events to the TUI
|
||||
ch, cancelSubs := setupSubscriptions(app, ctx)
|
||||
|
||||
@@ -234,20 +193,6 @@ func attemptTUIRecovery(program *tea.Program) {
|
||||
program.Quit()
|
||||
}
|
||||
|
||||
func initMCPTools(ctx context.Context, app *app.App) {
|
||||
go func() {
|
||||
defer logging.RecoverPanic("MCP-goroutine", nil)
|
||||
|
||||
// Create a context with timeout for the initial MCP tools fetch
|
||||
ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Set this up once with proper error handling
|
||||
agent.GetMcpTools(ctxWithTimeout, app.Permissions)
|
||||
slog.Info("MCP message handling goroutine exiting")
|
||||
}()
|
||||
}
|
||||
|
||||
func setupSubscriber[T any](
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
@@ -298,10 +243,10 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg,
|
||||
wg := sync.WaitGroup{}
|
||||
ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
|
||||
|
||||
setupSubscriber(ctx, &wg, "logging", app.Logs.Subscribe, ch)
|
||||
setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
|
||||
setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
|
||||
setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
|
||||
// setupSubscriber(ctx, &wg, "logging", app.Logs.Subscribe, ch)
|
||||
// setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
|
||||
// setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
|
||||
// setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
|
||||
setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
|
||||
|
||||
cleanupFunc := func() {
|
||||
|
||||
Reference in New Issue
Block a user