feat: add clickable terminal links with configurable open behavior (#401)
* feat: add clickable terminal links with configurable open behavior Add @xterm/addon-web-links to both passthrough and shell terminals, enabling Cmd+Click on URLs. Users can configure whether links open in a new browser tab (default) or the built-in browser panel via a new setting in the Settings page. * style: fix prettier formatting * fix: address CodeRabbit review comments - Extract applyTerminalLinkBehavior helper to reduce complexity - Set default terminalLinkBehavior for first-time users in empty settings path - Use storeApi.getState() for optimistic rollback to avoid stale closures - Add beforeEach settings reset in E2E tests - Use 4xx range assertion instead of hardcoded 500 * fix: return 400 for user settings validation errors Add ErrValidation sentinel to user service and wrap validation errors from apply* functions. Handler now returns 400 Bad Request instead of 500 Internal Server Error for invalid input. Update E2E test assertion to enforce client error status (< 500). * fix: remove cmd+click mention from terminal links description * fix: mirror validation error handling in WS update path * style: fix prettier formatting
This commit is contained in:
@@ -63,6 +63,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS
|
||||
DefaultUtilityAgentID: req.DefaultUtilityAgentID,
|
||||
DefaultUtilityModel: req.DefaultUtilityModel,
|
||||
KeyboardShortcuts: req.KeyboardShortcuts,
|
||||
TerminalLinkBehavior: req.TerminalLinkBehavior,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.UserSettingsResponse{}, err
|
||||
|
||||
@@ -34,6 +34,7 @@ type UserSettingsDTO struct {
|
||||
DefaultUtilityAgentID string `json:"default_utility_agent_id"`
|
||||
DefaultUtilityModel string `json:"default_utility_model"`
|
||||
KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"`
|
||||
TerminalLinkBehavior string `json:"terminal_link_behavior"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ type UpdateUserSettingsRequest struct {
|
||||
DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"`
|
||||
DefaultUtilityModel *string `json:"default_utility_model,omitempty"`
|
||||
KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"`
|
||||
TerminalLinkBehavior *string `json:"terminal_link_behavior,omitempty"`
|
||||
}
|
||||
|
||||
func FromUser(user *models.User) UserDTO {
|
||||
@@ -105,6 +107,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO {
|
||||
DefaultUtilityAgentID: settings.DefaultUtilityAgentID,
|
||||
DefaultUtilityModel: settings.DefaultUtilityModel,
|
||||
KeyboardShortcuts: settings.KeyboardShortcuts,
|
||||
TerminalLinkBehavior: settings.TerminalLinkBehavior,
|
||||
UpdatedAt: settings.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/user/controller"
|
||||
"github.com/kandev/kandev/internal/user/dto"
|
||||
"github.com/kandev/kandev/internal/user/service"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -71,8 +73,12 @@ func (h *Handlers) httpUpdateUserSettings(c *gin.Context) {
|
||||
}
|
||||
resp, err := h.controller.UpdateUserSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, service.ErrValidation) {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
h.logger.Error("failed to update user settings", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user settings"})
|
||||
c.JSON(status, gin.H{"error": "failed to update user settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
@@ -93,7 +99,11 @@ func (h *Handlers) wsUpdateUserSettings(ctx context.Context, msg *ws.Message) (*
|
||||
}
|
||||
resp, err := h.controller.UpdateUserSettings(ctx, req)
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update user settings", nil)
|
||||
code := ws.ErrorCodeInternalError
|
||||
if errors.Is(err, service.ErrValidation) {
|
||||
code = ws.ErrorCodeBadRequest
|
||||
}
|
||||
return ws.NewError(msg.ID, msg.Action, code, "Failed to update user settings", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type UserSettings struct {
|
||||
DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents
|
||||
DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents
|
||||
KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides
|
||||
TerminalLinkBehavior string `json:"terminal_link_behavior"` // "new_tab" | "browser_panel"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrValidation = errors.New("validation error")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo store.Repository
|
||||
@@ -45,6 +48,7 @@ type UpdateUserSettingsRequest struct {
|
||||
DefaultUtilityAgentID *string
|
||||
DefaultUtilityModel *string
|
||||
KeyboardShortcuts *map[string]interface{}
|
||||
TerminalLinkBehavior *string
|
||||
}
|
||||
|
||||
func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service {
|
||||
@@ -95,16 +99,16 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting
|
||||
return nil, err
|
||||
}
|
||||
if err := applyBasicSettings(settings, req); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
|
||||
}
|
||||
if err := s.applyChatSubmitKey(settings, req); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
|
||||
}
|
||||
if err := applyLSPSettings(settings, req); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
|
||||
}
|
||||
if err := applySavedLayouts(settings, req); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error())
|
||||
}
|
||||
settings.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpsertUserSettings(ctx, settings); err != nil {
|
||||
@@ -161,6 +165,21 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe
|
||||
}
|
||||
settings.KeyboardShortcuts = *req.KeyboardShortcuts
|
||||
}
|
||||
if err := applyTerminalLinkBehavior(settings, req.TerminalLinkBehavior); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyTerminalLinkBehavior(settings *models.UserSettings, value *string) error {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimSpace(*value)
|
||||
if v != "new_tab" && v != "browser_panel" {
|
||||
return errors.New("terminal_link_behavior must be 'new_tab' or 'browser_panel'")
|
||||
}
|
||||
settings.TerminalLinkBehavior = v
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -243,6 +262,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models
|
||||
"default_utility_agent_id": settings.DefaultUtilityAgentID,
|
||||
"default_utility_model": settings.DefaultUtilityModel,
|
||||
"keyboard_shortcuts": settings.KeyboardShortcuts,
|
||||
"terminal_link_behavior": settings.TerminalLinkBehavior,
|
||||
"updated_at": settings.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil {
|
||||
|
||||
@@ -153,6 +153,7 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod
|
||||
"default_utility_agent_id": settings.DefaultUtilityAgentID,
|
||||
"default_utility_model": settings.DefaultUtilityModel,
|
||||
"keyboard_shortcuts": keyboardShortcuts,
|
||||
"terminal_link_behavior": settings.TerminalLinkBehavior,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -196,6 +197,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
settings.ReviewAutoMarkOnScroll = true
|
||||
settings.ChatSubmitKey = "cmd_enter"
|
||||
settings.KeyboardShortcuts = map[string]interface{}{}
|
||||
settings.TerminalLinkBehavior = "new_tab"
|
||||
return settings, nil
|
||||
}
|
||||
var payload struct {
|
||||
@@ -218,6 +220,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
DefaultUtilityAgentID string `json:"default_utility_agent_id"`
|
||||
DefaultUtilityModel string `json:"default_utility_model"`
|
||||
KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"`
|
||||
TerminalLinkBehavior string `json:"terminal_link_behavior"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil {
|
||||
return nil, err
|
||||
@@ -266,5 +269,10 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin
|
||||
if settings.KeyboardShortcuts == nil {
|
||||
settings.KeyboardShortcuts = map[string]interface{}{}
|
||||
}
|
||||
if payload.TerminalLinkBehavior != "" {
|
||||
settings.TerminalLinkBehavior = payload.TerminalLinkBehavior
|
||||
} else {
|
||||
settings.TerminalLinkBehavior = "new_tab"
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
Generated
+69
-10
@@ -350,6 +350,9 @@ importers:
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: ^0.9.0
|
||||
version: 0.9.0
|
||||
'@xterm/addon-web-links':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
'@xterm/addon-webgl':
|
||||
specifier: ^0.19.0
|
||||
version: 0.19.0
|
||||
@@ -394,7 +397,7 @@ importers:
|
||||
version: 0.55.1
|
||||
next:
|
||||
specifier: 16.1.5
|
||||
version: 16.1.5(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 16.1.5(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -448,7 +451,7 @@ importers:
|
||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
styled-jsx:
|
||||
specifier: 5.1.6
|
||||
version: 5.1.6(react@19.2.3)
|
||||
version: 5.1.6(@babel/core@7.28.6)(react@19.2.3)
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
@@ -1277,89 +1280,105 @@ packages:
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.2.4':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.2.4':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.2.4':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.2.4':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.2.4':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linux-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-arm@0.34.5':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.34.5':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.34.5':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-s390x@0.34.5':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linux-x64@0.34.5':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.34.5':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.34.5':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
|
||||
@@ -1556,24 +1575,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.5':
|
||||
resolution: {integrity: sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.5':
|
||||
resolution: {integrity: sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.5':
|
||||
resolution: {integrity: sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.5':
|
||||
resolution: {integrity: sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==}
|
||||
@@ -2410,66 +2433,79 @@ packages:
|
||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||
@@ -2587,24 +2623,28 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||
@@ -3231,41 +3271,49 @@ packages:
|
||||
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
|
||||
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
|
||||
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
|
||||
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
|
||||
@@ -3311,6 +3359,9 @@ packages:
|
||||
'@xterm/addon-unicode11@0.9.0':
|
||||
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
|
||||
|
||||
'@xterm/addon-web-links@0.12.0':
|
||||
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
|
||||
|
||||
'@xterm/addon-webgl@0.19.0':
|
||||
resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==}
|
||||
|
||||
@@ -5044,24 +5095,28 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.2:
|
||||
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||
@@ -9672,6 +9727,8 @@ snapshots:
|
||||
|
||||
'@xterm/addon-unicode11@0.9.0': {}
|
||||
|
||||
'@xterm/addon-web-links@0.12.0': {}
|
||||
|
||||
'@xterm/addon-webgl@0.19.0': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
@@ -10617,7 +10674,7 @@ snapshots:
|
||||
'@next/eslint-plugin-next': 16.1.1
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
@@ -10640,7 +10697,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
@@ -10655,14 +10712,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10677,7 +10734,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -12230,7 +12287,7 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
next@16.1.5(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@16.1.5(@babel/core@7.28.6)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
'@next/env': 16.1.5
|
||||
'@swc/helpers': 0.5.15
|
||||
@@ -12239,7 +12296,7 @@ snapshots:
|
||||
postcss: 8.4.31
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
styled-jsx: 5.1.6(react@19.2.3)
|
||||
styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.5
|
||||
'@next/swc-darwin-x64': 16.1.5
|
||||
@@ -13374,10 +13431,12 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.3):
|
||||
styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.6
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createEditor, deleteEditor, updateEditor, updateUserSettings } from "@/
|
||||
import { useRequest } from "@/lib/http/use-request";
|
||||
import type { EditorOption } from "@/lib/types/http";
|
||||
import { type ComboboxOption } from "@/components/combobox";
|
||||
import { parseTerminalLinkBehavior } from "@/lib/ssr/user-settings";
|
||||
import {
|
||||
type EditorFormState,
|
||||
buildConfig,
|
||||
@@ -238,6 +239,7 @@ function buildUserSettingsFromResponse(
|
||||
enablePreviewOnClick: s.enable_preview_on_click ?? false,
|
||||
defaultUtilityAgentId: s.default_utility_agent_id || null,
|
||||
keyboardShortcuts: s.keyboard_shortcuts ?? {},
|
||||
terminalLinkBehavior: parseTerminalLinkBehavior(s.terminal_link_behavior),
|
||||
...mapEditorSettingsFields(s),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { IconCommand, IconPalette, IconServer, IconKeyboard } from "@tabler/icons-react";
|
||||
import { IconCommand, IconLink, IconPalette, IconServer, IconKeyboard } from "@tabler/icons-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
@@ -12,7 +12,7 @@ import { SettingsSection } from "@/components/settings/settings-section";
|
||||
import { ShellSettingsCard } from "@/components/settings/shell-settings-card";
|
||||
import { KeyboardShortcutsCard } from "@/components/settings/keyboard-shortcuts-card";
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { updateUserSettings } from "@/lib/api";
|
||||
import type { Theme } from "@/lib/settings/types";
|
||||
|
||||
@@ -98,6 +98,62 @@ function ChatSubmitKeyCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalLinksCard() {
|
||||
const userSettings = useAppStore((state) => state.userSettings);
|
||||
const setUserSettings = useAppStore((state) => state.setUserSettings);
|
||||
const storeApi = useAppStoreApi();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleChange = async (value: "new_tab" | "browser_panel") => {
|
||||
if (isSaving) return;
|
||||
setIsSaving(true);
|
||||
const current = storeApi.getState().userSettings;
|
||||
const previous = current.terminalLinkBehavior;
|
||||
try {
|
||||
setUserSettings({ ...current, terminalLinkBehavior: value });
|
||||
await updateUserSettings({
|
||||
workspace_id: current.workspaceId || "",
|
||||
repository_ids: current.repositoryIds || [],
|
||||
terminal_link_behavior: value,
|
||||
});
|
||||
} catch {
|
||||
setUserSettings({
|
||||
...storeApi.getState().userSettings,
|
||||
terminalLinkBehavior: previous,
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Terminal Links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="terminal-link-behavior">Open links in</Label>
|
||||
<Select
|
||||
value={userSettings.terminalLinkBehavior}
|
||||
onValueChange={(v) => handleChange(v as "new_tab" | "browser_panel")}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger id="terminal-link-behavior">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new_tab">New browser tab</SelectItem>
|
||||
<SelectItem value="browser_panel">Built-in browser panel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Click a URL in the terminal to open it.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BackendConnectionCard() {
|
||||
const [backendUrl] = useState<string>(() => getBackendConfig().apiBaseUrl);
|
||||
const displayBackendUrl = backendUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
@@ -154,6 +210,16 @@ export function GeneralSettings() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconLink className="h-5 w-5" />}
|
||||
title="Terminal Links"
|
||||
description="Configure how clickable URLs in the terminal are opened"
|
||||
>
|
||||
<TerminalLinksCard />
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
icon={<IconKeyboard className="h-5 w-5" />}
|
||||
title="Chat Input"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { useSession } from "@/hooks/domains/session/use-session";
|
||||
import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl";
|
||||
import { getBackendConfig } from "@/lib/config";
|
||||
import { useTerminalLinkHandler } from "@/hooks/use-terminal-link-handler";
|
||||
import {
|
||||
log,
|
||||
MIN_WIDTH,
|
||||
@@ -89,6 +90,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
|
||||
setConnectedSessionId(sessionId ?? null);
|
||||
}, [sessionId]);
|
||||
|
||||
const linkHandler = useTerminalLinkHandler();
|
||||
const sendResize = useSendResize(wsRef);
|
||||
const fitAndResize = useFitAndResize({
|
||||
xtermRef,
|
||||
@@ -108,6 +110,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
|
||||
webglAddonRef,
|
||||
fitAndResize,
|
||||
onReady: onTerminalReady,
|
||||
linkHandler,
|
||||
});
|
||||
|
||||
useWebSocketConnection({
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import { useSession } from "@/hooks/domains/session/use-session";
|
||||
import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl";
|
||||
import { getTerminalTheme } from "@/lib/theme/terminal-theme";
|
||||
import { useTerminalLinkHandler } from "@/hooks/use-terminal-link-handler";
|
||||
|
||||
type ShellTerminalProps = {
|
||||
sessionId?: string;
|
||||
@@ -30,6 +32,7 @@ function useTerminalInit(
|
||||
isReadOnlyMode: boolean,
|
||||
taskId: string | null,
|
||||
sessionId: string | null | undefined,
|
||||
linkHandler?: (event: MouseEvent, uri: string) => void,
|
||||
) {
|
||||
const { terminalRef, xtermRef, fitAddonRef, lastOutputLengthRef, outputRef } = refs;
|
||||
|
||||
@@ -45,6 +48,8 @@ function useTerminalInit(
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
const webLinksAddon = new WebLinksAddon(linkHandler);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
xtermRef.current = terminal;
|
||||
@@ -89,6 +94,7 @@ function useTerminalInit(
|
||||
taskId,
|
||||
sessionId,
|
||||
isReadOnlyMode,
|
||||
linkHandler,
|
||||
terminalRef,
|
||||
xtermRef,
|
||||
fitAddonRef,
|
||||
@@ -276,8 +282,9 @@ export function ShellTerminal({
|
||||
if (client) client.send({ type: "request", action, payload });
|
||||
}, []);
|
||||
|
||||
const linkHandler = useTerminalLinkHandler();
|
||||
const refs: TerminalRefs = { terminalRef, xtermRef, fitAddonRef, lastOutputLengthRef, outputRef };
|
||||
useTerminalInit(refs, isReadOnlyMode, taskId, sessionId);
|
||||
useTerminalInit(refs, isReadOnlyMode, taskId, sessionId, linkHandler);
|
||||
|
||||
// Handle user input (interactive mode only)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { AttachAddon } from "@xterm/addon-attach";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { getTerminalTheme } from "@/lib/theme/terminal-theme";
|
||||
|
||||
@@ -16,6 +17,20 @@ export const log = (...args: unknown[]) => {
|
||||
export const MIN_WIDTH = 100;
|
||||
export const MIN_HEIGHT = 100;
|
||||
|
||||
type TerminalContainerWithBuffer = HTMLDivElement & { __xtermReadBuffer?: () => string };
|
||||
|
||||
/** Expose buffer reader on the container for e2e tests (xterm renders to canvas). */
|
||||
function exposeBufferReader(container: HTMLDivElement, terminal: Terminal) {
|
||||
(container as TerminalContainerWithBuffer).__xtermReadBuffer = () => {
|
||||
const buf = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i <= buf.baseY + buf.cursorY; i++) {
|
||||
lines.push(buf.getLine(i)?.translateToString(true) ?? "");
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
}
|
||||
|
||||
export type TerminalInitOptions = {
|
||||
terminalRef: React.RefObject<HTMLDivElement | null>;
|
||||
xtermRef: React.MutableRefObject<Terminal | null>;
|
||||
@@ -40,6 +55,7 @@ function initTerminalInstance(
|
||||
| "resizeTimeoutRef"
|
||||
>,
|
||||
fitAndResize: (force?: boolean) => void,
|
||||
linkHandler?: (event: MouseEvent, uri: string) => void,
|
||||
) {
|
||||
if (refs.isInitializedRef.current || refs.xtermRef.current) return undefined;
|
||||
refs.isInitializedRef.current = true;
|
||||
@@ -60,6 +76,8 @@ function initTerminalInstance(
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.unicode.activeVersion = "11";
|
||||
const webLinksAddon = new WebLinksAddon(linkHandler);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
log("Opening terminal in container");
|
||||
terminal.open(termContainer);
|
||||
try {
|
||||
@@ -91,17 +109,7 @@ function initTerminalInstance(
|
||||
log("WebGL failed, using canvas:", e);
|
||||
}
|
||||
});
|
||||
// Expose buffer reader on the container for e2e tests (xterm renders to
|
||||
// canvas so text isn't accessible in the DOM).
|
||||
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
|
||||
() => {
|
||||
const buf = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i <= buf.baseY + buf.cursorY; i++) {
|
||||
lines.push(buf.getLine(i)?.translateToString(true) ?? "");
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
exposeBufferReader(termContainer, terminal);
|
||||
const handleResize = () => {
|
||||
const rect = termContainer.getBoundingClientRect();
|
||||
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) {
|
||||
@@ -124,8 +132,7 @@ function initTerminalInstance(
|
||||
refs.webglAddonRef.current = null;
|
||||
}
|
||||
terminal.dispose();
|
||||
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
|
||||
undefined;
|
||||
(termContainer as TerminalContainerWithBuffer).__xtermReadBuffer = undefined;
|
||||
refs.xtermRef.current = null;
|
||||
refs.fitAddonRef.current = null;
|
||||
refs.isInitializedRef.current = false;
|
||||
@@ -143,7 +150,8 @@ export function useTerminalInit({
|
||||
webglAddonRef,
|
||||
fitAndResize,
|
||||
onReady,
|
||||
}: TerminalInitOptions) {
|
||||
linkHandler,
|
||||
}: TerminalInitOptions & { linkHandler?: (event: MouseEvent, uri: string) => void }) {
|
||||
const refs = {
|
||||
xtermRef,
|
||||
fitAddonRef,
|
||||
@@ -169,7 +177,7 @@ export function useTerminalInit({
|
||||
const rect = container.getBoundingClientRect();
|
||||
log("Init check: dimensions", rect.width, "x", rect.height);
|
||||
if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) {
|
||||
initTerminalInstance(container, refs, fitAndResize);
|
||||
initTerminalInstance(container, refs, fitAndResize, linkHandler);
|
||||
onReady();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,15 @@ export type MockCheckRun = {
|
||||
export class ApiClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
|
||||
/** Perform an HTTP request and return the raw Response (does not throw on non-2xx). */
|
||||
async rawRequest(method: string, path: string, body?: unknown): Promise<Response> {
|
||||
return fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
@@ -254,10 +263,17 @@ export class ApiClient {
|
||||
return this.request("GET", "/api/v1/executors");
|
||||
}
|
||||
|
||||
async getUserSettings(): Promise<{
|
||||
settings: { terminal_link_behavior?: string; [key: string]: unknown };
|
||||
}> {
|
||||
return this.request("GET", "/api/v1/user/settings");
|
||||
}
|
||||
|
||||
async saveUserSettings(settings: {
|
||||
enable_preview_on_click?: boolean;
|
||||
workspace_id?: string;
|
||||
workflow_filter_id?: string;
|
||||
terminal_link_behavior?: "new_tab" | "browser_panel";
|
||||
}): Promise<void> {
|
||||
await this.request("PATCH", "/api/v1/user/settings", settings);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect } from "../fixtures/test-base";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal clickable links — settings persistence and UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("terminal clickable links", () => {
|
||||
test.beforeEach(async ({ apiClient }) => {
|
||||
await apiClient.saveUserSettings({ terminal_link_behavior: "new_tab" });
|
||||
});
|
||||
|
||||
test("terminal_link_behavior defaults to new_tab and can be updated via API", async ({
|
||||
apiClient,
|
||||
}) => {
|
||||
// Default value
|
||||
const initial = await apiClient.getUserSettings();
|
||||
expect(initial.settings.terminal_link_behavior).toBe("new_tab");
|
||||
|
||||
// Switch to browser_panel
|
||||
await apiClient.saveUserSettings({ terminal_link_behavior: "browser_panel" });
|
||||
const updated = await apiClient.getUserSettings();
|
||||
expect(updated.settings.terminal_link_behavior).toBe("browser_panel");
|
||||
|
||||
// Revert to new_tab
|
||||
await apiClient.saveUserSettings({ terminal_link_behavior: "new_tab" });
|
||||
const reverted = await apiClient.getUserSettings();
|
||||
expect(reverted.settings.terminal_link_behavior).toBe("new_tab");
|
||||
});
|
||||
|
||||
test("rejects invalid terminal_link_behavior values", async ({ apiClient }) => {
|
||||
const res = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
terminal_link_behavior: "invalid_value",
|
||||
});
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
|
||||
// Setting should remain unchanged
|
||||
const current = await apiClient.getUserSettings();
|
||||
expect(current.settings.terminal_link_behavior).toBe("new_tab");
|
||||
});
|
||||
|
||||
test("settings page shows terminal links card and allows toggling", async ({ testPage }) => {
|
||||
await testPage.goto("/settings");
|
||||
|
||||
// Terminal Links section visible
|
||||
await expect(testPage.locator("text=Terminal Links").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Select trigger is visible
|
||||
const trigger = testPage.locator("#terminal-link-behavior");
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
// Switch to "Built-in browser panel"
|
||||
await trigger.click();
|
||||
const browserOption = testPage.getByRole("option", { name: "Built-in browser panel" });
|
||||
await expect(browserOption).toBeVisible();
|
||||
await browserOption.click();
|
||||
await expect(trigger).toHaveText(/Built-in browser panel/);
|
||||
|
||||
// Switch back to "New browser tab"
|
||||
await trigger.click();
|
||||
const tabOption = testPage.getByRole("option", { name: "New browser tab" });
|
||||
await expect(tabOption).toBeVisible();
|
||||
await tabOption.click();
|
||||
await expect(trigger).toHaveText(/New browser tab/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
|
||||
/**
|
||||
* Returns a stable callback for handling terminal link clicks.
|
||||
* Reads the user's `terminalLinkBehavior` setting to decide whether
|
||||
* to open URLs in a new browser tab or the built-in browser panel.
|
||||
*/
|
||||
export function useTerminalLinkHandler(): (event: MouseEvent, uri: string) => void {
|
||||
const behaviorRef = useRef<"new_tab" | "browser_panel">("new_tab");
|
||||
const behavior = useAppStore((s) => s.userSettings.terminalLinkBehavior);
|
||||
|
||||
useEffect(() => {
|
||||
behaviorRef.current = behavior;
|
||||
}, [behavior]);
|
||||
|
||||
return useCallback((_event: MouseEvent, uri: string) => {
|
||||
if (behaviorRef.current === "browser_panel") {
|
||||
const api = useDockviewStore.getState().api;
|
||||
if (api) {
|
||||
const browserId = `browser:${Date.now()}`;
|
||||
api.addPanel({
|
||||
id: browserId,
|
||||
component: "browser",
|
||||
title: "Browser",
|
||||
params: { url: uri },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.open(uri, "_blank", "noopener,noreferrer");
|
||||
}, []);
|
||||
}
|
||||
@@ -40,6 +40,9 @@ function carryForwardSettings(current: DisplaySettings) {
|
||||
savedLayouts: current.savedLayouts ?? [],
|
||||
defaultUtilityAgentId: current.defaultUtilityAgentId ?? null,
|
||||
keyboardShortcuts: current.keyboardShortcuts ?? {},
|
||||
terminalLinkBehavior: (current.terminalLinkBehavior ?? "new_tab") as
|
||||
| "new_tab"
|
||||
| "browser_panel",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function updateUserSettings(
|
||||
default_utility_agent_id?: string;
|
||||
default_utility_model?: string;
|
||||
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
terminal_link_behavior?: "new_tab" | "browser_panel";
|
||||
},
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { SavedLayout, UserSettingsResponse } from "@/lib/types/http";
|
||||
|
||||
export type UserSettingsData = NonNullable<UserSettingsResponse["settings"]>;
|
||||
|
||||
export function parseTerminalLinkBehavior(value: string | undefined): "new_tab" | "browser_panel" {
|
||||
return value === "browser_panel" ? "browser_panel" : "new_tab";
|
||||
}
|
||||
|
||||
export function buildCoreFields(s: UserSettingsData) {
|
||||
return {
|
||||
workspaceId: s.workspace_id || null,
|
||||
@@ -18,6 +22,7 @@ export function buildCoreFields(s: UserSettingsData) {
|
||||
savedLayouts: s.saved_layouts ?? [],
|
||||
defaultUtilityAgentId: s.default_utility_agent_id || null,
|
||||
keyboardShortcuts: s.keyboard_shortcuts ?? {},
|
||||
terminalLinkBehavior: parseTerminalLinkBehavior(s.terminal_link_behavior),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +58,7 @@ export function mapUserSettingsResponse(response: UserSettingsResponse | null) {
|
||||
savedLayouts: [] as SavedLayout[],
|
||||
defaultUtilityAgentId: null,
|
||||
keyboardShortcuts: {} as Record<string, { key: string; modifiers?: Record<string, boolean> }>,
|
||||
terminalLinkBehavior: "new_tab" as const,
|
||||
...buildLspFields(undefined),
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export const defaultSettingsState: SettingsSliceState = {
|
||||
shellOptions: [],
|
||||
defaultEditorId: null,
|
||||
enablePreviewOnClick: false,
|
||||
terminalLinkBehavior: "new_tab",
|
||||
chatSubmitKey: "cmd_enter",
|
||||
reviewAutoMarkOnScroll: true,
|
||||
showReleaseNotification: true,
|
||||
|
||||
@@ -118,6 +118,7 @@ export type UserSettingsState = {
|
||||
savedLayouts: SavedLayout[];
|
||||
defaultUtilityAgentId: string | null;
|
||||
keyboardShortcuts: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
terminalLinkBehavior: "new_tab" | "browser_panel";
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -374,6 +374,7 @@ export type UserSettingsUpdatedPayload = {
|
||||
saved_layouts?: SavedLayout[];
|
||||
default_utility_agent_id?: string;
|
||||
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
terminal_link_behavior?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -374,6 +374,7 @@ export type UserSettings = {
|
||||
default_utility_agent_id?: string;
|
||||
default_utility_model?: string;
|
||||
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
terminal_link_behavior?: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ export function registerUsersHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
savedLayouts: message.payload.saved_layouts ?? [],
|
||||
defaultUtilityAgentId: message.payload.default_utility_agent_id || null,
|
||||
keyboardShortcuts: message.payload.keyboard_shortcuts ?? {},
|
||||
terminalLinkBehavior:
|
||||
message.payload.terminal_link_behavior === "browser_panel"
|
||||
? "browser_panel"
|
||||
: "new_tab",
|
||||
loaded: true,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
Reference in New Issue
Block a user