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:
José Almeida
2026-03-10 15:37:51 +00:00
committed by GitHub
parent c0daa1901b
commit 06e5114174
24 changed files with 358 additions and 35 deletions
@@ -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
+3
View File
@@ -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"`
}
+25 -5
View File
@@ -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
}
+69 -10
View File
@@ -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({
+8 -1
View File
@@ -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;
}
+16
View File
@@ -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);
}
+66
View File
@@ -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",
};
}
+1
View File
@@ -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,
) {
+6
View File
@@ -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;
};
+1
View File
@@ -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;
};
+1
View File
@@ -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;
};
+4
View File
@@ -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,
},
}));
+1
View File
@@ -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",