From 06e51141747b091b9b05c67f1d76e214879ebca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:37:51 +0000 Subject: [PATCH] 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 --- .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + .../internal/user/handlers/handlers.go | 14 +++- apps/backend/internal/user/models/models.go | 1 + apps/backend/internal/user/service/service.go | 30 +++++-- apps/backend/internal/user/store/sqlite.go | 8 ++ apps/pnpm-lock.yaml | 79 ++++++++++++++++--- .../settings/editors-settings-state.tsx | 2 + .../components/settings/general-settings.tsx | 70 +++++++++++++++- .../components/task/passthrough-terminal.tsx | 3 + apps/web/components/task/shell-terminal.tsx | 9 ++- .../task/use-passthrough-terminal.ts | 38 +++++---- apps/web/e2e/helpers/api-client.ts | 16 ++++ apps/web/e2e/tests/terminal-links.spec.ts | 66 ++++++++++++++++ apps/web/hooks/use-terminal-link-handler.ts | 34 ++++++++ apps/web/hooks/use-user-display-settings.ts | 3 + apps/web/lib/api/domains/settings-api.ts | 1 + apps/web/lib/ssr/user-settings.ts | 6 ++ .../state/slices/settings/settings-slice.ts | 1 + apps/web/lib/state/slices/settings/types.ts | 1 + apps/web/lib/types/backend.ts | 1 + apps/web/lib/types/http.ts | 1 + apps/web/lib/ws/handlers/users.ts | 4 + apps/web/package.json | 1 + 24 files changed, 358 insertions(+), 35 deletions(-) create mode 100644 apps/web/e2e/tests/terminal-links.spec.ts create mode 100644 apps/web/hooks/use-terminal-link-handler.ts diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index c4ddd7172f..0564d5901a 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -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 diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 7d0c2f84a7..64bf4e13ae 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -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), } } diff --git a/apps/backend/internal/user/handlers/handlers.go b/apps/backend/internal/user/handlers/handlers.go index 204abaa82a..db529a87f1 100644 --- a/apps/backend/internal/user/handlers/handlers.go +++ b/apps/backend/internal/user/handlers/handlers.go @@ -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) } diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index aeebfce210..ecace6ae23 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -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"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 861268f352..2e99cad515 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -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 { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index f0f2100483..487e55028b 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -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 } diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 4c0c1d1055..df60aeaa91 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -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: {} diff --git a/apps/web/components/settings/editors-settings-state.tsx b/apps/web/components/settings/editors-settings-state.tsx index 8f5defb70b..58f128546b 100644 --- a/apps/web/components/settings/editors-settings-state.tsx +++ b/apps/web/components/settings/editors-settings-state.tsx @@ -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), }; } diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 5214cd2b83..2dd6b036eb 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -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 ( + + + Terminal Links + + + + Open links in + handleChange(v as "new_tab" | "browser_panel")} + disabled={isSaving} + > + + + + + New browser tab + Built-in browser panel + + + Click a URL in the terminal to open it. + + + + ); +} + function BackendConnectionCard() { const [backendUrl] = useState(() => getBackendConfig().apiBaseUrl); const displayBackendUrl = backendUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""); @@ -154,6 +210,16 @@ export function GeneralSettings() { + } + title="Terminal Links" + description="Configure how clickable URLs in the terminal are opened" + > + + + + + } title="Chat Input" diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index 604697460d..1f35cd9568 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -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({ diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index ce592d8916..17b9beadc1 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -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(() => { diff --git a/apps/web/components/task/use-passthrough-terminal.ts b/apps/web/components/task/use-passthrough-terminal.ts index 385901d1a6..2f3af2edaf 100644 --- a/apps/web/components/task/use-passthrough-terminal.ts +++ b/apps/web/components/task/use-passthrough-terminal.ts @@ -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; xtermRef: React.MutableRefObject; @@ -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; } diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index feaa3ec2c8..d4edb6e060 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -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 { + return fetch(`${this.baseUrl}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + } + private async request(method: string, path: string, body?: unknown): Promise { 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 { await this.request("PATCH", "/api/v1/user/settings", settings); } diff --git a/apps/web/e2e/tests/terminal-links.spec.ts b/apps/web/e2e/tests/terminal-links.spec.ts new file mode 100644 index 0000000000..9d61644a81 --- /dev/null +++ b/apps/web/e2e/tests/terminal-links.spec.ts @@ -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/); + }); +}); diff --git a/apps/web/hooks/use-terminal-link-handler.ts b/apps/web/hooks/use-terminal-link-handler.ts new file mode 100644 index 0000000000..32f332ba47 --- /dev/null +++ b/apps/web/hooks/use-terminal-link-handler.ts @@ -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"); + }, []); +} diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index d7d39e22c6..9f09fd4254 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -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", }; } diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 37637cfd1b..2d3ed964be 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -46,6 +46,7 @@ export async function updateUserSettings( default_utility_agent_id?: string; default_utility_model?: string; keyboard_shortcuts?: Record }>; + terminal_link_behavior?: "new_tab" | "browser_panel"; }, options?: ApiRequestOptions, ) { diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index fcc66ada39..508b0dc265 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -2,6 +2,10 @@ import type { SavedLayout, UserSettingsResponse } from "@/lib/types/http"; export type UserSettingsData = NonNullable; +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 }>, + terminalLinkBehavior: "new_tab" as const, ...buildLspFields(undefined), loaded: false, }; diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index e4002d3e22..87cbd71d71 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -28,6 +28,7 @@ export const defaultSettingsState: SettingsSliceState = { shellOptions: [], defaultEditorId: null, enablePreviewOnClick: false, + terminalLinkBehavior: "new_tab", chatSubmitKey: "cmd_enter", reviewAutoMarkOnScroll: true, showReleaseNotification: true, diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 24a253d537..448bc93f1c 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -118,6 +118,7 @@ export type UserSettingsState = { savedLayouts: SavedLayout[]; defaultUtilityAgentId: string | null; keyboardShortcuts: Record }>; + terminalLinkBehavior: "new_tab" | "browser_panel"; loaded: boolean; }; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 9eb8e74613..a516feceed 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -374,6 +374,7 @@ export type UserSettingsUpdatedPayload = { saved_layouts?: SavedLayout[]; default_utility_agent_id?: string; keyboard_shortcuts?: Record }>; + terminal_link_behavior?: string; updated_at?: string; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 904371d5ae..628665bfa8 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -374,6 +374,7 @@ export type UserSettings = { default_utility_agent_id?: string; default_utility_model?: string; keyboard_shortcuts?: Record }>; + terminal_link_behavior?: string; updated_at: string; }; diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts index 04deef5d7d..a01b95b896 100644 --- a/apps/web/lib/ws/handlers/users.ts +++ b/apps/web/lib/ws/handlers/users.ts @@ -28,6 +28,10 @@ export function registerUsersHandlers(store: StoreApi): 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, }, })); diff --git a/apps/web/package.json b/apps/web/package.json index af80cc6763..862b529a47 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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",
Click a URL in the terminal to open it.