From 64a540bc8a477191e9719fe6e15e2a5d55ade1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 17 Jan 2026 02:00:34 +0000 Subject: [PATCH] feat: shell selector (#27) --- apps/backend/cmd/kandev/main.go | 2 +- .../agentctl/server/process/manager.go | 19 +++ .../internal/agentctl/server/shell/session.go | 29 +++- .../internal/integration/orchestrator_test.go | 2 +- .../orchestrator/executor/executor.go | 31 ++++ apps/backend/internal/orchestrator/service.go | 2 + .../internal/user/controller/controller.go | 33 +++- apps/backend/internal/user/dto/dto.go | 11 +- .../internal/user/handlers/handlers.go | 4 + apps/backend/internal/user/models/models.go | 1 + apps/backend/internal/user/service/service.go | 14 ++ apps/backend/internal/user/store/sqlite.go | 3 + apps/backend/pkg/api/v1/user.go | 11 +- apps/web/app/page.tsx | 1 + apps/web/app/settings/general/page.tsx | 148 +++++++++++++++++- apps/web/hooks/use-user-display-settings.ts | 11 +- apps/web/lib/http/index.ts | 7 +- apps/web/lib/state/store.ts | 9 +- apps/web/lib/types/backend.ts | 1 + apps/web/lib/types/http.ts | 2 + apps/web/lib/ws/handlers/users.ts | 1 + 21 files changed, 324 insertions(+), 18 deletions(-) diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 60b8094fc7..6aadf2ee5c 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -417,7 +417,7 @@ func main() { } serviceCfg := orchestrator.DefaultServiceConfig() - orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, log) + orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, userSvc, log) taskSvc.SetExecutionStopper(orchestratorSvc) // Set up comment creator for saving agent responses as comments diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 6935e2b825..c11e3fe175 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os/exec" + "strings" "sync" "sync/atomic" "time" @@ -221,6 +222,7 @@ func (m *Manager) Start(ctx context.Context) error { // Auto-create shell session if enabled if m.cfg.ShellEnabled { shellCfg := shell.DefaultConfig(m.cfg.WorkDir) + shellCfg.ShellCommand = preferredShellCommand(m.cfg.AgentEnv) shellSession, err := shell.NewSession(shellCfg, m.logger) if err != nil { m.logger.Warn("failed to create shell session", zap.Error(err)) @@ -237,6 +239,23 @@ func (m *Manager) Start(ctx context.Context) error { return nil } +func preferredShellCommand(env []string) string { + if value := lookupEnvValue(env, "AGENTCTL_SHELL_COMMAND"); value != "" { + return value + } + return lookupEnvValue(env, "SHELL") +} + +func lookupEnvValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} + // Configure sets the agent command and optional environment variables. // This must be called before Start() if the instance was created without a command. func (m *Manager) Configure(command string, env map[string]string) error { diff --git a/apps/backend/internal/agentctl/server/shell/session.go b/apps/backend/internal/agentctl/server/shell/session.go index b5c15cedd7..85b73ef4ab 100644 --- a/apps/backend/internal/agentctl/server/shell/session.go +++ b/apps/backend/internal/agentctl/server/shell/session.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "runtime" + "strings" "sync" "time" @@ -53,9 +54,11 @@ const maxOutputBufferSize = 16 * 1024 // Config holds shell session configuration type Config struct { - WorkDir string // Working directory (default: workspace) - Cols int // Initial terminal columns (default: 80) - Rows int // Initial terminal rows (default: 24) + WorkDir string // Working directory (default: workspace) + Cols int // Initial terminal columns (default: 80) + Rows int // Initial terminal rows (default: 24) + ShellCommand string // Optional shell command override + ShellArgs []string // Optional shell args override } // DefaultConfig returns the default shell configuration @@ -109,6 +112,14 @@ func detectShell() (string, []string) { // This is called automatically when agentctl starts. func NewSession(cfg Config, log *logger.Logger) (*Session, error) { shell, args := detectShell() + if cfg.ShellCommand != "" { + shell = cfg.ShellCommand + if len(cfg.ShellArgs) > 0 { + args = cfg.ShellArgs + } else { + args = defaultShellArgs(shell) + } + } s := &Session{ logger: log.WithFields(zap.String("component", "shell")), @@ -128,6 +139,17 @@ func NewSession(cfg Config, log *logger.Logger) (*Session, error) { return s, nil } +func defaultShellArgs(shellCommand string) []string { + if runtime.GOOS == "windows" { + lower := strings.ToLower(shellCommand) + if strings.Contains(lower, "pwsh") || strings.Contains(lower, "powershell") { + return []string{"-NoLogo", "-NoExit"} + } + return nil + } + return []string{"-l"} +} + // start initializes and starts the shell process func (s *Session) start(cfg Config) error { s.mu.Lock() @@ -400,4 +422,3 @@ func buildShellEnv(workDir string) []string { return env } - diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 3a89f29f2a..17048a83c0 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -522,7 +522,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { // Create orchestrator service cfg := orchestrator.DefaultServiceConfig() cfg.Scheduler.ProcessInterval = 50 * time.Millisecond // Faster for tests - orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, log) + orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, nil, log) // Create WebSocket gateway gateway := gateways.NewGateway(log) diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 0d6e1fc50c..a077b9ab53 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -143,6 +143,7 @@ func agentSessionStateToV1(state models.TaskSessionState) v1.TaskSessionState { type Executor struct { agentManager AgentManagerClient repo repository.Repository + shellPrefs ShellPreferenceProvider logger *logger.Logger // In-memory cache for fast lookups (synced with database) @@ -160,6 +161,11 @@ type Executor struct { type ExecutorConfig struct { MaxConcurrent int // Maximum concurrent agent executions WorktreeEnabled bool // Whether to use Git worktrees for agent isolation + ShellPrefs ShellPreferenceProvider +} + +type ShellPreferenceProvider interface { + PreferredShell(ctx context.Context) (string, error) } // NewExecutor creates a new executor @@ -171,6 +177,7 @@ func NewExecutor(agentManager AgentManagerClient, repo repository.Repository, lo return &Executor{ agentManager: agentManager, repo: repo, + shellPrefs: cfg.ShellPrefs, logger: log.WithFields(zap.String("component", "executor")), executions: make(map[string]*TaskExecution), maxConcurrent: maxConcurrent, @@ -185,6 +192,26 @@ func (e *Executor) SetWorktreeEnabled(enabled bool) { e.worktreeEnabled = enabled } +func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]string) map[string]string { + if e.shellPrefs == nil { + return env + } + preferred, err := e.shellPrefs.PreferredShell(ctx) + if err != nil { + return env + } + preferred = strings.TrimSpace(preferred) + if preferred == "" { + return env + } + if env == nil { + env = make(map[string]string) + } + env["AGENTCTL_SHELL_COMMAND"] = preferred + env["SHELL"] = preferred + return env +} + // LoadActiveSessionsFromDB loads active agent sessions from the database into memory // This should be called on startup to restore state after a restart func (e *Executor) LoadActiveSessionsFromDB(ctx context.Context) error { @@ -354,6 +381,8 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP zap.String("agent_profile_id", agentProfileID), zap.Bool("use_worktree", req.UseWorktree)) + req.Env = e.applyPreferredShellEnv(ctx, req.Env) + // Call the AgentManager to launch the container resp, err := e.agentManager.LaunchAgent(ctx, req) if err != nil { @@ -544,6 +573,8 @@ func (e *Executor) ResumeSession(ctx context.Context, task *v1.Task, session *mo zap.String("acp_session_id", req.ACPSessionID), zap.Bool("use_worktree", req.UseWorktree)) + req.Env = e.applyPreferredShellEnv(ctx, req.Env) + resp, err := e.agentManager.LaunchAgent(ctx, req) if err != nil { e.logger.Error("failed to relaunch agent for session", diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 69a1c1385e..dbcf47237f 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -116,6 +116,7 @@ func NewService( agentManager executor.AgentManagerClient, taskRepo scheduler.TaskRepository, repo repository.Repository, + shellPrefs executor.ShellPreferenceProvider, log *logger.Logger, ) *Service { svcLogger := log.WithFields(zap.String("component", "orchestrator")) @@ -127,6 +128,7 @@ func NewService( execCfg := executor.ExecutorConfig{ MaxConcurrent: cfg.Scheduler.MaxConcurrent, WorktreeEnabled: cfg.WorktreeEnabled, + ShellPrefs: shellPrefs, } exec := executor.NewExecutor(agentManager, repo, log, execCfg) diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 97d68eb8d6..111760aad7 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -2,6 +2,7 @@ package controller import ( "context" + "runtime" "github.com/kandev/kandev/internal/user/dto" "github.com/kandev/kandev/internal/user/service" @@ -35,7 +36,10 @@ func (c *Controller) GetUserSettings(ctx context.Context) (dto.UserSettingsRespo if err != nil { return dto.UserSettingsResponse{}, err } - return dto.UserSettingsResponse{Settings: dto.FromUserSettings(settings)}, nil + return dto.UserSettingsResponse{ + Settings: dto.FromUserSettings(settings), + ShellOptions: shellOptionsForOS(), + }, nil } func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserSettingsRequest) (dto.UserSettingsResponse, error) { @@ -44,9 +48,34 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS BoardID: req.BoardID, RepositoryIDs: req.RepositoryIDs, InitialSetupComplete: req.InitialSetupComplete, + PreferredShell: req.PreferredShell, }) if err != nil { return dto.UserSettingsResponse{}, err } - return dto.UserSettingsResponse{Settings: dto.FromUserSettings(settings)}, nil + return dto.UserSettingsResponse{ + Settings: dto.FromUserSettings(settings), + ShellOptions: shellOptionsForOS(), + }, nil +} + +func shellOptionsForOS() []dto.ShellOption { + switch runtime.GOOS { + case "windows": + return []dto.ShellOption{ + {Value: "auto", Label: "System default"}, + {Value: "pwsh.exe", Label: "PowerShell (pwsh)"}, + {Value: "powershell.exe", Label: "Windows PowerShell"}, + {Value: "cmd.exe", Label: "Command Prompt"}, + {Value: "custom", Label: "Custom"}, + } + default: + return []dto.ShellOption{ + {Value: "auto", Label: "System default"}, + {Value: "/bin/zsh", Label: "zsh"}, + {Value: "/bin/bash", Label: "bash"}, + {Value: "/bin/sh", Label: "sh"}, + {Value: "custom", Label: "Custom"}, + } + } } diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 4f98a8cf9f..1115afe338 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -19,6 +19,7 @@ type UserSettingsDTO struct { BoardID string `json:"board_id"` RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` UpdatedAt string `json:"updated_at"` } @@ -28,7 +29,13 @@ type UserResponse struct { } type UserSettingsResponse struct { - Settings UserSettingsDTO `json:"settings"` + Settings UserSettingsDTO `json:"settings"` + ShellOptions []ShellOption `json:"shell_options"` +} + +type ShellOption struct { + Value string `json:"value"` + Label string `json:"label"` } type UpdateUserSettingsRequest struct { @@ -36,6 +43,7 @@ type UpdateUserSettingsRequest struct { BoardID *string `json:"board_id,omitempty"` RepositoryIDs *[]string `json:"repository_ids,omitempty"` InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` + PreferredShell *string `json:"preferred_shell,omitempty"` } func FromUser(user *models.User) UserDTO { @@ -54,6 +62,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { BoardID: settings.BoardID, RepositoryIDs: settings.RepositoryIDs, InitialSetupComplete: settings.InitialSetupComplete, + PreferredShell: settings.PreferredShell, UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } } diff --git a/apps/backend/internal/user/handlers/handlers.go b/apps/backend/internal/user/handlers/handlers.go index 473f45581e..e84a6b58df 100644 --- a/apps/backend/internal/user/handlers/handlers.go +++ b/apps/backend/internal/user/handlers/handlers.go @@ -68,6 +68,7 @@ type httpUpdateUserSettingsRequest struct { BoardID *string `json:"board_id,omitempty"` RepositoryIDs *[]string `json:"repository_ids,omitempty"` InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` + PreferredShell *string `json:"preferred_shell,omitempty"` } func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { @@ -81,6 +82,7 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) { BoardID: body.BoardID, RepositoryIDs: body.RepositoryIDs, InitialSetupComplete: body.InitialSetupComplete, + PreferredShell: body.PreferredShell, }) if err != nil { h.logger.Error("failed to update user settings", zap.Error(err)) @@ -103,6 +105,7 @@ type wsUpdateUserSettingsRequest struct { BoardID *string `json:"board_id,omitempty"` RepositoryIDs *[]string `json:"repository_ids,omitempty"` InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` + PreferredShell *string `json:"preferred_shell,omitempty"` } func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -115,6 +118,7 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (* BoardID: req.BoardID, RepositoryIDs: req.RepositoryIDs, InitialSetupComplete: req.InitialSetupComplete, + PreferredShell: req.PreferredShell, }) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil) diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 4bf1637831..6f02d88738 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -15,6 +15,7 @@ type UserSettings struct { BoardID string `json:"board_id"` RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 1124b8391b..669babef8b 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "strings" "time" "github.com/kandev/kandev/internal/common/logger" @@ -27,6 +28,7 @@ type UpdateUserSettingsRequest struct { BoardID *string RepositoryIDs *[]string InitialSetupComplete *bool + PreferredShell *string } func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { @@ -54,6 +56,14 @@ func (s *Service) GetUserSettings(ctx context.Context) (*models.UserSettings, er return settings, nil } +func (s *Service) PreferredShell(ctx context.Context) (string, error) { + settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + if err != nil { + return "", err + } + return settings.PreferredShell, nil +} + func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSettingsRequest) (*models.UserSettings, error) { settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) if err != nil { @@ -71,6 +81,9 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if req.InitialSetupComplete != nil { settings.InitialSetupComplete = *req.InitialSetupComplete } + if req.PreferredShell != nil { + settings.PreferredShell = strings.TrimSpace(*req.PreferredShell) + } settings.UpdatedAt = time.Now().UTC() if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { return nil, err @@ -89,6 +102,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "board_id": settings.BoardID, "repository_ids": settings.RepositoryIDs, "initial_setup_complete": settings.InitialSetupComplete, + "preferred_shell": settings.PreferredShell, "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index fa49740112..2c60bf24c5 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -191,6 +191,7 @@ func (r *SQLiteRepository) UpsertUserSettings(ctx context.Context, settings *mod "board_id": settings.BoardID, "repository_ids": settings.RepositoryIDs, "initial_setup_complete": settings.InitialSetupComplete, + "preferred_shell": settings.PreferredShell, }) if err != nil { return err @@ -233,6 +234,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin BoardID string `json:"board_id"` RepositoryIDs []string `json:"repository_ids"` InitialSetupComplete bool `json:"initial_setup_complete"` + PreferredShell string `json:"preferred_shell"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -241,5 +243,6 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.BoardID = payload.BoardID settings.RepositoryIDs = payload.RepositoryIDs settings.InitialSetupComplete = payload.InitialSetupComplete + settings.PreferredShell = payload.PreferredShell return settings, nil } diff --git a/apps/backend/pkg/api/v1/user.go b/apps/backend/pkg/api/v1/user.go index 90559f2f27..f02a4f5c9d 100644 --- a/apps/backend/pkg/api/v1/user.go +++ b/apps/backend/pkg/api/v1/user.go @@ -11,9 +11,10 @@ type User struct { } type UserSettings struct { - UserID string `json:"user_id"` - WorkspaceID string `json:"workspace_id"` - BoardID string `json:"board_id"` - RepositoryIDs []string `json:"repository_ids"` - UpdatedAt time.Time `json:"updated_at"` + UserID string `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + BoardID string `json:"board_id"` + RepositoryIDs []string `json:"repository_ids"` + PreferredShell string `json:"preferred_shell"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 85a196a554..7eb98a5f20 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -53,6 +53,7 @@ export default async function Page({ searchParams }: PageProps) { workspaceId: settingsWorkspaceId, boardId: settingsBoardId, repositoryIds: settingsRepositoryIds, + preferredShell: userSettings?.preferred_shell || null, loaded: Boolean(userSettings), }, }; diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx index 2bd8477cb4..0525c83aea 100644 --- a/apps/web/app/settings/general/page.tsx +++ b/apps/web/app/settings/general/page.tsx @@ -1,14 +1,18 @@ 'use client'; -import { useState, useSyncExternalStore } from 'react'; +import { useEffect, useState, useSyncExternalStore } from 'react'; import { useTheme } from 'next-themes'; import { IconPalette, IconCode, IconServer } from '@tabler/icons-react'; import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; +import { Button } from '@kandev/ui/button'; import { Label } from '@kandev/ui/label'; import { Input } from '@kandev/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; import { Separator } from '@kandev/ui/separator'; import { SettingsSection } from '@/components/settings/settings-section'; +import { useAppStore, useAppStoreApi } from '@/components/state-provider'; +import { fetchUserSettings, updateUserSettings } from '@/lib/http'; +import { useRequest } from '@/lib/http/use-request'; import { SETTINGS_DATA } from '@/lib/settings/dummy-data'; import { getBackendConfig } from '@/lib/config'; import type { Theme, Editor } from '@/lib/settings/types'; @@ -20,6 +24,30 @@ export default function GeneralSettingsPage() { SETTINGS_DATA.general.customEditorCommand || '' ); const [backendUrl] = useState(() => getBackendConfig().apiBaseUrl); + const [preferredShell, setPreferredShell] = useState(''); + const [customShell, setCustomShell] = useState(''); + const [shellSelection, setShellSelection] = useState('auto'); + const [baselineShell, setBaselineShell] = useState(''); + const [shellLoaded, setShellLoaded] = useState(false); + const [shellOptions, setShellOptions] = useState>([]); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const storeApi = useAppStoreApi(); + const saveShellRequest = useRequest(async () => { + const trimmed = preferredShell.trim(); + const currentSettings = storeApi.getState().userSettings; + await updateUserSettings({ + workspace_id: currentSettings.workspaceId ?? '', + board_id: currentSettings.boardId ?? '', + repository_ids: currentSettings.repositoryIds, + preferred_shell: trimmed, + }); + setBaselineShell(trimmed); + setUserSettings({ + ...currentSettings, + preferredShell: trimmed || null, + loaded: true, + }); + }); const mounted = useSyncExternalStore( () => () => undefined, () => true, @@ -27,6 +55,45 @@ export default function GeneralSettingsPage() { ); const displayBackendUrl = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, ''); + const shellDirty = preferredShell.trim() !== baselineShell.trim(); + + useEffect(() => { + let isActive = true; + fetchUserSettings({ cache: 'no-store' }) + .then((data) => { + if (!isActive || !data?.settings) return; + const shellValue = data.settings.preferred_shell || ''; + setPreferredShell(shellValue); + setBaselineShell(shellValue); + const nextShellOptions = data.shell_options ?? []; + setShellOptions(nextShellOptions); + if (shellValue === '') { + setShellSelection('auto'); + setCustomShell(''); + } else if (nextShellOptions.some((option) => option.value === shellValue)) { + setShellSelection(shellValue); + setCustomShell(''); + } else { + setShellSelection('custom'); + setCustomShell(shellValue); + } + setShellLoaded(true); + const currentSettings = storeApi.getState().userSettings; + setUserSettings({ + ...currentSettings, + preferredShell: shellValue || null, + loaded: true, + }); + }) + .catch(() => { + if (isActive) { + setShellLoaded(true); + } + }); + return () => { + isActive = false; + }; + }, [setUserSettings, storeApi]); if (!mounted) { return null; @@ -121,6 +188,85 @@ export default function GeneralSettingsPage() { + } + title="Shell" + description="Pick the default shell for task sessions" + > + + + Preferred Shell + + + +
+ +
+ {shellSelection === 'custom' && ( +
+ { + const nextValue = event.target.value; + setCustomShell(nextValue); + setPreferredShell(nextValue); + }} + placeholder="/bin/zsh" + /> +

+ Enter a shell path or command available in the agent environment. +

+
+ )} +

+ New task sessions will use this shell. Existing sessions keep their current shell. +

+
+
+
+ + + } title="Backend Connection" diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index a07d9bc799..69d7ffa4b9 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -9,6 +9,7 @@ type DisplaySettings = { workspaceId: string | null; boardId: string | null; repositoryIds: string[]; + preferredShell: string | null; loaded: boolean; }; @@ -23,6 +24,7 @@ type CommitPayload = { workspaceId: string | null; boardId: string | null; repositoryIds: string[]; + preferredShell?: string | null; }; export function useUserDisplaySettings({ @@ -38,7 +40,13 @@ export function useUserDisplaySettings({ const commitSettings = useCallback( (next: CommitPayload) => { const repositoryIds = Array.from(new Set(next.repositoryIds)).sort(); - const normalized: DisplaySettings = { ...next, repositoryIds, loaded: true }; + const normalized: DisplaySettings = { + workspaceId: next.workspaceId, + boardId: next.boardId, + repositoryIds, + preferredShell: next.preferredShell ?? userSettings.preferredShell ?? null, + loaded: true, + }; const sameWorkspace = normalized.workspaceId === userSettings.workspaceId; const sameBoard = normalized.boardId === userSettings.boardId; const sameRepos = @@ -77,6 +85,7 @@ export function useUserDisplaySettings({ workspaceId: data.settings.workspace_id || null, boardId: data.settings.board_id || null, repositoryIds, + preferredShell: data.settings.preferred_shell || null, loaded: true, }); }) diff --git a/apps/web/lib/http/index.ts b/apps/web/lib/http/index.ts index 41cef0aeee..a9f5ad0aa9 100644 --- a/apps/web/lib/http/index.ts +++ b/apps/web/lib/http/index.ts @@ -156,7 +156,12 @@ export async function fetchUserSettings(options?: ApiRequestOptions) { } export async function updateUserSettings( - payload: { workspace_id: string; board_id: string; repository_ids: string[] }, + payload: { + workspace_id: string; + board_id: string; + repository_ids: string[]; + preferred_shell?: string; + }, options?: ApiRequestOptions ) { return fetchJson('/api/v1/user/settings', { diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index e4e7b74a76..82c2db36dd 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -105,6 +105,7 @@ export type UserSettingsState = { workspaceId: string | null; boardId: string | null; repositoryIds: string[]; + preferredShell: string | null; loaded: boolean; }; @@ -281,7 +282,13 @@ const defaultState: AppState = { tasks: { activeTaskId: null }, agents: { agents: [] }, agentProfiles: { items: [], version: 0 }, - userSettings: { workspaceId: null, boardId: null, repositoryIds: [], loaded: false }, + userSettings: { + workspaceId: null, + boardId: null, + repositoryIds: [], + preferredShell: null, + loaded: false, + }, terminal: { terminals: [] }, shell: { outputs: {}, statuses: {} }, diffs: { files: [] }, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index a87d2b28b0..1dc61e9437 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -231,6 +231,7 @@ export type UserSettingsUpdatedPayload = { board_id: string; repository_ids: string[]; initial_setup_complete?: boolean; + preferred_shell?: string; updated_at?: string; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 1063313ee2..6ae78571c3 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -172,11 +172,13 @@ export type UserSettings = { board_id: string; repository_ids: string[]; initial_setup_complete?: boolean; + preferred_shell?: string; updated_at: string; }; export type UserSettingsResponse = { settings: UserSettings; + shell_options?: Array<{ value: string; label: string }>; }; export type UserResponse = { diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts index 2f21797a5b..c1e4ba4ecc 100644 --- a/apps/web/lib/ws/handlers/users.ts +++ b/apps/web/lib/ws/handlers/users.ts @@ -12,6 +12,7 @@ export function registerUsersHandlers(store: StoreApi): WsHandlers { workspaceId: message.payload.workspace_id || null, boardId: message.payload.board_id || null, repositoryIds, + preferredShell: message.payload.preferred_shell || null, loaded: true, }, }));