diff --git a/pkg/setup/prompts.go b/pkg/setup/prompts.go deleted file mode 100644 index 222662415..000000000 --- a/pkg/setup/prompts.go +++ /dev/null @@ -1,373 +0,0 @@ -package setup - -import ( - "fmt" - "strings" - - "github.com/sipeed/picoclaw/pkg/config" -) - -// StepDef describes a single prompt step in the setup wizard. -type StepDef struct { - Kind string // "text", "select", "yesno" - Prompt string - Info string - Options []string // for select/yesno - ID string // logical identifier for mapping answers into config -} - -// BuildDefs creates the per-step tui definitions based on the current config. -// It returns a modular list of steps for workspace, provider, model, and channel setup. -func (s *Setup) BuildDefs() []StepDef { - defs := []StepDef{} - - // Step 1: Workspace setup - defs = append(defs, StepDef{ID: "workspace", Kind: "text", Prompt: "1. Workspace path", Info: "Directory for agent workspace files"}) - defs = append(defs, StepDef{ID: "restrict_workspace", Kind: "select", Prompt: "1b. Restrict to workspace?", Options: []string{"yes", "no"}}) - - // Step 2: Provider setup (ordered by popularity) - defs = append(defs, buildProviderSelectStep(s.Cfg)...) - - // Step 3: Model selection (popular suggestions + custom option) - defs = append(defs, buildModelSelectStep(s.Cfg)...) - - // Step 4: Channel setup - defs = append(defs, buildChannelSelectStep(s.Cfg)...) - - // Final confirmation step - defs = append(defs, StepDef{ID: "confirm", Kind: "yesno", Prompt: "6. Confirm and save configuration?", Options: []string{"yes", "no"}}) - - return defs -} - -// buildProviderSelectStep returns provider selection step and credential prompts for missing required fields. -func buildProviderSelectStep(cfg *config.Config) []StepDef { - var steps []StepDef - - provInfo := config.GetProvidersInfo(cfg) - ordered := config.GetOrderedProviderNames() - - provOptions := []string{} - added := map[string]struct{}{} - - // Merge ordered list with available provider info - for _, name := range ordered { - if _, ok := provInfoLookup(provInfo, name); ok { - provOptions = append(provOptions, name) - added[name] = struct{}{} - } - } - // Add any remaining providers not in priority list - for _, p := range provInfo { - if _, ok := added[p.Name]; !ok { - provOptions = append(provOptions, p.Name) - } - } - - // Provider selection - steps = append(steps, StepDef{ID: "provider", Kind: "select", Prompt: "2. Choose provider", Options: provOptions}) - - // Determine selected provider (from config or first in list) - selProvider := cfg.Agents.Defaults.Provider - if selProvider == "" && len(provOptions) > 0 { - selProvider = provOptions[0] - } - - // Add credential prompts for missing required fields - if selProvider != "" { - provInfoItem, ok := provInfoLookup(provInfo, selProvider) - if ok { - for _, cred := range provInfoItem.RequiredCredentials { - if !ProviderCredentialPresent(cfg, selProvider, cred) { - steps = append(steps, StepDef{ - ID: "provider_" + cred, - Kind: "text", - Prompt: fmt.Sprintf("2b. %s for %s", cred, selProvider), - }) - } - } - } - } - - return steps -} - -// buildModelSelectStep returns model selection step and custom model input. -func buildModelSelectStep(cfg *config.Config) []StepDef { - var steps []StepDef - - selProvider := cfg.Agents.Defaults.Provider - modelSuggestions := config.GetPopularModels(selProvider) - - modelOptions := make([]string, len(modelSuggestions)+1) - copy(modelOptions, modelSuggestions) - modelOptions[len(modelSuggestions)] = "custom" - - steps = append(steps, StepDef{ - ID: "model_select", - Kind: "select", - Prompt: "3. Choose model", - Options: modelOptions, - }) - - steps = append(steps, StepDef{ - ID: "default_model", - Kind: "text", - Prompt: "3b. Enter custom model name", - }) - - return steps -} - -// buildChannelSelectStep returns channel selection step and credential prompts. -func buildChannelSelectStep(cfg *config.Config) []StepDef { - var steps []StepDef - - channels := config.GetAllChannelNames() - - steps = append(steps, StepDef{ - ID: "channel_select", - Kind: "select", - Prompt: "4. Choose channel", - Options: channels, - }) - - // Add placeholder for channel credentials (filled dynamically based on selection) - steps = append(steps, StepDef{ - ID: "channel_token", - Kind: "text", - Prompt: "4b. Channel credential/token", - }) - - return steps -} - -// provInfoLookup returns (ProviderInfo, true) if provider name exists in list. -func provInfoLookup(list []config.ProviderInfo, name string) (config.ProviderInfo, bool) { - for _, p := range list { - if p.Name == name { - return p, true - } - } - return config.ProviderInfo{}, false -} - -// ProviderCredentialPresent checks whether a given provider credential is present -// in the legacy ProvidersConfig or within any ModelConfig for that provider. -func ProviderCredentialPresent(c *config.Config, provider, cred string) bool { - if c == nil { - return false - } - p := strings.ToLower(provider) - switch p { - case "anthropic": - if cred == "api_key" { - return c.Providers.Anthropic.APIKey != "" - } - if cred == "api_base" { - return c.Providers.Anthropic.APIBase != "" - } - case "openai": - if cred == "api_key" { - return c.Providers.OpenAI.APIKey != "" - } - if cred == "api_base" { - return c.Providers.OpenAI.APIBase != "" - } - case "openrouter": - if cred == "api_key" { - return c.Providers.OpenRouter.APIKey != "" - } - if cred == "api_base" { - return c.Providers.OpenRouter.APIBase != "" - } - case "groq": - if cred == "api_key" { - return c.Providers.Groq.APIKey != "" - } - case "zhipu": - if cred == "api_key" { - return c.Providers.Zhipu.APIKey != "" - } - case "vllm": - if cred == "api_key" { - return c.Providers.VLLM.APIKey != "" - } - case "gemini": - if cred == "api_key" { - return c.Providers.Gemini.APIKey != "" - } - case "nvidia": - if cred == "api_key" { - return c.Providers.Nvidia.APIKey != "" - } - case "ollama": - if cred == "api_key" { - return c.Providers.Ollama.APIKey != "" - } - case "moonshot": - if cred == "api_key" { - return c.Providers.Moonshot.APIKey != "" - } - case "shengsuanyun": - if cred == "api_key" { - return c.Providers.ShengSuanYun.APIKey != "" - } - case "deepseek": - if cred == "api_key" { - return c.Providers.DeepSeek.APIKey != "" - } - case "cerebras": - if cred == "api_key" { - return c.Providers.Cerebras.APIKey != "" - } - case "volcengine": - if cred == "api_key" { - return c.Providers.VolcEngine.APIKey != "" - } - case "github_copilot": - if cred == "api_key" { - return c.Providers.GitHubCopilot.APIKey != "" - } - case "antigravity": - if cred == "api_key" { - return c.Providers.Antigravity.APIKey != "" - } - case "qwen": - if cred == "api_key" { - return c.Providers.Qwen.APIKey != "" - } - } - - // Check model_list entries for provider credentials - for _, m := range c.ModelList { - pfx := config.ParseProtocol(m.Model) - if pfx == "" { - pfx = "openai" - } - if pfx == p { - if cred == "api_key" && m.APIKey != "" { - return true - } - if cred == "api_base" && m.APIBase != "" { - return true - } - } - } - - return false -} - -// SetProviderCredential sets a credential value for the named provider into the -// appropriate place in the config (legacy ProvidersConfig or model_list fallback). -func SetProviderCredential(c *config.Config, provider, cred, value string) { - if c == nil || provider == "" || cred == "" { - return - } - p := strings.ToLower(provider) - switch p { - case "anthropic": - if cred == "api_key" { - c.Providers.Anthropic.APIKey = value - } else if cred == "api_base" { - c.Providers.Anthropic.APIBase = value - } - case "openai": - if cred == "api_key" { - c.Providers.OpenAI.APIKey = value - } else if cred == "api_base" { - c.Providers.OpenAI.APIBase = value - } - case "openrouter": - if cred == "api_key" { - c.Providers.OpenRouter.APIKey = value - } else if cred == "api_base" { - c.Providers.OpenRouter.APIBase = value - } - case "groq": - if cred == "api_key" { - c.Providers.Groq.APIKey = value - } - case "zhipu": - if cred == "api_key" { - c.Providers.Zhipu.APIKey = value - } - case "vllm": - if cred == "api_key" { - c.Providers.VLLM.APIKey = value - } - case "gemini": - if cred == "api_key" { - c.Providers.Gemini.APIKey = value - } - case "nvidia": - if cred == "api_key" { - c.Providers.Nvidia.APIKey = value - } - case "ollama": - if cred == "api_key" { - c.Providers.Ollama.APIKey = value - } - case "moonshot": - if cred == "api_key" { - c.Providers.Moonshot.APIKey = value - } - case "shengsuanyun": - if cred == "api_key" { - c.Providers.ShengSuanYun.APIKey = value - } - case "deepseek": - if cred == "api_key" { - c.Providers.DeepSeek.APIKey = value - } - case "cerebras": - if cred == "api_key" { - c.Providers.Cerebras.APIKey = value - } - case "volcengine": - if cred == "api_key" { - c.Providers.VolcEngine.APIKey = value - } - case "github_copilot": - if cred == "api_key" { - c.Providers.GitHubCopilot.APIKey = value - } - case "antigravity": - if cred == "api_key" { - c.Providers.Antigravity.APIKey = value - } - case "qwen": - if cred == "api_key" { - c.Providers.Qwen.APIKey = value - } - default: - // If provider not in legacy ProvidersConfig, try to set on a matching ModelConfig - for i := range c.ModelList { - pfx := config.ParseProtocol(c.ModelList[i].Model) - if pfx == "" { - pfx = "openai" - } - if pfx == p { - if cred == "api_key" { - c.ModelList[i].APIKey = value - } else if cred == "api_base" { - c.ModelList[i].APIBase = value - } - return - } - } - } -} - -// BuildSummary returns a human-readable summary of the current configuration. -func BuildSummary(cfg *config.Config) []string { - summary := []string{} - summary = append(summary, "Configuration summary:") - summary = append(summary, fmt.Sprintf("Workspace: %s", cfg.Agents.Defaults.Workspace)) - summary = append(summary, fmt.Sprintf("Restrict to workspace: %v", cfg.Agents.Defaults.RestrictToWorkspace)) - summary = append(summary, fmt.Sprintf("Provider: %s", cfg.Agents.Defaults.Provider)) - if cfg.Agents.Defaults.Model != "" { - summary = append(summary, fmt.Sprintf("Model: %s", cfg.Agents.Defaults.Model)) - } - return summary -} diff --git a/pkg/setup/questions.go b/pkg/setup/questions.go deleted file mode 100644 index 6ea54f2b6..000000000 --- a/pkg/setup/questions.go +++ /dev/null @@ -1,243 +0,0 @@ -package setup - -import ( - "github.com/sipeed/picoclaw/pkg/config" -) - -// QuestionType defines the type of response expected for a question. -type QuestionType string - -const ( - QuestionTypeText QuestionType = "text" - QuestionTypeSelect QuestionType = "select" - QuestionTypeYesNo QuestionType = "yesno" -) - -// Question represents a single question in the setup wizard. -type Question struct { - ID string // unique identifier for mapping answer to config - Type QuestionType // response type: text, select, yesno - Prompt string // question text shown to user - Info string // optional helper text - Options []string // options for select/yesno types - DefaultValue string // default value to prefill - DependsOn string // question ID this depends on (optional) - DependsValue string // value that must be matched (optional) - ConfigPath string // dot-notation path to config field (e.g. "Agents.Defaults.Workspace") - Transformer string // optional: "lowercase", "bool_yesno", "channel_enable" -} - -// QuestionGroup represents a group of related questions. -type QuestionGroup struct { - Name string - Questions []Question -} - -// AllQuestions returns the complete list of all possible questions in the setup wizard. -// This is the declarative data structure - questions are defined here, not built procedurally. -var AllQuestions = []QuestionGroup{ - { - Name: "Workspace", - Questions: []Question{ - { - ID: "workspace", - Type: QuestionTypeText, - Prompt: "1. Workspace path", - Info: "Directory for agent workspace files", - ConfigPath: "Agents.Defaults.Workspace", - DefaultValue: "~/.picoclaw/workspace", - }, - { - ID: "restrict_workspace", - Type: QuestionTypeYesNo, - Prompt: "1b. Restrict to workspace?", - ConfigPath: "Agents.Defaults.RestrictToWorkspace", - Transformer: "bool_yesno", - }, - }, - }, - { - Name: "Provider", - Questions: []Question{ - { - ID: "provider", - Type: QuestionTypeSelect, - Prompt: "2. Choose provider", - ConfigPath: "Agents.Defaults.Provider", - }, - { - ID: "provider_api_key", - Type: QuestionTypeText, - Prompt: "2b. API key for provider", - ConfigPath: "Providers.{provider}.APIKey", - DependsOn: "provider", - }, - { - ID: "provider_api_base", - Type: QuestionTypeText, - Prompt: "2c. API base URL (optional)", - ConfigPath: "Providers.{provider}.APIBase", - DependsOn: "provider", - }, - }, - }, - { - Name: "Model", - Questions: []Question{ - { - ID: "model_select", - Type: QuestionTypeSelect, - Prompt: "3. Choose model", - ConfigPath: "Agents.Defaults.Model", - }, - { - ID: "custom_model", - Type: QuestionTypeText, - Prompt: "3b. Enter custom model name", - ConfigPath: "Agents.Defaults.Model", - DependsOn: "model_select", - DependsValue: "custom", - }, - }, - }, - { - Name: "Channel", - Questions: []Question{ - { - ID: "channel_select", - Type: QuestionTypeSelect, - Prompt: "4. Choose channel", - ConfigPath: "channel_enabled", - }, - { - ID: "channel_token", - Type: QuestionTypeText, - Prompt: "4b. Channel token/credential", - ConfigPath: "channel.{channel}.token", - DependsOn: "channel_select", - }, - }, - }, - { - Name: "Confirmation", - Questions: []Question{ - { - ID: "confirm", - Type: QuestionTypeYesNo, - Prompt: "5. Confirm and save configuration?", - }, - }, - }, -} - -// QuestionRegistry holds resolved questions for a specific config state. -type QuestionRegistry struct { - Questions []Question - Defaults map[string]string // questionID -> default value -} - -// BuildQuestionRegistry creates the question registry based on current config. -// It resolves dynamic options (provider list, channel list, model suggestions) from config. -func BuildQuestionRegistry(cfg *config.Config) QuestionRegistry { - registry := QuestionRegistry{ - Questions: []Question{}, - Defaults: map[string]string{}, - } - - // Collect provider options - provInfo := config.GetProvidersInfo(cfg) - ordered := config.GetOrderedProviderNames() - provOptions := []string{} - added := map[string]struct{}{} - for _, name := range ordered { - if _, ok := provInfoLookup(provInfo, name); ok { - provOptions = append(provOptions, name) - added[name] = struct{}{} - } - } - for _, p := range provInfo { - if _, ok := added[p.Name]; !ok { - provOptions = append(provOptions, p.Name) - } - } - - // Collect channel options - channelOptions := config.GetAllChannelNames() - - // Collect model options based on selected provider - selProvider := cfg.Agents.Defaults.Provider - modelSuggestions := config.GetPopularModels(selProvider) - modelOptions := make([]string, len(modelSuggestions)+1) - copy(modelOptions, modelSuggestions) - modelOptions[len(modelSuggestions)] = "custom" - - // Build flat list of questions with resolved options - for _, group := range AllQuestions { - for _, q := range group.Questions { - question := q - - // Set options based on question type - if question.Type == QuestionTypeSelect { - switch question.ID { - case "provider": - question.Options = provOptions - case "channel_select": - question.Options = channelOptions - case "model_select": - question.Options = modelOptions - } - } - - // Set defaults from config - if question.DefaultValue != "" { - registry.Defaults[question.ID] = question.DefaultValue - } - - registry.Questions = append(registry.Questions, question) - } - } - - return registry -} - -// GetQuestionByID returns a question by its ID. -func (r *QuestionRegistry) GetQuestionByID(id string) *Question { - for i := range r.Questions { - if r.Questions[i].ID == id { - return &r.Questions[i] - } - } - return nil -} - -// GetDependentQuestions returns questions that depend on a specific question. -func (r *QuestionRegistry) GetDependentQuestions(dependsOn string) []Question { - var deps []Question - for _, q := range r.Questions { - if q.DependsOn == dependsOn { - deps = append(deps, q) - } - } - return deps -} - -// ShouldShowQuestion checks if a question should be shown based on its dependencies. -func (r *QuestionRegistry) ShouldShowQuestion(questionID string, answers map[string]string) bool { - q := r.GetQuestionByID(questionID) - if q == nil || q.DependsOn == "" { - return true - } - - depValue, exists := answers[q.DependsOn] - if !exists { - return false - } - - // If DependsValue is set, check for exact match - if q.DependsValue != "" { - return depValue == q.DependsValue - } - - // Otherwise show if dependency has any value - return depValue != "" -} diff --git a/pkg/setup/sessions.go b/pkg/setup/sessions.go new file mode 100644 index 000000000..f7b838d35 --- /dev/null +++ b/pkg/setup/sessions.go @@ -0,0 +1,817 @@ +package setup + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type ChannelField struct { + ID string + Prompt string + Placeholder string + Info string +} + +type ChannelInfo struct { + Fields []ChannelField +} + +var channelInfoMap = map[string]ChannelInfo{ + "telegram": { + Fields: []ChannelField{ + {ID: "token", Prompt: "Bot Token", Placeholder: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz", Info: "Get from @BotFather on Telegram"}, + }, + }, + "slack": { + Fields: []ChannelField{ + {ID: "bot_token", Prompt: "Bot Token", Placeholder: "xoxb-xxxxx-xxxxx", Info: "Bot token from Slack App settings (starts with xoxb-)"}, + {ID: "app_token", Prompt: "App Token", Placeholder: "xoxa-xxxxx-xxxxx", Info: "App token from Slack App settings (starts with xoxa-)"}, + }, + }, + "discord": { + Fields: []ChannelField{ + {ID: "token", Prompt: "Bot Token", Placeholder: "MTExxxxxxxxxxxx.MDExxxxxxxx.xxxxx", Info: "Get from Discord Developer Portal > Bot"}, + }, + }, + "whatsapp": { + Fields: []ChannelField{ + {ID: "bridge_url", Prompt: "Bridge URL", Placeholder: "http://localhost:9080", Info: "URL of your WhatsApp bridge service"}, + }, + }, + "feishu": { + Fields: []ChannelField{ + {ID: "app_id", Prompt: "App ID", Placeholder: "cli_xxxxx", Info: "App ID from Feishu Open Platform > App"}, + {ID: "app_secret", Prompt: "App Secret", Placeholder: "xxxxxxxxxxxxxxxx", Info: "App Secret from Feishu Open Platform > App"}, + }, + }, + "dingtalk": { + Fields: []ChannelField{ + {ID: "client_id", Prompt: "Client ID", Placeholder: "dingxxxxx", Info: "Client ID from DingTalk Admin > OAuth"}, + {ID: "client_secret", Prompt: "Client Secret", Placeholder: "xxxxxxxxxxxxxxxx", Info: "Client Secret from DingTalk Admin > OAuth"}, + }, + }, + "line": { + Fields: []ChannelField{ + {ID: "channel_access_token", Prompt: "Channel Access Token", Placeholder: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", Info: "Get from LINE Developers > Channel Access Token"}, + }, + }, + "qq": { + Fields: []ChannelField{ + {ID: "app_id", Prompt: "App ID", Placeholder: "1234567890", Info: "App ID from QQ Open Platform"}, + }, + }, + "onebot": { + Fields: []ChannelField{ + {ID: "access_token", Prompt: "Access Token", Placeholder: "your_access_token_here", Info: "Access token configured in your OneBot server"}, + }, + }, + "wecom": { + Fields: []ChannelField{ + {ID: "token", Prompt: "Token", Placeholder: "your_token", Info: "Token from Wecom Admin"}, + {ID: "encoding_aes_key", Prompt: "Encoding AES Key", Placeholder: "your_encoding_aes_key", Info: "Encoding AES Key from Wecom Admin"}, + }, + }, + "wecom_app": { + Fields: []ChannelField{ + {ID: "corp_id", Prompt: "Corp ID", Placeholder: "wwxxxxx", Info: "Corp ID from Wecom Admin"}, + {ID: "agent_id", Prompt: "Agent ID", Placeholder: "1000001", Info: "Agent ID from Wecom Admin"}, + }, + }, + "maixcam": { + Fields: []ChannelField{ + {ID: "device_address", Prompt: "Device Address", Placeholder: "http://192.168.1.100:8080", Info: "MaixCam device HTTP address"}, + }, + }, +} + +func GetChannelInfo(channel string) (ChannelInfo, bool) { + info, ok := channelInfoMap[strings.ToLower(channel)] + return info, ok +} + +// QuestionType defines the type of response expected for a question. +type QuestionType string + +const ( + QuestionTypeText QuestionType = "text" + QuestionTypeSelect QuestionType = "select" + QuestionTypeYesNo QuestionType = "yesno" +) + +// Question represents a single question in a setup session. +type Question struct { + ID string // unique identifier + Type QuestionType // response type: text, select, yesno + Prompt string // question text shown to user + Info string // optional helper text + Options []string // options for select/yesno types + DefaultValue string // default value to prefill + DependsOn string // question ID this depends on (optional) + DependsValue string // value that must be matched to show this question +} + +// Session represents a group of questions shown together in one step. +type Session struct { + ID string // session identifier (e.g., "workspace", "provider", "model", "channel", "confirm") + Title string // step title shown to user + Questions []Question // questions in this session + Transformer func(answers map[string]string) // optional: transform answers before applying to config +} + +// AllSessions returns the complete list of setup sessions. +// Each session can have multiple questions shown together. +var AllSessions = []Session{ + { + ID: "workspace", + Title: "Workspace Setup", + Questions: []Question{ + { + ID: "workspace", + Type: QuestionTypeText, + Prompt: "Workspace path", + Info: "Directory for agent workspace files", + DefaultValue: "~/.picoclaw/workspace", + }, + { + ID: "restrict_workspace", + Type: QuestionTypeYesNo, + Prompt: "Restrict to workspace?", + Options: []string{"yes", "no"}, + DefaultValue: "no", + }, + }, + }, + { + ID: "provider", + Title: "Provider Setup", + Questions: []Question{ + { + ID: "provider", + Type: QuestionTypeSelect, + Prompt: "Choose provider", + }, + { + ID: "provider_auth_method", + Type: QuestionTypeSelect, + Prompt: "Authentication method", + Options: []string{"api_key", "oauth_login"}, + DependsOn: "provider", + }, + { + ID: "provider_api_key", + Type: QuestionTypeText, + Prompt: "API key", + Info: "API key for authentication", + DependsOn: "provider_auth_method", + DependsValue: "api_key", + }, + }, + }, + { + ID: "model", + Title: "Model Setup", + Questions: []Question{ + { + ID: "model_select", + Type: QuestionTypeSelect, + Prompt: "Choose model", + }, + { + ID: "custom_model", + Type: QuestionTypeText, + Prompt: "Custom model name", + Info: "Enter the model identifier", + DependsOn: "model_select", + DependsValue: "custom", + }, + }, + }, + { + ID: "channel", + Title: "Channel Setup", + Questions: []Question{ + { + ID: "channel_select", + Type: QuestionTypeSelect, + Prompt: "Choose channel to connect", + Info: "Select a channel to enable", + }, + { + ID: "channel_enable", + Type: QuestionTypeYesNo, + Prompt: "Enable this channel?", + Options: []string{"yes", "no"}, + DefaultValue: "yes", + DependsOn: "channel_select", + }, + { + ID: "telegram_token", + Type: QuestionTypeText, + Prompt: "Bot Token", + Info: "Get from @BotFather on Telegram", + DependsOn: "channel_select", + DependsValue: "telegram", + }, + { + ID: "slack_bot_token", + Type: QuestionTypeText, + Prompt: "Bot Token", + Info: "Bot token from Slack App settings (starts with xoxb-)", + DependsOn: "channel_select", + DependsValue: "slack", + }, + { + ID: "slack_app_token", + Type: QuestionTypeText, + Prompt: "App Token", + Info: "App token from Slack App settings (starts with xoxa-)", + DependsOn: "channel_select", + DependsValue: "slack", + }, + { + ID: "discord_token", + Type: QuestionTypeText, + Prompt: "Bot Token", + Info: "Get from Discord Developer Portal > Bot", + DependsOn: "channel_select", + DependsValue: "discord", + }, + { + ID: "whatsapp_bridge_url", + Type: QuestionTypeText, + Prompt: "Bridge URL", + Info: "URL of your WhatsApp bridge service", + DependsOn: "channel_select", + DependsValue: "whatsapp", + }, + { + ID: "feishu_app_id", + Type: QuestionTypeText, + Prompt: "App ID", + Info: "App ID from Feishu Open Platform > App", + DependsOn: "channel_select", + DependsValue: "feishu", + }, + { + ID: "feishu_app_secret", + Type: QuestionTypeText, + Prompt: "App Secret", + Info: "App Secret from Feishu Open Platform > App", + DependsOn: "channel_select", + DependsValue: "feishu", + }, + { + ID: "dingtalk_client_id", + Type: QuestionTypeText, + Prompt: "Client ID", + Info: "Client ID from DingTalk Admin > OAuth", + DependsOn: "channel_select", + DependsValue: "dingtalk", + }, + { + ID: "dingtalk_client_secret", + Type: QuestionTypeText, + Prompt: "Client Secret", + Info: "Client Secret from DingTalk Admin > OAuth", + DependsOn: "channel_select", + DependsValue: "dingtalk", + }, + { + ID: "line_channel_access_token", + Type: QuestionTypeText, + Prompt: "Channel Access Token", + Info: "Get from LINE Developers > Channel Access Token", + DependsOn: "channel_select", + DependsValue: "line", + }, + { + ID: "qq_app_id", + Type: QuestionTypeText, + Prompt: "App ID", + Info: "App ID from QQ Open Platform", + DependsOn: "channel_select", + DependsValue: "qq", + }, + { + ID: "onebot_access_token", + Type: QuestionTypeText, + Prompt: "Access Token", + Info: "Access token configured in your OneBot server", + DependsOn: "channel_select", + DependsValue: "onebot", + }, + { + ID: "wecom_token", + Type: QuestionTypeText, + Prompt: "Token", + Info: "Token from Wecom Admin", + DependsOn: "channel_select", + DependsValue: "wecom", + }, + { + ID: "wecom_encoding_aes_key", + Type: QuestionTypeText, + Prompt: "Encoding AES Key", + Info: "Encoding AES Key from Wecom Admin", + DependsOn: "channel_select", + DependsValue: "wecom", + }, + { + ID: "wecom_app_corp_id", + Type: QuestionTypeText, + Prompt: "Corp ID", + Info: "Corp ID from Wecom Admin", + DependsOn: "channel_select", + DependsValue: "wecom_app", + }, + { + ID: "wecom_app_agent_id", + Type: QuestionTypeText, + Prompt: "Agent ID", + Info: "Agent ID from Wecom Admin", + DependsOn: "channel_select", + DependsValue: "wecom_app", + }, + { + ID: "maixcam_device_address", + Type: QuestionTypeText, + Prompt: "Device Address", + Info: "MaixCam device HTTP address", + DependsOn: "channel_select", + DependsValue: "maixcam", + }, + }, + }, + { + ID: "confirm", + Title: "Confirmation", + Questions: []Question{ + { + ID: "confirm", + Type: QuestionTypeYesNo, + Prompt: "Confirm and save configuration?", + Options: []string{"yes", "no"}, + DefaultValue: "yes", + }, + }, + }, +} + +// SessionRegistry holds resolved sessions for a specific config state. +type SessionRegistry struct { + Sessions []Session + Answers map[string]string // questionID -> answer +} + +// BuildSessionRegistry creates the session registry with resolved options from config. +func BuildSessionRegistry(cfg *config.Config) SessionRegistry { + registry := SessionRegistry{ + Sessions: make([]Session, len(AllSessions)), + Answers: map[string]string{}, + } + + // Collect provider options + provInfo := config.GetProvidersInfo(cfg) + ordered := config.GetOrderedProviderNames() + provOptions := []string{} + added := map[string]struct{}{} + for _, name := range ordered { + if _, ok := provInfoLookup(provInfo, name); ok { + provOptions = append(provOptions, name) + added[name] = struct{}{} + } + } + for _, p := range provInfo { + if _, ok := added[p.Name]; !ok { + provOptions = append(provOptions, p.Name) + } + } + + // Collect channel options + channelOptions := config.GetAllChannelNames() + + // Collect model options based on selected provider + selProvider := cfg.Agents.Defaults.Provider + modelSuggestions := config.GetPopularModels(selProvider) + modelOptions := make([]string, len(modelSuggestions)+1) + copy(modelOptions, modelSuggestions) + modelOptions[len(modelSuggestions)] = "custom" + + // Deep copy sessions and fill in options + for i, sess := range AllSessions { + registry.Sessions[i] = Session{ + ID: sess.ID, + Title: sess.Title, + Questions: make([]Question, len(sess.Questions)), + } + + for j, q := range sess.Questions { + registry.Sessions[i].Questions[j] = q + + // Fill in options for select/yesno types + if q.Type == QuestionTypeSelect || q.Type == QuestionTypeYesNo { + switch q.ID { + case "provider": + registry.Sessions[i].Questions[j].Options = provOptions + case "channel_select": + registry.Sessions[i].Questions[j].Options = channelOptions + case "model_select": + registry.Sessions[i].Questions[j].Options = modelOptions + case "restrict_workspace", "confirm": + // Ensure yesno options are set + if len(q.Options) == 0 { + registry.Sessions[i].Questions[j].Options = []string{"yes", "no"} + } + } + } + + // Pre-fill defaults from config + if q.DefaultValue == "" { + switch q.ID { + case "workspace": + if cfg.Agents.Defaults.Workspace != "" { + registry.Sessions[i].Questions[j].DefaultValue = cfg.Agents.Defaults.Workspace + } + case "restrict_workspace": + if cfg.Agents.Defaults.RestrictToWorkspace { + registry.Sessions[i].Questions[j].DefaultValue = "yes" + } else { + registry.Sessions[i].Questions[j].DefaultValue = "no" + } + case "provider": + if cfg.Agents.Defaults.Provider != "" { + registry.Sessions[i].Questions[j].DefaultValue = cfg.Agents.Defaults.Provider + } + case "model_select": + if cfg.Agents.Defaults.Model != "" { + registry.Sessions[i].Questions[j].DefaultValue = cfg.Agents.Defaults.Model + } + case "custom_model": + if cfg.Agents.Defaults.Model != "" { + registry.Sessions[i].Questions[j].DefaultValue = cfg.Agents.Defaults.Model + } + } + } + } + } + + return registry +} + +// ShouldShowQuestion checks if a question should be visible based on current answers. +func (r *SessionRegistry) ShouldShowQuestion(questionID string) bool { + for _, sess := range r.Sessions { + for _, q := range sess.Questions { + if q.ID == questionID { + if q.DependsOn == "" { + return true + } + depValue := r.Answers[q.DependsOn] + if q.DependsValue != "" { + return depValue == q.DependsValue + } + return depValue != "" + } + } + } + return false +} + +// GetSessionByID returns a session by its ID. +func (r *SessionRegistry) GetSessionByID(id string) *Session { + for i := range r.Sessions { + if r.Sessions[i].ID == id { + return &r.Sessions[i] + } + } + return nil +} + +// provInfoLookup is a helper to find provider info by name. +func provInfoLookup(list []config.ProviderInfo, name string) (config.ProviderInfo, bool) { + for _, p := range list { + if p.Name == name { + return p, true + } + } + return config.ProviderInfo{}, false +} + +// BuildSummary returns a human-readable summary of the current configuration. +func BuildSummary(cfg *config.Config) []string { + summary := []string{} + summary = append(summary, "Configuration summary:") + summary = append(summary, "─────────────────────") + summary = append(summary, fmt.Sprintf("Workspace: %s", cfg.Agents.Defaults.Workspace)) + summary = append(summary, fmt.Sprintf("Restrict to workspace: %v", cfg.Agents.Defaults.RestrictToWorkspace)) + summary = append(summary, fmt.Sprintf("Provider: %s", cfg.Agents.Defaults.Provider)) + if cfg.Agents.Defaults.Model != "" { + summary = append(summary, fmt.Sprintf("Model: %s", cfg.Agents.Defaults.Model)) + } + return summary +} + +// SetProviderCredential sets a credential value for the named provider into the +// appropriate place in the config (legacy ProvidersConfig or model_list fallback). +func SetProviderCredential(c *config.Config, provider, cred, value string) { + if c == nil || provider == "" || cred == "" { + return + } + p := strings.ToLower(provider) + switch p { + case "anthropic": + if cred == "api_key" { + c.Providers.Anthropic.APIKey = value + } else if cred == "api_base" { + c.Providers.Anthropic.APIBase = value + } + case "openai": + if cred == "api_key" { + c.Providers.OpenAI.APIKey = value + } else if cred == "api_base" { + c.Providers.OpenAI.APIBase = value + } + case "openrouter": + if cred == "api_key" { + c.Providers.OpenRouter.APIKey = value + } else if cred == "api_base" { + c.Providers.OpenRouter.APIBase = value + } + case "groq": + if cred == "api_key" { + c.Providers.Groq.APIKey = value + } + case "zhipu": + if cred == "api_key" { + c.Providers.Zhipu.APIKey = value + } + case "vllm": + if cred == "api_key" { + c.Providers.VLLM.APIKey = value + } + case "gemini": + if cred == "api_key" { + c.Providers.Gemini.APIKey = value + } + case "nvidia": + if cred == "api_key" { + c.Providers.Nvidia.APIKey = value + } + case "ollama": + if cred == "api_key" { + c.Providers.Ollama.APIKey = value + } + case "moonshot": + if cred == "api_key" { + c.Providers.Moonshot.APIKey = value + } + case "shengsuanyun": + if cred == "api_key" { + c.Providers.ShengSuanYun.APIKey = value + } + case "deepseek": + if cred == "api_key" { + c.Providers.DeepSeek.APIKey = value + } + case "cerebras": + if cred == "api_key" { + c.Providers.Cerebras.APIKey = value + } + case "volcengine": + if cred == "api_key" { + c.Providers.VolcEngine.APIKey = value + } + case "github_copilot": + if cred == "api_key" { + c.Providers.GitHubCopilot.APIKey = value + } + case "antigravity": + if cred == "api_key" { + c.Providers.Antigravity.APIKey = value + } + case "qwen": + if cred == "api_key" { + c.Providers.Qwen.APIKey = value + } + default: + // If provider not in legacy ProvidersConfig, try to set on a matching ModelConfig + for i := range c.ModelList { + pfx := config.ParseProtocol(c.ModelList[i].Model) + if pfx == "" { + pfx = "openai" + } + if pfx == p { + if cred == "api_key" { + c.ModelList[i].APIKey = value + } else if cred == "api_base" { + c.ModelList[i].APIBase = value + } + return + } + } + } +} + +// GetProviderAPIKey retrieves the API key for a provider from config. +func GetProviderAPIKey(cfg *config.Config, provider string) string { + if cfg == nil || provider == "" { + return "" + } + p := strings.ToLower(provider) + switch p { + case "anthropic": + return cfg.Providers.Anthropic.APIKey + case "openai": + return cfg.Providers.OpenAI.APIKey + case "openrouter": + return cfg.Providers.OpenRouter.APIKey + case "groq": + return cfg.Providers.Groq.APIKey + case "zhipu": + return cfg.Providers.Zhipu.APIKey + case "vllm": + return cfg.Providers.VLLM.APIKey + case "gemini": + return cfg.Providers.Gemini.APIKey + case "nvidia": + return cfg.Providers.Nvidia.APIKey + case "ollama": + return cfg.Providers.Ollama.APIKey + case "moonshot": + return cfg.Providers.Moonshot.APIKey + case "shengsuanyun": + return cfg.Providers.ShengSuanYun.APIKey + case "deepseek": + return cfg.Providers.DeepSeek.APIKey + case "cerebras": + return cfg.Providers.Cerebras.APIKey + case "volcengine": + return cfg.Providers.VolcEngine.APIKey + case "github_copilot": + return cfg.Providers.GitHubCopilot.APIKey + case "antigravity": + return cfg.Providers.Antigravity.APIKey + case "qwen": + return cfg.Providers.Qwen.APIKey + default: + for _, m := range cfg.ModelList { + pfx := config.ParseProtocol(m.Model) + if pfx == "" { + pfx = "openai" + } + if pfx == p { + return m.APIKey + } + } + } + return "" +} + +// GetProviderAPIBase retrieves the API base URL for a provider from config. +func GetProviderAPIBase(cfg *config.Config, provider string) string { + if cfg == nil || provider == "" { + return "" + } + p := strings.ToLower(provider) + switch p { + case "anthropic": + return cfg.Providers.Anthropic.APIBase + case "openai": + return cfg.Providers.OpenAI.APIBase + case "openrouter": + return cfg.Providers.OpenRouter.APIBase + case "vllm": + return cfg.Providers.VLLM.APIBase + default: + for _, m := range cfg.ModelList { + pfx := config.ParseProtocol(m.Model) + if pfx == "" { + pfx = "openai" + } + if pfx == p { + return m.APIBase + } + } + } + return "" +} + +// GetChannelFieldToken retrieves a specific token field for a channel from config. +func GetChannelFieldToken(cfg *config.Config, questionID string) string { + if cfg == nil || questionID == "" { + return "" + } + switch questionID { + case "telegram_token": + return cfg.Channels.Telegram.Token + case "slack_bot_token": + return cfg.Channels.Slack.BotToken + case "slack_app_token": + return cfg.Channels.Slack.AppToken + case "discord_token": + return cfg.Channels.Discord.Token + case "whatsapp_bridge_url": + return cfg.Channels.WhatsApp.BridgeURL + case "feishu_app_id": + return cfg.Channels.Feishu.AppID + case "feishu_app_secret": + return cfg.Channels.Feishu.AppSecret + case "dingtalk_client_id": + return cfg.Channels.DingTalk.ClientID + case "dingtalk_client_secret": + return cfg.Channels.DingTalk.ClientSecret + case "line_channel_access_token": + return cfg.Channels.LINE.ChannelAccessToken + case "qq_app_id": + return cfg.Channels.QQ.AppID + case "onebot_access_token": + return cfg.Channels.OneBot.AccessToken + case "wecom_token": + return cfg.Channels.WeCom.Token + case "wecom_encoding_aes_key": + return cfg.Channels.WeCom.EncodingAESKey + case "wecom_app_corp_id": + return cfg.Channels.WeComApp.CorpID + case "wecom_app_agent_id": + return fmt.Sprintf("%d", cfg.Channels.WeComApp.AgentID) + case "maixcam_device_address": + return fmt.Sprintf("http://%s:%d", cfg.Channels.MaixCam.Host, cfg.Channels.MaixCam.Port) + } + return "" +} + +// GetChannelToken retrieves the token/credential for a channel from config. +func GetChannelToken(cfg *config.Config, channel string) string { + if cfg == nil || channel == "" { + return "" + } + ch := strings.ToLower(channel) + switch ch { + case "telegram": + return cfg.Channels.Telegram.Token + case "slack": + return cfg.Channels.Slack.BotToken + case "discord": + return cfg.Channels.Discord.Token + case "whatsapp": + return cfg.Channels.WhatsApp.BridgeURL + case "feishu": + return cfg.Channels.Feishu.AppID + case "dingtalk": + return cfg.Channels.DingTalk.ClientID + case "line": + return cfg.Channels.LINE.ChannelAccessToken + case "qq": + return cfg.Channels.QQ.AppID + case "onebot": + return cfg.Channels.OneBot.AccessToken + case "wecom": + return cfg.Channels.WeCom.Token + case "wecom_app": + return cfg.Channels.WeComApp.CorpID + } + return "" +} + +// GetChannelTokenFromConfig retrieves any enabled channel's token from config. +func GetChannelTokenFromConfig(cfg *config.Config) string { + if cfg == nil { + return "" + } + if cfg.Channels.Telegram.Enabled { + return cfg.Channels.Telegram.Token + } + if cfg.Channels.Slack.Enabled { + return cfg.Channels.Slack.BotToken + } + if cfg.Channels.Discord.Enabled { + return cfg.Channels.Discord.Token + } + if cfg.Channels.WhatsApp.Enabled { + return cfg.Channels.WhatsApp.BridgeURL + } + if cfg.Channels.Feishu.Enabled { + return cfg.Channels.Feishu.AppID + } + if cfg.Channels.DingTalk.Enabled { + return cfg.Channels.DingTalk.ClientID + } + if cfg.Channels.LINE.Enabled { + return cfg.Channels.LINE.ChannelAccessToken + } + if cfg.Channels.QQ.Enabled { + return cfg.Channels.QQ.AppID + } + if cfg.Channels.OneBot.Enabled { + return cfg.Channels.OneBot.AccessToken + } + if cfg.Channels.WeCom.Enabled { + return cfg.Channels.WeCom.Token + } + if cfg.Channels.WeComApp.Enabled { + return cfg.Channels.WeComApp.CorpID + } + return "" +} diff --git a/pkg/setup/setup.go b/pkg/setup/setup.go index e5900426f..a7210815a 100644 --- a/pkg/setup/setup.go +++ b/pkg/setup/setup.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "github.com/charmbracelet/bubbles/textinput" @@ -13,33 +14,16 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) -var Banner = ` -******************************************************************* - - ██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗ - ██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║ - ██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║ - ██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║ - ██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝ - ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ - -******************************************************************* -` - -var About = `Tiny, Fast, and Deployable anywhere — automate the mundane, unleash your creativity` - -// Setup holds state for the interactive setup flow. type Setup struct { ConfigPath string Cfg *config.Config Steps []string + Confirmed bool } -// NewSetup creates a Setup, loading existing config if present or using defaults. func NewSetup(configPath string) (*Setup, error) { s := &Setup{ConfigPath: configPath} - // Try load existing config, otherwise use defaults if _, err := os.Stat(configPath); err == nil { cfg, err := config.LoadConfig(configPath) if err != nil { @@ -54,78 +38,17 @@ func NewSetup(configPath string) (*Setup, error) { return s, nil } -// BuildDefs creates the per-step tui definitions based on the current config. -// It only includes steps that are relevant (e.g., missing values) but always -// exposes the provider selector so users can change it. -func (s *Setup) BuildDefs() []stepDef { - defs := []stepDef{} - - // Workspace: always allow the user to set/change workspace - defs = append(defs, stepDef{id: "workspace", kind: "text", prompt: "1. Workspace path (e.g. ~/.picoclaw/workspace)", info: "always allow the user to set/change workspace"}) - - // Provider: allow selection (always present so user can change) - defs = append(defs, stepDef{id: "provider", kind: "select", prompt: "2. Choose default provider", options: []string{ - "openai", "openrouter", "anthropic", "ollama", "volcengine", "github_copilot", "groq", "gemini", "zhipu", "deepseek", "antigravity", "qwen", - }}) - - // Provider-specific API key prompts (only add when the provider is openai or when key is missing) - // For now we only prompt for OpenAI API key if empty. - if s.Cfg.Providers.OpenAI.APIKey == "" { - defs = append(defs, stepDef{id: "openai_api_key", kind: "text", prompt: "3. Provider API key (leave empty to skip)"}) - } - - // Default model - if s.Cfg.Agents.Defaults.Model == "" { - defs = append(defs, stepDef{id: "default_model", kind: "text", prompt: "4. Default model name (e.g. gpt-5.2)"}) - } - - // Web search option for OpenAI (only relevant if provider is openai but safe to show) - defs = append(defs, stepDef{id: "provider", kind: "select", prompt: "2. Choose channel", options: []string{ - "telegram", "whatsapp", "discord", "feishu", "maxicam", "qq", "dingtalk", "slack", "line", "onebot", "wecom", - }}) - // Install example skills (optional) - defs = append(defs, stepDef{id: "install_skills", kind: "yesno", prompt: "6. Install example skills now?", options: []string{"yes", "no"}}) - - return defs -} - -// buildSteps inspects the config and creates a list of remaining steps for the user. func (s *Setup) buildSteps() { s.Steps = []string{} - // If providers section is empty, prompt to add API keys - // if s.Cfg.Providers.IsEmpty() { - // s.Steps = append(s.Steps, "Choose workspace location") - // } - - // Check model list for missing API keys - missing := 0 - for _, m := range s.Cfg.ModelList { - if m.APIKey == "" { - missing++ - } - } - if missing > 0 { - s.Steps = append(s.Steps, fmt.Sprintf("Add API keys for %d model(s) in model_list", missing)) - } - - // Workspace - if s.Cfg.Agents.Defaults.Workspace == "" { - s.Steps = append(s.Steps, "Configure workspace path") - } } -// Run shows a minimal Bubble Tea UI listing steps and waits for user to continue. func (s *Setup) Run() error { - // Create the TUI model which owns a pointer to this Setup so it can - // update s.Cfg while the user answers questions. - m := tuiModel{setup: s, state: "intro"} - + m := newTuiModel(s) p := tea.NewProgram(m) - if err := p.Start(); err != nil { + if _, err := p.Run(); err != nil { return fmt.Errorf("tui start failed: %w", err) } - // On exit, save the potentially-updated config back to disk. if err := saveConfigPath(s.ConfigPath, s.Cfg); err != nil { return fmt.Errorf("failed to save config: %w", err) } @@ -133,8 +56,9 @@ func (s *Setup) Run() error { return nil } -// AskMissing is deprecated when using full TUI; keep for backward compatibility. -func (s *Setup) AskMissing() error { return nil } +func (s *Setup) AskMissing() error { + return nil +} func saveConfigPath(path string, cfg *config.Config) error { dir := filepath.Dir(path) @@ -144,324 +68,875 @@ func saveConfigPath(path string, cfg *config.Config) error { return config.SaveConfig(path, cfg) } -// trimNewline kept for potential future use -// func trimNewline(s string) string { -// if len(s) == 0 { -// return s -// } -// if s[len(s)-1] == '\n' { -// s = s[:len(s)-1] -// } -// if len(s) > 0 && s[len(s)-1] == '\r' { -// s = s[:len(s)-1] -// } -// return s -// } - -// --- TUI model --- type tuiModel struct { - setup *Setup - state string // "intro", "questions", "done" - cursor int - // per-step definitions - defs []stepDef - // active text input (if current step is text) - ti textinput.Model - // selection index for select/yesno steps - selIdx int + setup *Setup + state string + sessionIdx int + questionIdx int + answers map[string]string + textInputs map[string]textinput.Model + selIdx map[string]int + registry SessionRegistry + errorMsg string } -type stepDef struct { - kind string // "text", "select", "yesno" - prompt string - info string - options []string // for select/yesno - id string // logical identifier for mapping answers into config +func newTuiModel(s *Setup) tuiModel { + return tuiModel{ + setup: s, + state: "intro", + answers: make(map[string]string), + textInputs: make(map[string]textinput.Model), + selIdx: make(map[string]int), + } } -func (t tuiModel) Init() tea.Cmd { return nil } +func (t tuiModel) Init() tea.Cmd { + return nil +} func (t tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - // Always allow immediate quit for sensitive interrupts - if msg.String() == "esc" || msg.String() == "ctrl+c" { - return t, tea.Quit - } - - // If we're in questions state and the current step is text, let the textinput - // handle keystrokes first. In that state we must not treat `q` as a global - // quit key because the user may be typing the letter 'q' into the input. - if t.state == "questions" && len(t.defs) > 0 && t.defs[t.cursor].kind == "text" { - var cmd tea.Cmd - ti, cmd := t.ti.Update(msg) - t.ti = ti - // propagate any command from textinput - if cmd != nil { - return t, cmd - } - // After textinput processed the key, do not interpret `q` as quit here. - // Continue to next message handling (e.g. Enter) below. - } - - // quit key 'q' should work when not typing into a text input (e.g., intro or select) - if msg.String() == "q" { - return t, tea.Quit - } - - // handle other keys (enter, left/right) below switch msg.String() { - case "enter": - if t.state == "intro" { - // Build defs dynamically from the Setup - t.defs = t.setup.BuildDefs() - - // initialize first input if text - t.cursor = 0 - if t.defs[0].kind == "text" { - ti := textinput.New() - ti.Placeholder = t.defs[0].prompt - ti.CharLimit = 512 - // prefill from config when we know mapping; try workspace first - if t.setup.Cfg.Agents.Defaults.Workspace != "" { - ti.SetValue(t.setup.Cfg.Agents.Defaults.Workspace) - } - ti.Focus() - t.ti = ti - } - // initialize selection index from existing config when applicable - t.selIdx = 0 - // if provider already configured, set selIdx accordingly - // find first select/yesno and prefill from config - for _, def := range t.defs { - if def.kind == "select" { - for i, o := range def.options { - if o == t.setup.Cfg.Agents.Defaults.Provider { - t.selIdx = i - break - } - } - break - } - } - t.state = "questions" - return t, nil - } - if t.state == "questions" { - // commit current answer and advance; when past last, finish - idx := t.cursor - def := t.defs[idx] - switch def.kind { - case "text": - ans := t.ti.Value() - // Store mapped answers by matching prompt where possible. - // This avoids relying on fixed indices since defs are now dynamic. - // Prefer id mapping when available - switch def.id { - case "workspace": - t.setup.Cfg.Agents.Defaults.Workspace = ans - case "openai_api_key": - t.setup.Cfg.Providers.OpenAI.APIKey = ans - case "default_model": - t.setup.Cfg.Agents.Defaults.Model = ans - default: - // fallback to prompt matching for backward compatibility - switch def.prompt { - case "Workspace path (e.g. ~/.picoclaw/workspace)": - t.setup.Cfg.Agents.Defaults.Workspace = ans - case "OpenAI API key (leave empty to skip)": - t.setup.Cfg.Providers.OpenAI.APIKey = ans - case "Default model name (e.g. gpt-5.2)": - t.setup.Cfg.Agents.Defaults.Model = ans - } - } - case "yesno": - // map yes/no to booleans - yes := t.selIdx == 0 - // map by prompt instead of index - if def.prompt == "Enable OpenAI web search?" { - t.setup.Cfg.Providers.OpenAI.WebSearch = yes - } - // install-skills prompt left as a TODO action - case "select": - // set selected provider - sel := def.options[t.selIdx] - // prefer id-based mapping - if def.id == "provider" { - t.setup.Cfg.Agents.Defaults.Provider = sel - } else { - t.setup.Cfg.Agents.Defaults.Provider = sel - } - } - - if t.cursor < len(t.defs)-1 { - t.cursor++ - // prepare next input - if t.defs[t.cursor].kind == "text" { - ti := textinput.New() - ti.Placeholder = t.defs[t.cursor].prompt - ti.CharLimit = 512 - // prefill from config by inspecting the prompt - // prefill using id when present, otherwise prompt match - switch t.defs[t.cursor].id { - case "workspace": - ti.SetValue(t.setup.Cfg.Agents.Defaults.Workspace) - case "openai_api_key": - ti.SetValue(t.setup.Cfg.Providers.OpenAI.APIKey) - case "default_model": - ti.SetValue(t.setup.Cfg.Agents.Defaults.Model) - default: - switch t.defs[t.cursor].prompt { - case "Workspace path (e.g. ~/.picoclaw/workspace)": - ti.SetValue(t.setup.Cfg.Agents.Defaults.Workspace) - case "OpenAI API key (leave empty to skip)": - ti.SetValue(t.setup.Cfg.Providers.OpenAI.APIKey) - case "Default model name (e.g. gpt-5.2)": - ti.SetValue(t.setup.Cfg.Agents.Defaults.Model) - } - } - ti.Focus() - t.ti = ti - } else { - t.ti.Blur() - // set selection index from existing config if available - if t.defs[t.cursor].kind == "select" { - for i, o := range t.defs[t.cursor].options { - if o == t.setup.Cfg.Agents.Defaults.Provider { - t.selIdx = i - break - } - } - } else if t.defs[t.cursor].kind == "yesno" { - // try to map known yesno prompts to config - if t.defs[t.cursor].prompt == "Enable OpenAI web search?" { - if t.setup.Cfg.Providers.OpenAI.WebSearch { - t.selIdx = 0 - } else { - t.selIdx = 1 - } - } else { - t.selIdx = 0 - } - } else { - t.selIdx = 0 - } - } - return t, nil - } - // last step committed - t.state = "done" + case "ctrl+c": + return t, tea.Quit + case "esc": + if t.state == "intro" || t.state == "done" { return t, tea.Quit } - case "left": - // For select/yesno steps, move selection left - if t.state == "questions" && len(t.defs) > 0 { - k := t.defs[t.cursor].kind - if (k == "yesno" || k == "select") && t.selIdx > 0 { - t.selIdx-- - } - } - case "right": - // For select/yesno steps, move selection right - if t.state == "questions" && len(t.defs) > 0 { - k := t.defs[t.cursor].kind - if k == "yesno" || k == "select" { - if t.selIdx < len(t.defs[t.cursor].options)-1 { - t.selIdx++ - } - } - } - case "up": - // allow up to also move selection up for select/yesno - if t.state == "questions" && len(t.defs) > 0 { - k := t.defs[t.cursor].kind - if (k == "yesno" || k == "select") && t.selIdx > 0 { - t.selIdx-- - } - } - case "down": - // allow down to move selection down for select/yesno - if t.state == "questions" && len(t.defs) > 0 { - k := t.defs[t.cursor].kind - if k == "yesno" || k == "select" { - if t.selIdx < len(t.defs[t.cursor].options)-1 { - t.selIdx++ - } - } + return t.prevQuestion() + case "q": + if t.state == "intro" || t.state == "done" { + return t, tea.Quit } } + + switch t.state { + case "intro": + return t.handleIntro(msg) + case "questions": + return t.handleQuestions(msg) + case "done": + return t, tea.Quit + } } return t, nil } -func (t tuiModel) View() string { - // Styles - // Keep styles simple and avoid lipgloss alignment/layout, which can - // cause flex-like behavior. We'll control left offset manually. - bannerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("63")) - aboutStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) - stepStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250")) - selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) +func (t tuiModel) handleIntro(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + t.state = "questions" + t.sessionIdx = 0 + t.questionIdx = 0 + t.errorMsg = "" + t.registry = BuildSessionRegistry(t.setup.Cfg) + t.initSession() + return t, nil + } + return t, nil +} - out := "" - // render banner and about with a small left offset - out += lipgloss.NewStyle().PaddingLeft(1).Render(bannerStyle.Render(Banner)) + "\n" - out += lipgloss.NewStyle().PaddingLeft(1).Render(aboutStyle.Render(About)) + "\n\n" +func (t tuiModel) handleQuestions(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + session := t.registry.Sessions[t.sessionIdx] + visible := t.getVisibleQuestions(session) - // Show slideshow style: only current step prompt and progress - if t.state == "questions" && len(t.defs) > 0 { - total := len(t.defs) - out += stepStyle.Render(fmt.Sprintf("Step [%d/%d]\n", t.cursor+1, total)) - // separator between step header and prompt - sep := lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Render(strings.Repeat("─", 0)) - out += sep + "\n" - cur := t.defs[t.cursor] - promptStyle := lipgloss.NewStyle().Bold(true) - out += promptStyle.Render(cur.prompt) + "\n\n" - // optional informational label (may be empty) - if cur.info != "" { - // Label style: bold, no left margin - // labelStyle := lipgloss.NewStyle().Bold(true) - // Info style: secondary light color, small padding to separate from label - infoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244")).PaddingLeft(0) - out += infoStyle.Render(cur.info) + "\n\n" + if len(visible) == 0 { + return t, nil + } + + if t.questionIdx >= len(visible) { + t.questionIdx = len(visible) - 1 + } + + currentQ := visible[t.questionIdx] + + switch msg.String() { + case "enter": + // Save current answer + t.saveAnswer(currentQ) + t.errorMsg = "" + + // Ensure text inputs exist for newly visible questions + t.ensureInputsExist() + + // Recalculate visible questions after saving (dependencies may change) + session = t.registry.Sessions[t.sessionIdx] + visible = t.getVisibleQuestions(session) + + // If not the last question, move to next question in same session + if t.questionIdx < len(visible)-1 { + t.questionIdx++ + t.focusCurrent() + return t, nil } - // show the input or selection - if cur.kind == "text" { - out += t.ti.View() + "\n" - } else if cur.kind == "yesno" || cur.kind == "select" { - for i, opt := range cur.options { - if i == t.selIdx { - out += selectedStyle.Render("→ "+opt) + "\n" - } else { - out += stepStyle.Render(" "+opt) + "\n" - } + // If last question, try to proceed to next session + return t.proceedToNextSession() + + case "tab": + t.errorMsg = "" + return t.nextQuestion() + + case "shift+tab": + t.errorMsg = "" + return t.prevQuestion() + + case "e": + // Go back to edit from confirmation session + if session.ID == "confirm" { + t.sessionIdx = 0 + t.questionIdx = 0 + t.initSession() + return t, nil + } + } + + // Handle input based on current question type + switch currentQ.Type { + case QuestionTypeText: + // Ensure text input exists + t.ensureTextInputExists(currentQ) + + if ti, ok := t.textInputs[currentQ.ID]; ok { + var cmd tea.Cmd + t.textInputs[currentQ.ID], cmd = ti.Update(msg) + return t, cmd + } + + case QuestionTypeSelect, QuestionTypeYesNo: + switch msg.String() { + case "up", "k": + t.errorMsg = "" + if t.selIdx[currentQ.ID] > 0 { + t.selIdx[currentQ.ID]-- + } + case "down", "j": + t.errorMsg = "" + if t.selIdx[currentQ.ID] < len(currentQ.Options)-1 { + t.selIdx[currentQ.ID]++ } } - out += "\n" - } else { - out += stepStyle.Render("Press Enter to begin the interactive setup") + "\n\n" } + return t, nil +} + +func (t tuiModel) proceedToNextSession() (tea.Model, tea.Cmd) { + session := t.registry.Sessions[t.sessionIdx] + visible := t.getVisibleQuestions(session) + + // Save all answers first + for _, q := range visible { + t.saveAnswer(q) + } + + // Validate all required fields are filled + missing := t.validateSession(visible) + if len(missing) > 0 { + t.errorMsg = fmt.Sprintf("Please fill: %s", strings.Join(missing, ", ")) + // Move to first missing field + for i, q := range visible { + if t.isQuestionEmpty(q) { + t.questionIdx = i + t.focusCurrent() + break + } + } + return t, nil + } + + t.errorMsg = "" + + if session.ID == "confirm" { + confirmVal := t.answers["confirm"] + if confirmVal == "yes" { + t.applyAnswersToConfig() + t.state = "done" + return t, tea.Quit + } + t.state = "intro" + t.sessionIdx = 0 + t.questionIdx = 0 + t.answers = make(map[string]string) + t.textInputs = make(map[string]textinput.Model) + t.selIdx = make(map[string]int) + return t, nil + } + + if t.sessionIdx < len(t.registry.Sessions)-1 { + t.sessionIdx++ + t.questionIdx = 0 + t.initSession() + } + + return t, nil +} + +func (t tuiModel) nextQuestion() (tea.Model, tea.Cmd) { + session := t.registry.Sessions[t.sessionIdx] + visible := t.getVisibleQuestions(session) + + if t.questionIdx < len(visible)-1 { + t.questionIdx++ + t.ensureInputsExist() + t.focusCurrent() + } + return t, nil +} + +func (t tuiModel) prevQuestion() (tea.Model, tea.Cmd) { + if t.questionIdx > 0 { + t.questionIdx-- + t.focusCurrent() + } else if t.sessionIdx > 0 { + // Go to previous session + t.sessionIdx-- + // Get the last question of the previous session + prevSession := t.registry.Sessions[t.sessionIdx] + prevVisible := t.getVisibleQuestions(prevSession) + if len(prevVisible) > 0 { + t.questionIdx = len(prevVisible) - 1 + } else { + t.questionIdx = 0 + } + t.initSession() + } + return t, nil +} + +func (t *tuiModel) initSession() { + t.ensureInputsExist() + t.focusCurrent() +} + +func (t *tuiModel) ensureInputsExist() { + session := t.registry.Sessions[t.sessionIdx] + visible := t.getVisibleQuestions(session) + + for _, q := range visible { + switch q.Type { + case QuestionTypeText: + t.ensureTextInputExists(q) + case QuestionTypeSelect, QuestionTypeYesNo: + if _, ok := t.selIdx[q.ID]; !ok { + t.selIdx[q.ID] = t.getDefaultSelIdx(q) + } + } + } +} + +func (t *tuiModel) ensureTextInputExists(q Question) { + if q.Type != QuestionTypeText { + return + } + + // Only create if doesn't exist + if _, exists := t.textInputs[q.ID]; !exists { + ti := textinput.New() + + // Determine placeholder based on question type + switch q.ID { + case "provider_api_key": + // Get API key for the selected provider (from answers) + if provider := t.answers["provider"]; provider != "" { + ti.Placeholder = GetProviderAPIKey(t.setup.Cfg, provider) + } else if q.DefaultValue != "" { + ti.Placeholder = q.DefaultValue + } else { + ti.Placeholder = q.Info + } + case "provider_api_base": + // Get API base for the selected provider (from answers) + if provider := t.answers["provider"]; provider != "" { + ti.Placeholder = GetProviderAPIBase(t.setup.Cfg, provider) + } else if q.DefaultValue != "" { + ti.Placeholder = q.DefaultValue + } else { + ti.Placeholder = q.Info + } + default: + if q.DefaultValue != "" { + ti.Placeholder = q.DefaultValue + } else { + ti.Placeholder = q.Info + } + } + + ti.CharLimit = 512 + ti.Width = 40 + + if val, ok := t.answers[q.ID]; ok && val != "" { + ti.SetValue(val) + } else if q.DefaultValue != "" { + ti.SetValue(q.DefaultValue) + } + t.textInputs[q.ID] = ti + } +} + +func (t *tuiModel) getDefaultSelIdx(q Question) int { + defaultIdx := 0 + if val, ok := t.answers[q.ID]; ok && val != "" { + for i, opt := range q.Options { + if opt == val { + defaultIdx = i + break + } + } + } else if q.DefaultValue != "" { + for i, opt := range q.Options { + if opt == q.DefaultValue { + defaultIdx = i + break + } + } + } + return defaultIdx +} + +func (t *tuiModel) focusCurrent() { + session := t.registry.Sessions[t.sessionIdx] + visible := t.getVisibleQuestions(session) + + // Blur all text inputs + for id := range t.textInputs { + if ti, ok := t.textInputs[id]; ok { + ti.Blur() + t.textInputs[id] = ti + } + } + + // Focus current text input if applicable + if t.questionIdx < len(visible) { + q := visible[t.questionIdx] + if q.Type == QuestionTypeText { + t.ensureTextInputExists(q) + if ti, ok := t.textInputs[q.ID]; ok { + ti.Focus() + t.textInputs[q.ID] = ti + } + } + } +} + +func (t *tuiModel) getVisibleQuestions(session Session) []Question { + var visible []Question + for _, q := range session.Questions { + if t.isQuestionVisible(q) { + visible = append(visible, q) + } + } + return visible +} + +func (t *tuiModel) isQuestionVisible(q Question) bool { + if q.DependsOn == "" { + return true + } + depValue := t.answers[q.DependsOn] + if q.DependsValue != "" { + return depValue == q.DependsValue + } + return depValue != "" +} + +func (t *tuiModel) buildAnswersSummary() []string { + var summary []string + + // Workspace + if val := t.answers["workspace"]; val != "" { + summary = append(summary, fmt.Sprintf("Workspace: %s", val)) + } + if val := t.answers["restrict_workspace"]; val != "" { + summary = append(summary, fmt.Sprintf("Restrict to workspace: %s", val)) + } + + // Provider + if val := t.answers["provider"]; val != "" { + summary = append(summary, fmt.Sprintf("Provider: %s", val)) + } + if val := t.answers["provider_api_key"]; val != "" { + summary = append(summary, fmt.Sprintf("API Key: %s", maskSensitive(val))) + } + + // Model + if val := t.answers["model_select"]; val != "" { + if val == "custom" { + if customVal := t.answers["custom_model"]; customVal != "" { + summary = append(summary, fmt.Sprintf("Model: %s (custom)", customVal)) + } + } else { + summary = append(summary, fmt.Sprintf("Model: %s", val)) + } + } + + // Channel + if val := t.answers["channel_select"]; val != "" { + summary = append(summary, fmt.Sprintf("Channel: %s", val)) + } + if val := t.answers["channel_token"]; val != "" { + summary = append(summary, fmt.Sprintf("Channel Token: %s", maskSensitive(val))) + } + + return summary +} + +func maskSensitive(val string) string { + if len(val) <= 4 { + return "****" + } + return val[:4] + strings.Repeat("*", len(val)-4) +} + +func (t *tuiModel) validateSession(questions []Question) []string { + var missing []string + for _, q := range questions { + // Skip optional fields + if q.ID == "provider_api_key" || q.ID == "provider_api_base" { + continue + } + // Skip channel token fields - they're optional based on channel selection + if isChannelTokenField(q.ID) { + continue + } + // Skip channel_enable if set to "no" + if q.ID == "channel_enable" && t.answers["channel_enable"] == "no" { + continue + } + if t.isQuestionEmpty(q) { + missing = append(missing, q.Prompt) + } + } + return missing +} + +func isChannelTokenField(id string) bool { + channelFields := []string{ + "telegram_token", "slack_bot_token", "slack_app_token", + "discord_token", "whatsapp_bridge_url", + "feishu_app_id", "feishu_app_secret", + "dingtalk_client_id", "dingtalk_client_secret", + "line_channel_access_token", "qq_app_id", + "onebot_access_token", + "wecom_token", "wecom_encoding_aes_key", + "wecom_app_corp_id", "wecom_app_agent_id", + "maixcam_device_address", + } + for _, f := range channelFields { + if id == f { + return true + } + } + return false +} + +func (t *tuiModel) isQuestionEmpty(q Question) bool { + switch q.Type { + case QuestionTypeText: + if ti, ok := t.textInputs[q.ID]; ok { + return strings.TrimSpace(ti.Value()) == "" + } + return true + case QuestionTypeSelect, QuestionTypeYesNo: + return false + } + return false +} + +func (t *tuiModel) saveAnswer(q Question) { + switch q.Type { + case QuestionTypeText: + if ti, ok := t.textInputs[q.ID]; ok { + t.answers[q.ID] = ti.Value() + } + case QuestionTypeSelect, QuestionTypeYesNo: + if idx, ok := t.selIdx[q.ID]; ok && idx >= 0 && idx < len(q.Options) { + t.answers[q.ID] = q.Options[idx] + } + } + + // Update channel question prompts when channel is selected + if q.ID == "channel_select" { + t.updateChannelQuestionPrompt() + } +} + +func (t *tuiModel) updateChannelQuestionPrompt() { + channel := t.answers["channel_select"] + if channel == "" { + return + } + + ch := strings.ToLower(channel) + channelFieldIDs := getChannelFieldIDs(ch) + + for i := range t.registry.Sessions { + if t.registry.Sessions[i].ID == "channel" { + for j := range t.registry.Sessions[i].Questions { + q := &t.registry.Sessions[i].Questions[j] + for _, fieldID := range channelFieldIDs { + if q.ID == fieldID { + q.DefaultValue = GetChannelFieldToken(t.setup.Cfg, fieldID) + if ti, ok := t.textInputs[fieldID]; ok { + ti.SetValue(q.DefaultValue) + t.textInputs[fieldID] = ti + } + } + } + } + break + } + } +} + +func getChannelFieldIDs(channel string) []string { + switch channel { + case "telegram": + return []string{"telegram_token"} + case "slack": + return []string{"slack_bot_token", "slack_app_token"} + case "discord": + return []string{"discord_token"} + case "whatsapp": + return []string{"whatsapp_bridge_url"} + case "feishu": + return []string{"feishu_app_id", "feishu_app_secret"} + case "dingtalk": + return []string{"dingtalk_client_id", "dingtalk_client_secret"} + case "line": + return []string{"line_channel_access_token"} + case "qq": + return []string{"qq_app_id"} + case "onebot": + return []string{"onebot_access_token"} + case "wecom": + return []string{"wecom_token", "wecom_encoding_aes_key"} + case "wecom_app": + return []string{"wecom_app_corp_id", "wecom_app_agent_id"} + case "maixcam": + return []string{"maixcam_device_address"} + } + return nil +} + +func (t *tuiModel) applyAnswersToConfig() { + cfg := t.setup.Cfg + + for qID, val := range t.answers { + if val == "" { + continue + } + + switch qID { + case "workspace": + cfg.Agents.Defaults.Workspace = val + case "restrict_workspace": + cfg.Agents.Defaults.RestrictToWorkspace = (val == "yes") + case "provider": + cfg.Agents.Defaults.Provider = val + case "model_select": + if val != "custom" { + cfg.Agents.Defaults.Model = val + } + case "custom_model": + if t.answers["model_select"] == "custom" { + cfg.Agents.Defaults.Model = val + } + case "channel_select": + if t.answers["channel_enable"] != "no" { + t.applyChannelSelection(val, cfg) + } + case "channel_enable": + if val == "no" { + cfg.Channels.Telegram.Enabled = false + cfg.Channels.Slack.Enabled = false + cfg.Channels.Discord.Enabled = false + cfg.Channels.WhatsApp.Enabled = false + cfg.Channels.Feishu.Enabled = false + cfg.Channels.DingTalk.Enabled = false + cfg.Channels.LINE.Enabled = false + cfg.Channels.QQ.Enabled = false + cfg.Channels.OneBot.Enabled = false + cfg.Channels.WeCom.Enabled = false + cfg.Channels.WeComApp.Enabled = false + cfg.Channels.MaixCam.Enabled = false + } + case "telegram_token": + if t.answers["channel_enable"] != "no" { + cfg.Channels.Telegram.Token = val + } + case "slack_bot_token": + if t.answers["channel_enable"] != "no" { + cfg.Channels.Slack.BotToken = val + } + case "slack_app_token": + if t.answers["channel_enable"] != "no" { + cfg.Channels.Slack.AppToken = val + } + case "discord_token": + if t.answers["channel_enable"] != "no" { + cfg.Channels.Discord.Token = val + } + case "whatsapp_bridge_url": + if t.answers["channel_enable"] != "no" { + cfg.Channels.WhatsApp.BridgeURL = val + } + case "feishu_app_id": + if t.answers["channel_enable"] != "no" { + cfg.Channels.Feishu.AppID = val + } + case "feishu_app_secret": + if t.answers["channel_enable"] != "no" { + cfg.Channels.Feishu.AppSecret = val + } + case "dingtalk_client_id": + if t.answers["channel_enable"] != "no" { + cfg.Channels.DingTalk.ClientID = val + } + case "dingtalk_client_secret": + if t.answers["channel_enable"] != "no" { + cfg.Channels.DingTalk.ClientSecret = val + } + case "line_channel_access_token": + if t.answers["channel_enable"] != "no" { + cfg.Channels.LINE.ChannelAccessToken = val + } + case "qq_app_id": + if t.answers["channel_enable"] != "no" { + cfg.Channels.QQ.AppID = val + } + case "onebot_access_token": + if t.answers["channel_enable"] != "no" { + cfg.Channels.OneBot.AccessToken = val + } + case "wecom_token": + if t.answers["channel_enable"] != "no" { + cfg.Channels.WeCom.Token = val + } + case "wecom_encoding_aes_key": + if t.answers["channel_enable"] != "no" { + cfg.Channels.WeCom.EncodingAESKey = val + } + case "wecom_app_corp_id": + if t.answers["channel_enable"] != "no" { + cfg.Channels.WeComApp.CorpID = val + } + case "wecom_app_agent_id": + if t.answers["channel_enable"] != "no" { + if agentID, err := strconv.ParseInt(val, 10, 64); err == nil { + cfg.Channels.WeComApp.AgentID = agentID + } + } + case "maixcam_device_address": + if t.answers["channel_enable"] != "no" { + val = strings.TrimPrefix(strings.TrimPrefix(val, "http://"), "https://") + if idx := strings.Index(val, ":"); idx > 0 { + cfg.Channels.MaixCam.Host = val[:idx] + if port, err := strconv.Atoi(val[idx+1:]); err == nil { + cfg.Channels.MaixCam.Port = port + } + } else { + cfg.Channels.MaixCam.Host = val + } + } + case "provider_api_key": + prov := t.answers["provider"] + if prov != "" { + SetProviderCredential(cfg, prov, "api_key", val) + } + case "provider_api_base": + prov := t.answers["provider"] + if prov != "" { + SetProviderCredential(cfg, prov, "api_base", val) + } + } + } +} + +func (t *tuiModel) applyChannelSelection(channel string, cfg *config.Config) { + ch := strings.ToLower(channel) + switch ch { + case "telegram": + cfg.Channels.Telegram.Enabled = true + case "slack": + cfg.Channels.Slack.Enabled = true + case "discord": + cfg.Channels.Discord.Enabled = true + case "whatsapp": + cfg.Channels.WhatsApp.Enabled = true + case "feishu": + cfg.Channels.Feishu.Enabled = true + case "dingtalk": + cfg.Channels.DingTalk.Enabled = true + case "line": + cfg.Channels.LINE.Enabled = true + case "qq": + cfg.Channels.QQ.Enabled = true + case "onebot": + cfg.Channels.OneBot.Enabled = true + case "wecom": + cfg.Channels.WeCom.Enabled = true + case "wecom_app": + cfg.Channels.WeComApp.Enabled = true + case "maixcam": + cfg.Channels.MaixCam.Enabled = true + } +} + +func (t tuiModel) View() string { + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + promptStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("86")).Bold(true) + optionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) + successStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("82")).Bold(true) + + var b strings.Builder + + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(titleStyle.Render("╔════════════════════════════════════════════════╗"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(titleStyle.Render("║ PicoClaw Interactive Setup ║"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(titleStyle.Render("╚════════════════════════════════════════════════╝"))) + b.WriteString("\n\n") + switch t.state { case "intro": - out += lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Italic(true).Render("Press Enter to start interactive setup — q to quit") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("Press Enter to start the configuration wizard"))) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("Press q or Ctrl+C to exit"))) + case "questions": - out += lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Render("Use Up/Down to move between steps; type and Enter to submit; q to quit.") + "\n\n" - // (current step rendering done above) + session := t.registry.Sessions[t.sessionIdx] + visible := t.getVisibleQuestions(session) + totalSessions := len(t.registry.Sessions) + + header := fmt.Sprintf("Step %d of %d: %s", t.sessionIdx+1, totalSessions, session.Title) + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(titleStyle.Render(header))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))) + b.WriteString("\n\n") + + if t.errorMsg != "" { + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(errorStyle.Render("⚠ " + t.errorMsg))) + b.WriteString("\n\n") + } + + for i, q := range visible { + isActive := i == t.questionIdx + + prompt := q.Prompt + if isActive { + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(activeStyle.Render("▶ " + prompt))) + } else { + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(promptStyle.Render(" " + prompt))) + } + b.WriteString("\n") + + switch q.Type { + case QuestionTypeText: + t.ensureTextInputExists(q) + if ti, ok := t.textInputs[q.ID]; ok { + if isActive { + b.WriteString(lipgloss.NewStyle().PaddingLeft(6).Render(ti.View())) + } else { + val := ti.Value() + if val == "" { + b.WriteString(lipgloss.NewStyle().PaddingLeft(6).Render(dimStyle.Render("(not filled)"))) + } else { + b.WriteString(lipgloss.NewStyle().PaddingLeft(6).Render(optionStyle.Render(val))) + } + } + } + + case QuestionTypeSelect, QuestionTypeYesNo: + idx, ok := t.selIdx[q.ID] + if !ok { + idx = 0 + } + for j, opt := range q.Options { + var line string + if j == idx { + if isActive { + line = " ◆ " + opt + b.WriteString(lipgloss.NewStyle().PaddingLeft(6).Render(selectedStyle.Render(line))) + } else { + line = " ○ " + opt + b.WriteString(lipgloss.NewStyle().PaddingLeft(6).Render(optionStyle.Render(line))) + } + } else { + line = " " + opt + b.WriteString(lipgloss.NewStyle().PaddingLeft(6).Render(dimStyle.Render(line))) + } + b.WriteString("\n") + } + } + b.WriteString("\n") + } + + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("─────────────────────────────────────────────────"))) + b.WriteString("\n") + + // Show summary in confirmation session + if session.ID == "confirm" { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(titleStyle.Render("Configuration Summary:"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))) + b.WriteString("\n\n") + + summary := t.buildAnswersSummary() + for _, line := range summary { + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(line))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("Press 'e' to go back and edit, or confirm below:"))) + b.WriteString("\n\n") + } + + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("─────────────────────────────────────────────────"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("↑/↓: Select option | Tab/Enter: Next | Esc: Back | e: Edit (in confirm) | q: Quit"))) + case "done": - out += lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Render("Setup complete — saving config and exiting...") + workspace := t.setup.Cfg.WorkspacePath() + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(successStyle.Render("✓ picoclaw is ready!"))) + b.WriteString("\n\n") + for _, line := range BuildSummary(t.setup.Cfg) { + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(line))) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(titleStyle.Render("Workspace Path:"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(workspace))) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(titleStyle.Render("Workspace structure:"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(" 📁 memory/ - Persistent memory"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(" 📁 skills/ - Custom skills"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(" 📄 AGENT.md - Agent configuration"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(" 📄 IDENTITY.md - Agent identity"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(" 📄 SOUL.md - Agent personality"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render(" 📄 USER.md - User profile"))) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("Next: Run 'picoclaw agent' to start chatting!"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("Config: ~/.picoclaw/config.json"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("─────────────────────────────────────────────────"))) + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().PaddingLeft(4).Render(dimStyle.Render("Press Enter or q to exit"))) } - // Apply global left padding so the whole UI shifts right by 3 columns. - return lipgloss.NewStyle().PaddingLeft(3).Render(out) + return b.String() } - -// Where to add questions/forms/selectors: -// - Implement the interactive questions inside the Update/View branches for state == "questions". -// - Use the charmbracelet/bubbles components (textinput, list, radiobuttons, checkbox) to build forms. -// - Example plan: -// 1) Create a field list (or pages) for missing items: models without API keys, providers, workspace path. -// 2) For each missing item, show a text input (bubbles/textinput) and let the user type and submit. -// 3) Store answers directly in t.setup.Cfg (it's a pointer) so they persist. -// 4) After all fields are answered, transition to state "done" which will cause Run() to save the config.